Align cache-overview.md and cache-usage.md with the template-key and
subscribe* APIs (sharedCasual was dropped in 3fbc52e05). Extract the
"adding keys" content into a new cache-schema-guide.md aligned with
preference and boot-config schema guides. Lift non-obvious invariants
(isEqual short-circuit, TTL-with-hooks warning, Persist has no delete,
Main-wins convergence, template placeholder rules) into a first-class
Design Invariants section in the overview.
Fix two code-contradicted claims:
- useCache does not accept a TTL options argument (hook signature is
(key, initValue?)).
- Persist is renderer-authoritative; Main only relays IPC and does
not store (CacheService.ts:477-479 is "Reserved, not implemented").
Update peripheral references in the same pass so cross-references stay
coherent: v2-renderer skill, CLAUDE.md, architecture-overview.md,
api-design-guidelines.md (Cache vs DataApi matcher contrast), and the
package READMEs.
Net change: +249 / -579.
8.9 KiB
Cache Usage Guide
Concept and invariants: cache-overview.md. Adding keys: cache-schema-guide.md.
React Hooks
Import from @data/hooks/useCache.
| Hook | Tier | Signature |
|---|---|---|
useCache |
Memory | (key: UseCacheKey, initValue?: V) => [V, (next: V) => void] |
useSharedCache |
Shared | (key: SharedCacheKey, initValue?: V) => [V, (next: V) => void] |
usePersistCache |
Persist | (key: RendererPersistCacheKey) => [V, (next: V) => void] |
Value type is inferred from the schema. Hooks pin the cache entry (refcounted) — the key cannot be deleted while any hook is mounted. Hooks do not accept a TTL option; using TTL under a hook logs a warning and is discouraged (see Design Invariant #4).
import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache'
// Memory — single renderer
const [generating, setGenerating] = useCache('chat.generating', false)
// Shared — all windows
const [activeSearches, setActive] = useSharedCache('chat.web_search.active_searches')
// Persist — survives restart via localStorage
const [pinned, setPinned] = usePersistCache('ui.tab.pinned_tabs')
// Template key (schema: 'scroll.position.${topicId}': number)
const [scrollPos, setScrollPos] = useCache(`scroll.position.${topicId}`)
CacheService Direct Usage (Renderer)
Import the singleton:
import { cacheService } from '@data/CacheService'
Memory
// Schema keys (Fixed or Template) — type-inferred
cacheService.set('chat.generating', true)
cacheService.set('chat.generating', true, 30_000) // with TTL (ms)
cacheService.get('chat.generating') // boolean
cacheService.has('chat.generating')
cacheService.hasTTL('chat.generating')
cacheService.delete('chat.generating')
// Casual (Memory tier only, no schema match allowed)
cacheService.setCasual<TopicCache>(`topic:${id}`, data, 30_000)
cacheService.getCasual<TopicCache>(`topic:${id}`)
cacheService.hasCasual(`topic:${id}`)
cacheService.hasTTLCasual(`topic:${id}`)
cacheService.deleteCasual(`topic:${id}`)
Shared
// Fixed key
cacheService.setShared('chat.web_search.active_searches', map)
cacheService.getShared('chat.web_search.active_searches')
// Template key (schema: 'web_search.provider.last_used_key.${providerId}': string)
const k = `web_search.provider.last_used_key.${providerId}` as const
cacheService.setShared(k, 'api-key-id-1')
cacheService.getShared(k)
cacheService.hasShared(k)
cacheService.hasSharedTTL(k)
cacheService.deleteShared(k)
Before the initial sync from Main completes, getShared() returns undefined. Writes before sync are applied locally and broadcast; Main-priority override applies at sync time (see Shared Cache Ready State).
Persist
cacheService.setPersist('ui.sidebar.width', 300)
cacheService.getPersist('ui.sidebar.width')
cacheService.hasPersist('ui.sidebar.width')
// No deletePersist — Persist keys are fixed by schema
Persist writes are debounced (200ms) and flushed on beforeunload. localStorage is limited to ~5MB per origin — keep Persist values small.
Main Process Usage
import { application } from '@application'
const cacheService = application.get('CacheService')
Main does not expose casual methods or Persist storage. Persist sync goes through Main as an IPC relay only.
Internal and Shared Access
// Internal cache (Main-only; free-form string keys)
cacheService.set('myService.scratch', value, 30_000)
cacheService.get<MyType>('myService.scratch')
// Shared cache (schema-typed; authoritative at Main)
cacheService.setShared('chat.web_search.active_searches', map)
cacheService.getShared('chat.web_search.active_searches')
cacheService.hasShared('chat.web_search.active_searches')
Subscribing to Changes
// Exact key, internal cache
this.registerDisposable(
cacheService.subscribeChange<number>('myService.counter', (newValue, oldValue) => {
logger.info('counter changed', { oldValue, newValue })
})
)
// Exact key, shared cache
this.registerDisposable(
cacheService.subscribeSharedChange('chat.web_search.active_searches', (newValue, oldValue) => {
// reacts to writes from any window and from Main itself
})
)
// Template key — fires for every matching concrete instance
const tpl = 'web_search.provider.last_used_key.${providerId}' as const
this.registerDisposable(
cacheService.subscribeSharedChange(tpl, (newValue, oldValue, concreteKey) => {
const providerId = concreteKey.split('.').pop()!
logger.info(`provider ${providerId} rotated`, { from: oldValue, to: newValue })
})
)
Fire semantics, re-entrance rules, and the placeholder / character-set contract are listed in cache-overview.md → Design Invariants. In short:
- Fires only on explicit
set/delete/setShared/deleteSharedand renderer-origin writes relayed via IPC - Never fires immediately on subscribe — call
get()/getShared()yourself for initial state - Same-value writes are suppressed (
lodash.isEqual) - Callback errors are caught; other subscribers still fire
Shared Cache Ready State
if (cacheService.isSharedCacheReady()) {
// Initial sync from Main has completed
}
const unsubscribe = cacheService.onSharedCacheReady(() => {
// Fires immediately if already ready, otherwise once sync completes
})
Hooks (useSharedCache) work correctly before ready — they return the local initValue / schema default until Main's state arrives, then update.
Cache Statistics (debugging)
cacheService.getStats() // summary: entry counts, TTL status, hook refs, estimated bytes
cacheService.getStats(true) // per-entry details for every tier
Common Patterns
Cache an expensive computation
function useExpensiveData(input: string) {
const [cached, setCached] = useCache(`entity.cache.input_${input}`)
useEffect(() => {
if (!cached.loaded) setCached({ loaded: true, data: expensiveCompute(input) })
}, [input, cached, setCached])
return cached.data
}
Cross-window coordination
// Window A
const [active, setActive] = useSharedCache('chat.web_search.active_searches')
setActive({ ...active, [searchId]: state })
// Window B re-renders automatically on next Main relay
const [active] = useSharedCache('chat.web_search.active_searches')
Bounded recent list (Persist)
const [pinned, setPinned] = usePersistCache('ui.tab.pinned_tabs')
const pin = (tab: Tab) =>
setPinned([tab, ...pinned.filter((t) => t.id !== tab.id)].slice(0, 10))
Observe every instance of a template key (Main only)
One subscription covers all providers, including ones registered at runtime:
const tpl = 'web_search.provider.last_used_key.${providerId}' as const
this.registerDisposable(
cacheService.subscribeSharedChange(tpl, (next, prev, concreteKey) => {
const id = concreteKey.split('.').pop()!
// react to rotation for provider `id`
})
)
TTL on a non-hook read path
// Main service or non-hook code path
cacheService.set('search.recent_query_hash', hash, 60_000)
// ... check before recomputing
if (!cacheService.has('search.recent_query_hash')) recompute()
Type-Safe vs Casual
| When | Use |
|---|---|
| Key is known at design time | Fixed key + type-safe method |
| Key has a recurring pattern with a variable part | Template key + type-safe method |
| Key is truly unknown until runtime | getCasual / setCasual (Memory only) |
| Need cross-window dynamic key | Template key on Shared tier — there is no getSharedCasual |
Casual methods type-error if the concrete key matches any schema pattern — that's intentional.
Best Practices
- Pick the tier by lifecycle, not by scope: Memory = regenerable, Shared = cross-window regenerable, Persist = nice-to-keep across restarts.
- TTL belongs on non-hook read paths; hook paths log a warn and may expire between renders.
- Prefer Fixed > Template > Casual. Promote recurring casual keys to Template.
- Keep Persist values small — localStorage is ~5MB per origin.
- For Main-process reactions to cache changes, always wrap the
subscribe*return inthis.registerDisposable(...)so teardown is automatic. - Same-value writes are free — don't add your own equality guards around
set/setShared.