refactor(cache-shared): support template keys and drop sharedCasual API

Align SharedCache type system with Memory (UseCache) template support and
remove the sharedCasual escape hatch that existed only because SharedCache
could not type-check dynamic keys:

- Introduce InferSharedCacheValue and expand SharedCacheKey through
  ProcessKey so template schema entries like
  'web_search.provider.last_used_key.${providerId}' match concrete keys
  with precise value types on both Main and Renderer.
- Extend useSharedCache hook with findMatchingSharedCacheSchemaKey /
  getSharedCacheDefaultValue, mirroring useCache's template-aware default
  resolution.
- Remove getSharedCasual / setSharedCasual / hasSharedCasual /
  deleteSharedCasual / hasSharedTTLCasual from the Renderer CacheService
  and all test mocks; Main CacheService never had them.
- Migrate BaseWebSearchProvider and OcrBaseApiClient rotation from
  sharedCasual to type-safe getShared/setShared. Rename keys to conform
  to schema naming rules (ESLint data-schema-key/valid-key):
    web-search-provider:${id}:last_used_key
      -> web_search.provider.last_used_key.${providerId}
    ocr_provider:${id}:last_used_key
      -> ocr.provider.last_used_key.${providerId}
  Old values under legacy key names become orphans after rollout; this
  is acceptable because rotation state is transient and consumers
  reinitialize from keys[0] on a miss.
- Add type-level assertions for SharedCacheKey and InferSharedCacheValue
  in useCache.types.test.ts to lock the contract in CI.
- Update cache-overview.md, cache-usage.md, and tests/__mocks__/README.md
  to describe SharedCache's template support and reflect that casual
  methods now exist only on the Memory tier.
This commit is contained in:
fullex
2026-04-22 19:25:54 -07:00
parent bb9482b8fc
commit 3fbc52e056
13 changed files with 226 additions and 216 deletions

View File

@@ -53,7 +53,7 @@ cacheService.set('temp.calculation', result, 30000)
### Type Safety
- **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`)
- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position.${id}'` matches `'scroll.position.topic123'`)
- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys)
- **Casual methods**: For completely dynamic keys with manual typing (Memory tier only; blocked from using schema-defined keys)
Note: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www`
@@ -138,7 +138,7 @@ For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.
| ------------ | --------------------------------- | --------------------------------- | -------------- |
| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic |
| Template key | `'scroll.position.${id}': number` | `get('scroll.position.topic123')` | Automatic |
| Casual key | N/A | `getCasual<T>('my.custom.key')` | Manual |
| Casual key | N/A (Memory only) | `getCasual<T>('my.custom.key')` | Manual |
### API Reference
@@ -146,6 +146,5 @@ For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.
| ----------------------------------------------- | ------- | --------------------------------------- |
| `useCache` / `get` / `set` | Memory | Fixed + Template keys |
| `getCasual` / `setCasual` | Memory | Dynamic keys only (schema keys blocked) |
| `useSharedCache` / `getShared` / `setShared` | Shared | Fixed keys only |
| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys only (schema keys blocked) |
| `useSharedCache` / `getShared` / `setShared` | Shared | Fixed + Template keys |
| `usePersistCache` / `getPersist` / `setPersist` | Persist | Fixed keys only |

View File

@@ -87,17 +87,17 @@ cacheService.deleteCasual(`topic:${id}`);
### Shared Cache
```typescript
// Type-safe (schema key)
// Fixed key (schema-defined)
cacheService.setShared("window.layout", layoutConfig);
const layout = cacheService.getShared("window.layout");
// Casual (dynamic key)
cacheService.setSharedCasual<WindowState>(`window:${windowId}`, state);
const state = cacheService.getSharedCasual<WindowState>(`window:${windowId}`);
// Template key (schema: 'window.state.${windowId}')
// Type is inferred automatically from the matching schema entry
cacheService.setShared(`window.state.${windowId}` as const, state);
const state = cacheService.getShared(`window.state.${windowId}` as const);
// Delete
cacheService.deleteShared("window.layout");
cacheService.deleteSharedCasual(`window:${windowId}`);
```
### Persist Cache
@@ -138,7 +138,7 @@ if (cacheService.hasShared("window.layout")) {
cacheService.deleteShared("window.layout");
```
**Note**: Main CacheService does NOT support Casual methods (`getSharedCasual`, etc.). Only schema-based type-safe access is available in Main process.
**Note**: SharedCache supports both fixed and template keys on Main and Renderer (aligned with Memory cache). Casual (schema-bypassing) access is not supported on SharedCache — if you need a dynamic key that isn't worth schematising, use Memory `getCasual` / `setCasual` instead.
### Sync Strategy
@@ -159,8 +159,9 @@ cacheService.deleteShared("window.layout");
const [counter, setCounter] = useCache("ui.counter", 0);
```
### Casual Methods
### Casual Methods (Memory tier only)
- Available only on the **Memory** tier (`getCasual` / `setCasual` / `hasCasual` / `deleteCasual` / `hasTTLCasual`). Shared and Persist tiers do not expose casual variants — all Shared access goes through the schema (fixed or template keys).
- Use dynamically constructed keys
- Require manual type specification via generics
- No compile-time key validation
@@ -175,6 +176,8 @@ cacheService.getCasual("app.user.avatar"); // Error: matches fixed key
cacheService.getCasual("scroll.position.topic123"); // Error: matches template key
```
If you need a cross-window dynamic key, define a template key in `SharedCacheSchema` and use the type-safe `getShared` / `setShared` — there is no `getSharedCasual`.
### Template Keys
Template keys provide type-safe caching for dynamic key patterns. Define a template in the schema using `${variable}` syntax, and TypeScript will automatically match and infer types for concrete keys.
@@ -220,13 +223,14 @@ cacheService.set("scroll.position.mytopic", "hi"); // Error: type mismatch
#### Template Key Benefits
| Feature | Fixed Keys | Template Keys | Casual Methods |
| ----------------------- | ------------ | ---------------------- | -------------- |
| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual |
| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None |
| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No |
| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes |
| Default values | ✅ Yes | ✅ Shared per template | ❌ No |
| Feature | Fixed Keys | Template Keys | Casual Methods (Memory only) |
| ----------------------- | ------------ | ---------------------- | ---------------------------- |
| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual |
| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None |
| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No |
| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes |
| Cross-window (Shared) | ✅ Yes | ✅ Yes | ❌ No |
| Default values | ✅ Yes | ✅ Shared per template | ❌ No |
### When to Use Which

View File

@@ -236,10 +236,15 @@ export const DefaultUseCache: UseCacheSchema = {
*/
export type SharedCacheSchema = {
'chat.web_search.active_searches': CacheValueTypes.CacheActiveSearches
// API key rotation state (cross-window, tracks last used key per provider)
'web_search.provider.last_used_key.${providerId}': string
'ocr.provider.last_used_key.${providerId}': string
}
export const DefaultSharedCache: SharedCacheSchema = {
'chat.web_search.active_searches': {}
'chat.web_search.active_searches': {},
'web_search.provider.last_used_key.${providerId}': '',
'ocr.provider.last_used_key.${providerId}': ''
}
/**
@@ -276,9 +281,25 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
export type RendererPersistCacheKey = keyof RendererPersistCacheSchema
/**
* Key type for shared cache (fixed keys only)
* Key type for shared cache (supports both fixed and template keys).
*
* Mirrors UseCacheKey: expands each schema key through ProcessKey so that
* template keys like 'web_search.provider.last_used_key.${providerId}' match
* any concrete instance (e.g. 'web_search.provider.last_used_key.google').
*/
export type SharedCacheKey = keyof SharedCacheSchema
export type SharedCacheKey = {
[K in keyof SharedCacheSchema]: ProcessKey<K & string>
}[keyof SharedCacheSchema]
/**
* Infers the value type for a given shared cache key from SharedCacheSchema.
*
* Mirrors InferUseCacheValue: resolves template instances back to the schema
* entry that defines them, so concrete keys still get precise value types.
*/
export type InferSharedCacheValue<K extends string> = {
[S in keyof SharedCacheSchema]: K extends ProcessKey<S & string> ? SharedCacheSchema[S] : never
}[keyof SharedCacheSchema]
/**
* Key type for memory cache (supports both fixed and template keys).

View File

@@ -20,7 +20,7 @@
import { loggerService } from '@logger'
import { BaseService, Injectable, ServicePhase } from '@main/core/lifecycle'
import { Phase } from '@main/core/lifecycle'
import type { SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas'
import type { InferSharedCacheValue, SharedCacheKey } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes'
import { IpcChannel } from '@shared/IpcChannel'
import { BrowserWindow } from 'electron'
@@ -173,7 +173,7 @@ export class CacheService extends BaseService {
* @param key - Schema-defined shared cache key
* @returns Cached value or undefined if not found or expired
*/
getShared<K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined {
getShared<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined {
const entry = this.sharedCache.get(key)
if (!entry) return undefined
@@ -183,7 +183,7 @@ export class CacheService extends BaseService {
return undefined
}
return entry.value as SharedCacheSchema[K]
return entry.value as InferSharedCacheValue<K>
}
/**
@@ -192,7 +192,7 @@ export class CacheService extends BaseService {
* @param value - Value to cache (type inferred from schema)
* @param ttl - Time to live in milliseconds (optional)
*/
setShared<K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number): void {
setShared<K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number): void {
const expireAt = ttl ? Date.now() + ttl : undefined
const entry: CacheEntry = { value, expireAt }

View File

@@ -19,11 +19,11 @@
import { loggerService } from '@logger'
import type {
InferSharedCacheValue,
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
SharedCacheKey,
SharedCacheSchema,
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas'
@@ -399,16 +399,6 @@ export class CacheService {
return entry?.expireAt !== undefined
}
/**
* Check if a shared cache key has TTL set (casual, dynamic key)
* @param key - Dynamic shared cache key
* @returns True if key has TTL configured
*/
hasSharedTTLCasual(key: Exclude<string, SharedCacheKey>): boolean {
const entry = this.sharedCache.get(key)
return entry?.expireAt !== undefined
}
// ============ Shared Cache (Cross-window) ============
/**
@@ -416,16 +406,7 @@ export class CacheService {
* @param key - Schema-defined shared cache key
* @returns Cached value or undefined if not found or expired
*/
getShared<K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined {
return this.getSharedInternal(key)
}
/**
* Get value from shared cache with TTL validation (casual, dynamic key)
* @param key - Dynamic shared cache key (e.g., `window:${id}`)
* @returns Cached value or undefined if not found or expired
*/
getSharedCasual<T>(key: Exclude<string, SharedCacheKey>): T | undefined {
getShared<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined {
return this.getSharedInternal(key)
}
@@ -452,17 +433,7 @@ export class CacheService {
* @param value - Value to cache (type inferred from schema)
* @param ttl - Time to live in milliseconds (optional)
*/
setShared<K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number): void {
this.setSharedInternal(key, value, ttl)
}
/**
* Set value in shared cache with cross-window synchronization (casual, dynamic key)
* @param key - Dynamic shared cache key (e.g., `window:${id}`)
* @param value - Value to cache
* @param ttl - Time to live in milliseconds (optional)
*/
setSharedCasual<T>(key: Exclude<string, SharedCacheKey>, value: T, ttl?: number): void {
setShared<K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number): void {
this.setSharedInternal(key, value, ttl)
}
@@ -520,15 +491,6 @@ export class CacheService {
return this.hasSharedInternal(key)
}
/**
* Check if key exists in shared cache and is not expired (casual, dynamic key)
* @param key - Dynamic shared cache key
* @returns True if key exists and is valid, false otherwise
*/
hasSharedCasual(key: Exclude<string, SharedCacheKey>): boolean {
return this.hasSharedInternal(key)
}
/**
* Internal implementation for shared cache has
*/
@@ -555,15 +517,6 @@ export class CacheService {
return this.deleteSharedInternal(key)
}
/**
* Delete from shared cache with cross-window synchronization and hook protection (casual, dynamic key)
* @param key - Dynamic shared cache key
* @returns True if deletion succeeded, false if key is protected by active hooks
*/
deleteSharedCasual(key: Exclude<string, SharedCacheKey>): boolean {
return this.deleteSharedInternal(key)
}
/**
* Internal implementation for shared cache delete
*/

View File

@@ -9,9 +9,11 @@
import type {
ExpandTemplateKey,
InferSharedCacheValue,
InferUseCacheValue,
IsTemplateKey,
ProcessKey,
SharedCacheKey,
UseCacheCasualKey,
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
@@ -146,4 +148,37 @@ describe('Template Key Type Utilities', () => {
expect(isTemplate('chat.generating')).toBe(false)
})
})
describe('SharedCacheKey', () => {
it('should include fixed keys', () => {
const key: SharedCacheKey = 'chat.web_search.active_searches'
expect(key).toBe('chat.web_search.active_searches')
})
it('should match template patterns', () => {
const key1: SharedCacheKey = 'web_search.provider.last_used_key.google'
const key2: SharedCacheKey = 'ocr.provider.last_used_key.tesseract'
expect(key1).toBe('web_search.provider.last_used_key.google')
expect(key2).toBe('ocr.provider.last_used_key.tesseract')
})
})
describe('InferSharedCacheValue', () => {
it('should infer value type for fixed keys', () => {
// 'chat.web_search.active_searches' -> CacheActiveSearches
expectTypeOf<InferSharedCacheValue<'chat.web_search.active_searches'>>().toMatchTypeOf<Record<string, unknown>>()
})
it('should infer value type for template key instances', () => {
const webSearchLastKey: InferSharedCacheValue<'web_search.provider.last_used_key.google'> = 'key-1'
const ocrLastKey: InferSharedCacheValue<'ocr.provider.last_used_key.tesseract'> = 'key-2'
expectTypeOf(webSearchLastKey).toBeString()
expectTypeOf(ocrLastKey).toBeString()
})
it('should return never for unknown keys', () => {
type UnknownValue = InferSharedCacheValue<'unknown.shared.key'>
expectTypeOf<UnknownValue>().toBeNever()
})
})
})

View File

@@ -1,6 +1,7 @@
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import type {
InferSharedCacheValue,
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
@@ -139,6 +140,49 @@ function getUseCacheDefaultValue<K extends UseCacheKey>(key: K): InferUseCacheVa
return undefined
}
/**
* Finds the shared schema key that matches a given concrete key.
*
* Mirrors findMatchingUseCacheSchemaKey but operates over SharedCacheSchema.
* Returns the exact schema key (fixed or template pattern), not the concrete
* instance — callers use it to look up the template's default value.
*/
function findMatchingSharedCacheSchemaKey(key: string): keyof SharedCacheSchema | undefined {
if (key in DefaultSharedCache) {
return key as keyof SharedCacheSchema
}
const schemaKeys = Object.keys(DefaultSharedCache) as Array<keyof SharedCacheSchema>
for (const schemaKey of schemaKeys) {
if (isTemplateKey(schemaKey as string)) {
const regex = templateToRegex(schemaKey as string)
if (regex.test(key)) {
return schemaKey
}
}
}
return undefined
}
/**
* Gets the default value for a shared cache key from the schema.
*
* Works with both fixed keys (direct lookup) and concrete keys that
* match template patterns (finds template, returns its default).
*
* Note: template default values are shared across all instances — e.g., all
* `web_search.provider.last_used_key.*` keys fall back to the single default
* `''`. This mirrors getUseCacheDefaultValue semantics.
*/
function getSharedCacheDefaultValue<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined {
const schemaKey = findMatchingSharedCacheSchemaKey(key)
if (schemaKey) {
return DefaultSharedCache[schemaKey] as InferSharedCacheValue<K>
}
return undefined
}
/**
* React hook for component-level memory cache
*
@@ -247,26 +291,34 @@ export function useCache<K extends UseCacheKey>(
* Use this for data that needs to be shared between all app windows.
* Data is lost when the app restarts.
*
* @param key - Cache key from the predefined schema
* Supports both fixed keys and template keys (aligned with useCache):
* - Fixed keys: `useSharedCache('chat.web_search.active_searches')`
* - Template keys: `useSharedCache('web_search.provider.last_used_key.google')`
* matches schema entry `'web_search.provider.last_used_key.${providerId}'`
*
* Template-instance defaults are shared across all matching instances (inherited
* from useCache semantics) — the schema default is written to cache on hook mount.
*
* @param key - Cache key from the predefined schema (fixed or matching template)
* @param initValue - Initial value (optional, uses schema default if not provided)
* @returns [value, setValue] - Similar to useState but shared across all windows
*
* @example
* ```typescript
* // Shared across all windows
* const [windowCount, setWindowCount] = useSharedCache('app.windowCount')
* // Fixed key
* const [active, setActive] = useSharedCache('chat.web_search.active_searches')
*
* // With custom initial value
* const [sharedState, setSharedState] = useSharedCache('app.state', { loaded: false })
* // Template key (schema: 'web_search.provider.last_used_key.${providerId}')
* const [lastKey, setLastKey] = useSharedCache('web_search.provider.last_used_key.google')
*
* // Changes automatically sync to all open windows
* setWindowCount(3)
* setLastKey('api-key-1')
* ```
*/
export function useSharedCache<K extends SharedCacheKey>(
key: K,
initValue?: SharedCacheSchema[K]
): [SharedCacheSchema[K], (value: SharedCacheSchema[K]) => void] {
initValue?: InferSharedCacheValue<K>
): [InferSharedCacheValue<K>, (value: InferSharedCacheValue<K>) => void] {
/**
* Subscribe to shared cache changes using React's useSyncExternalStore
* This ensures the component re-renders when the shared cache value changes
@@ -278,8 +330,13 @@ export function useSharedCache<K extends SharedCacheKey>(
)
/**
* Initialize shared cache with default value if it doesn't exist
* Priority: existing shared cache value > custom initValue > schema default
* Initialize shared cache with default value if it doesn't exist.
* Priority: existing shared cache value > custom initValue > schema default.
*
* Template-instance defaults fall through getSharedCacheDefaultValue, which
* resolves the concrete key back to its schema template and returns the
* shared default (e.g. all 'web_search.provider.last_used_key.*' instances
* share the single default '').
*/
useEffect(() => {
if (cacheService.hasShared(key)) {
@@ -287,7 +344,10 @@ export function useSharedCache<K extends SharedCacheKey>(
}
if (initValue === undefined) {
cacheService.setShared(key, DefaultSharedCache[key])
const defaultValue = getSharedCacheDefaultValue(key)
if (defaultValue !== undefined) {
cacheService.setShared(key, defaultValue)
}
} else {
cacheService.setShared(key, initValue)
}
@@ -320,13 +380,13 @@ export function useSharedCache<K extends SharedCacheKey>(
* @param newValue - New value to store in shared cache
*/
const setValue = useCallback(
(newValue: SharedCacheSchema[K]) => {
(newValue: InferSharedCacheValue<K>) => {
cacheService.setShared(key, newValue)
},
[key]
)
return [value ?? initValue ?? DefaultSharedCache[key], setValue]
return [value ?? initValue ?? (getSharedCacheDefaultValue(key) as InferSharedCacheValue<K>), setValue]
}
/**

View File

@@ -33,22 +33,22 @@ export default abstract class BaseWebSearchProvider {
public getApiKey() {
const keys = this.provider.apiKey?.split(',').map((key) => key.trim()) || []
const keyName = `web-search-provider:${this.provider.id}:last_used_key`
const keyName = `web_search.provider.last_used_key.${this.provider.id}` as const
if (keys.length === 1) {
return keys[0]
}
const lastUsedKey = cacheService.getSharedCasual<string>(keyName)
const lastUsedKey = cacheService.getShared(keyName)
if (lastUsedKey === undefined) {
cacheService.setSharedCasual(keyName, keys[0])
cacheService.setShared(keyName, keys[0])
return keys[0]
}
const currentIndex = keys.indexOf(lastUsedKey)
const nextIndex = (currentIndex + 1) % keys.length
const nextKey = keys[nextIndex]
cacheService.setSharedCasual(keyName, nextKey)
cacheService.setShared(keyName, nextKey)
return nextKey
}

View File

@@ -21,22 +21,22 @@ export abstract class OcrBaseApiClient {
// copy from BaseApiClient
public getApiKey() {
const keys = this.provider.config.api.apiKey.split(',').map((key) => key.trim())
const keyName = `ocr_provider:${this.provider.id}:last_used_key`
const keyName = `ocr.provider.last_used_key.${this.provider.id}` as const
if (keys.length === 1) {
return keys[0]
}
const lastUsedKey = cacheService.getSharedCasual<string>(keyName)
const lastUsedKey = cacheService.getShared(keyName)
if (lastUsedKey === undefined) {
cacheService.setSharedCasual(keyName, keys[0])
cacheService.setShared(keyName, keys[0])
return keys[0]
}
const currentIndex = keys.indexOf(lastUsedKey)
const nextIndex = (currentIndex + 1) % keys.length
const nextKey = keys[nextIndex]
cacheService.setSharedCasual(keyName, nextKey)
cacheService.setShared(keyName, nextKey)
return nextKey
}

View File

@@ -62,13 +62,13 @@ import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService'
### CacheService
Three-tier cache system with type-safe and casual (dynamic key) methods.
Three-tier cache system with type-safe methods (and casual/dynamic key methods on the Memory tier only).
#### Methods
| Category | Method | Signature |
|----------|--------|-----------|
| Memory (typed) | `get` | `<K>(key: K) => UseCacheSchema[K]` |
| Memory (typed) | `get` | `<K>(key: K) => InferUseCacheValue<K>` |
| Memory (typed) | `set` | `<K>(key: K, value, ttl?) => void` |
| Memory (typed) | `has` | `<K>(key: K) => boolean` |
| Memory (typed) | `delete` | `<K>(key: K) => boolean` |
@@ -78,16 +78,11 @@ Three-tier cache system with type-safe and casual (dynamic key) methods.
| Memory (casual) | `hasCasual` | `(key: string) => boolean` |
| Memory (casual) | `deleteCasual` | `(key: string) => boolean` |
| Memory (casual) | `hasTTLCasual` | `(key: string) => boolean` |
| Shared (typed) | `getShared` | `<K>(key: K) => SharedCacheSchema[K]` |
| Shared (typed) | `getShared` | `<K>(key: K) => InferSharedCacheValue<K>` |
| Shared (typed) | `setShared` | `<K>(key: K, value, ttl?) => void` |
| Shared (typed) | `hasShared` | `<K>(key: K) => boolean` |
| Shared (typed) | `deleteShared` | `<K>(key: K) => boolean` |
| Shared (typed) | `hasSharedTTL` | `<K>(key: K) => boolean` |
| Shared (casual) | `getSharedCasual` | `<T>(key: string) => T \| undefined` |
| Shared (casual) | `setSharedCasual` | `<T>(key, value, ttl?) => void` |
| Shared (casual) | `hasSharedCasual` | `(key: string) => boolean` |
| Shared (casual) | `deleteSharedCasual` | `(key: string) => boolean` |
| Shared (casual) | `hasSharedTTLCasual` | `(key: string) => boolean` |
| Persist | `getPersist` | `<K>(key: K) => RendererPersistCacheSchema[K]` |
| Persist | `setPersist` | `<K>(key: K, value) => void` |
| Persist | `hasPersist` | `(key) => boolean` |

View File

@@ -1,4 +1,4 @@
import type { SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas'
import type { InferSharedCacheValue, SharedCacheKey } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes'
import { vi } from 'vitest'
@@ -78,7 +78,7 @@ export class MockMainCacheService {
// ============ Shared Cache Methods ============
public getShared = vi.fn(<K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined => {
public getShared = vi.fn(<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined => {
const entry = mockSharedCache.get(key)
if (!entry) return undefined
@@ -88,10 +88,10 @@ export class MockMainCacheService {
return undefined
}
return entry.value as SharedCacheSchema[K]
return entry.value as InferSharedCacheValue<K>
})
public setShared = vi.fn(<K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number): void => {
public setShared = vi.fn(<K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number): void => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
@@ -240,7 +240,7 @@ export const MockMainCacheServiceUtils = {
/**
* Set shared cache value for testing
*/
setSharedCacheValue: <K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number) => {
setSharedCacheValue: <K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number) => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
@@ -251,7 +251,7 @@ export const MockMainCacheServiceUtils = {
/**
* Get shared cache value for testing
*/
getSharedCacheValue: <K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined => {
getSharedCacheValue: <K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined => {
const entry = mockSharedCache.get(key)
if (!entry) return undefined
@@ -261,7 +261,7 @@ export const MockMainCacheServiceUtils = {
return undefined
}
return entry.value as SharedCacheSchema[K]
return entry.value as InferSharedCacheValue<K>
},
/**

View File

@@ -1,10 +1,11 @@
import type {
InferSharedCacheValue,
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
UseCacheKey,
InferUseCacheValue,
SharedCacheKey,
SharedCacheSchema
SharedCacheSchema,
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSubscriber } from '@shared/data/cache/cacheTypes'
@@ -176,20 +177,23 @@ export const createMockCacheService = (
// ============ Shared Cache (Type-safe) ============
getShared: vi.fn(<K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined => {
getShared: vi.fn(<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined => {
const entry = sharedCache.get(key)
// For fixed schema keys, fall back to the schema default. Template
// instances miss this lookup at runtime and return undefined.
const fallback = DefaultSharedCache[key as keyof SharedCacheSchema] as InferSharedCacheValue<K> | undefined
if (entry === undefined) {
return DefaultSharedCache[key]
return fallback
}
if (isExpired(entry)) {
sharedCache.delete(key)
notifySubscribers(key)
return DefaultSharedCache[key]
return fallback
}
return entry.value
return entry.value as InferSharedCacheValue<K>
}),
setShared: vi.fn(<K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number): void => {
setShared: vi.fn(<K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number): void => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
@@ -229,61 +233,6 @@ export const createMockCacheService = (
return entry?.expireAt !== undefined
}),
// ============ Shared Cache (Casual - Dynamic Keys) ============
getSharedCasual: vi.fn(<T>(key: string): T | undefined => {
const entry = sharedCache.get(key)
if (entry === undefined) {
return undefined
}
if (isExpired(entry)) {
sharedCache.delete(key)
notifySubscribers(key)
return undefined
}
return entry.value as T
}),
setSharedCasual: vi.fn(<T>(key: string, value: T, ttl?: number): void => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
}
sharedCache.set(key, entry)
notifySubscribers(key)
}),
hasSharedCasual: vi.fn((key: string): boolean => {
const entry = sharedCache.get(key)
if (entry === undefined) {
return false
}
if (isExpired(entry)) {
sharedCache.delete(key)
notifySubscribers(key)
return false
}
return true
}),
deleteSharedCasual: vi.fn((key: string): boolean => {
if (activeHookCounts.get(key)) {
console.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`)
return false
}
const existed = sharedCache.has(key)
sharedCache.delete(key)
if (existed) {
notifySubscribers(key)
}
return true
}),
hasSharedTTLCasual: vi.fn((key: string): boolean => {
const entry = sharedCache.get(key)
return entry?.expireAt !== undefined
}),
// ============ Persist Cache ============
getPersist: vi.fn(<K extends RendererPersistCacheKey>(key: K): RendererPersistCacheSchema[K] => {
@@ -401,6 +350,17 @@ export const createMockCacheService = (
sharedCacheReadyCallbacks.forEach((cb) => cb())
sharedCacheReadyCallbacks.length = 0
}
},
// Test scaffold: seed a shared cache entry with an arbitrary string key,
// bypassing SharedCacheKey type-safety. Used by setInitialState.
_setSharedEntry: (key: string, value: unknown, ttl?: number) => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
}
sharedCache.set(key, entry)
notifySubscribers(key)
}
}
@@ -460,12 +420,12 @@ export const MockCacheService = {
}
// ============ Shared Cache (Type-safe) ============
getShared<K extends SharedCacheKey>(key: K): SharedCacheSchema[K] | undefined {
return mockCacheService.getShared(key)
getShared<K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> | undefined {
return mockCacheService.getShared(key) as InferSharedCacheValue<K> | undefined
}
setShared<K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K], ttl?: number): void {
return mockCacheService.setShared(key, value, ttl)
setShared<K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>, ttl?: number): void {
return mockCacheService.setShared(key, value as never, ttl)
}
hasShared<K extends SharedCacheKey>(key: K): boolean {
@@ -480,27 +440,6 @@ export const MockCacheService = {
return mockCacheService.hasSharedTTL(key)
}
// ============ Shared Cache (Casual) ============
getSharedCasual<T>(key: string): T | undefined {
return mockCacheService.getSharedCasual(key) as T | undefined
}
setSharedCasual<T>(key: string, value: T, ttl?: number): void {
return mockCacheService.setSharedCasual(key, value, ttl)
}
hasSharedCasual(key: string): boolean {
return mockCacheService.hasSharedCasual(key)
}
deleteSharedCasual(key: string): boolean {
return mockCacheService.deleteSharedCasual(key)
}
hasSharedTTLCasual(key: string): boolean {
return mockCacheService.hasSharedTTLCasual(key)
}
// ============ Persist Cache ============
getPersist<K extends RendererPersistCacheKey>(key: K): RendererPersistCacheSchema[K] {
return mockCacheService.getPersist(key)
@@ -581,7 +520,7 @@ export const MockCacheUtils = {
mockCacheService.setCasual(key, value, ttl)
})
state.shared?.forEach(([key, value, ttl]) => {
mockCacheService.setSharedCasual(key, value, ttl)
mockCacheService._setSharedEntry(key, value, ttl)
})
state.persist?.forEach(([key, value]) => {
mockCacheService.setPersist(key, value)

View File

@@ -1,11 +1,12 @@
import type {
InferSharedCacheValue,
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
UseCacheKey,
UseCacheSchema,
InferUseCacheValue,
SharedCacheKey,
SharedCacheSchema
SharedCacheSchema,
UseCacheKey,
UseCacheSchema
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas'
import { vi } from 'vitest'
@@ -164,19 +165,21 @@ export const mockUseCache = vi.fn(
export const mockUseSharedCache = vi.fn(
<K extends SharedCacheKey>(
key: K,
initValue?: SharedCacheSchema[K]
): [SharedCacheSchema[K], (value: SharedCacheSchema[K]) => void] => {
initValue?: InferSharedCacheValue<K>
): [InferSharedCacheValue<K>, (value: InferSharedCacheValue<K>) => void] => {
// Get current value
let currentValue = mockSharedCache.get(key)
if (currentValue === undefined) {
currentValue = initValue ?? DefaultSharedCache[key]
// Fixed keys look up in DefaultSharedCache; template instances return undefined.
const schemaDefault = DefaultSharedCache[key as keyof SharedCacheSchema]
currentValue = initValue ?? schemaDefault
if (currentValue !== undefined) {
mockSharedCache.set(key, currentValue)
}
}
// Mock setValue function
const setValue = vi.fn((value: SharedCacheSchema[K]) => {
const setValue = vi.fn((value: InferSharedCacheValue<K>) => {
mockSharedCache.set(key, value)
notifySharedSubscribers(key)
})
@@ -274,7 +277,7 @@ export const MockUseCacheUtils = {
/**
* Set shared cache value for testing
*/
setSharedCacheValue: <K extends SharedCacheKey>(key: K, value: SharedCacheSchema[K]) => {
setSharedCacheValue: <K extends SharedCacheKey>(key: K, value: InferSharedCacheValue<K>) => {
mockSharedCache.set(key, value)
notifySharedSubscribers(key)
},
@@ -282,8 +285,8 @@ export const MockUseCacheUtils = {
/**
* Get shared cache value
*/
getSharedCacheValue: <K extends SharedCacheKey>(key: K): SharedCacheSchema[K] => {
return mockSharedCache.get(key) ?? DefaultSharedCache[key]
getSharedCacheValue: <K extends SharedCacheKey>(key: K): InferSharedCacheValue<K> => {
return mockSharedCache.get(key) ?? DefaultSharedCache[key as keyof SharedCacheSchema]
},
/**
@@ -366,18 +369,19 @@ export const MockUseCacheUtils = {
*/
mockSharedCacheReturn: <K extends SharedCacheKey>(
key: K,
value: SharedCacheSchema[K],
setValue?: (value: SharedCacheSchema[K]) => void
value: InferSharedCacheValue<K>,
setValue?: (value: InferSharedCacheValue<K>) => void
) => {
mockUseSharedCache.mockImplementation((cacheKey, initValue) => {
mockUseSharedCache.mockImplementation(((cacheKey: K, initValue: InferSharedCacheValue<K> | undefined) => {
if (cacheKey === key) {
return [value, setValue || vi.fn()]
}
// Default behavior for other keys
const defaultValue = mockSharedCache.get(cacheKey) ?? initValue ?? DefaultSharedCache[cacheKey]
const defaultValue =
mockSharedCache.get(cacheKey) ?? initValue ?? DefaultSharedCache[cacheKey as keyof SharedCacheSchema]
return [defaultValue, vi.fn()]
})
}) as never)
},
/**