cherry-studio/packages/shared/data/cache/cacheSchemas.ts
fullex fb51df99d0 feat(cache): add template key support for useCache with type inference
- Add type utilities for template key matching (IsTemplateKey, ExpandTemplateKey, ProcessKey)
- Add InferUseCacheValue<K> for automatic value type inference from template patterns
- Update useCache hook to support template keys with default value fallback
- Extend ESLint rule to validate template key syntax (e.g., 'scroll.position:${id}')
- Update CacheService.get() docs: clarify | undefined return is intentional
  (developers need to know when value doesn't exist after deletion/TTL expiry)
- Update cache documentation with template key usage examples

BREAKING CHANGE: CacheService.get() now explicitly returns T | undefined
(was implicit before). Callers should use ?? defaultValue for fallback.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 12:23:19 +08:00

306 lines
9.7 KiB
TypeScript

import type * as CacheValueTypes from './cacheValueTypes'
/**
* Cache Schema Definitions
*
* ## Key Naming Convention
*
* All cache keys MUST follow the format: `namespace.sub.key_name`
*
* Rules:
* - At least 2 segments separated by dots (.)
* - Each segment uses lowercase letters, numbers, and underscores only
* - Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
*
* Examples:
* - 'app.user.avatar' (valid)
* - 'chat.multi_select_mode' (valid)
* - 'minapp.opened_keep_alive' (valid)
* - 'userAvatar' (invalid - missing dot separator)
* - 'App.user' (invalid - uppercase not allowed)
*
* ## Template Key Support
*
* Template keys allow type-safe dynamic keys using template literal syntax.
* Define in schema with `${variable}` placeholder, use with actual values.
*
* Examples:
* - Schema: `'scroll.position:${topicId}': number`
* - Usage: `useCache('scroll.position:topic-123')` -> infers `number` type
*
* Multiple placeholders are supported:
* - Schema: `'cache:${type}:${id}': CacheData`
* - Usage: `useCache('cache:user:456')` -> infers `CacheData` type
*
* This convention is enforced by ESLint rule: data-schema-key/valid-key
*/
// ============================================================================
// Template Key Type Utilities
// ============================================================================
/**
* Detects whether a key string contains template placeholder syntax.
*
* Template keys use `${variable}` syntax to define dynamic segments.
* This type returns `true` if the key contains at least one `${...}` placeholder.
*
* @template K - The key string to check
* @returns `true` if K contains `${...}`, `false` otherwise
*
* @example
* ```typescript
* type Test1 = IsTemplateKey<'scroll:${id}'> // true
* type Test2 = IsTemplateKey<'cache:${a}:${b}'> // true
* type Test3 = IsTemplateKey<'app.user.avatar'> // false
* ```
*/
export type IsTemplateKey<K extends string> = K extends `${string}\${${string}}${string}` ? true : false
/**
* Expands a template key pattern into a matching literal type.
*
* Replaces each `${variable}` placeholder with `string`, allowing
* TypeScript to match concrete keys against the template pattern.
* Recursively processes multiple placeholders.
*
* @template T - The template key pattern to expand
* @returns A template literal type that matches all valid concrete keys
*
* @example
* ```typescript
* type Test1 = ExpandTemplateKey<'scroll:${id}'>
* // Result: `scroll:${string}` (matches 'scroll:123', 'scroll:abc', etc.)
*
* type Test2 = ExpandTemplateKey<'cache:${type}:${id}'>
* // Result: `cache:${string}:${string}` (matches 'cache:user:123', etc.)
*
* type Test3 = ExpandTemplateKey<'app.user.avatar'>
* // Result: 'app.user.avatar' (unchanged for non-template keys)
* ```
*/
export type ExpandTemplateKey<T extends string> = T extends `${infer Prefix}\${${string}}${infer Suffix}`
? `${Prefix}${string}${ExpandTemplateKey<Suffix>}`
: T
/**
* Processes a cache key, expanding template patterns if present.
*
* For template keys (containing `${...}`), returns the expanded pattern.
* For fixed keys, returns the key unchanged.
*
* @template K - The key to process
* @returns The processed key type (expanded if template, unchanged if fixed)
*
* @example
* ```typescript
* type Test1 = ProcessKey<'scroll:${id}'> // `scroll:${string}`
* type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar'
* ```
*/
export type ProcessKey<K extends string> = IsTemplateKey<K> extends true ? ExpandTemplateKey<K> : K
/**
* Use cache schema for renderer hook
*/
export type UseCacheSchema = {
// App state
'app.dist.update_state': CacheValueTypes.CacheAppUpdateState
'app.user.avatar': string
'app.path.files': string
'app.path.resources': string
// Chat context
'chat.multi_select_mode': boolean
'chat.selected_message_ids': string[]
'chat.generating': boolean
'chat.websearch.searching': boolean
'chat.websearch.active_searches': CacheValueTypes.CacheActiveSearches
'chat.active_view': 'topic' | 'session'
// Minapp management
'minapp.opened_keep_alive': CacheValueTypes.CacheMinAppType[]
'minapp.current_id': string
'minapp.show': boolean
'minapp.opened_oneoff': CacheValueTypes.CacheMinAppType | null
// Topic management
'topic.active': CacheValueTypes.CacheTopic | null
'topic.renaming': string[]
'topic.newly_renamed': string[]
// Agent management
'agent.active_id': string | null
'agent.session.active_id_map': Record<string, string | null>
'agent.session.waiting_id_map': Record<string, boolean>
}
export const DefaultUseCache: UseCacheSchema = {
// App state
'app.dist.update_state': {
info: null,
checking: false,
downloading: false,
downloaded: false,
downloadProgress: 0,
available: false,
ignore: false
},
'app.user.avatar': '',
'app.path.files': '',
'app.path.resources': '',
// Chat context
'chat.multi_select_mode': false,
'chat.selected_message_ids': [],
'chat.generating': false,
'chat.websearch.searching': false,
'chat.websearch.active_searches': {},
'chat.active_view': 'topic',
// Minapp management
'minapp.opened_keep_alive': [],
'minapp.current_id': '',
'minapp.show': false,
'minapp.opened_oneoff': null,
// Topic management
'topic.active': null,
'topic.renaming': [],
'topic.newly_renamed': [],
// Agent management
'agent.active_id': null,
'agent.session.active_id_map': {},
'agent.session.waiting_id_map': {}
}
/**
* Use shared cache schema for renderer hook
*/
export type SharedCacheSchema = {
'example_scope.example_key': string
}
export const DefaultSharedCache: SharedCacheSchema = {
'example_scope.example_key': 'example default value'
}
/**
* Persist cache schema defining allowed keys and their value types
* This ensures type safety and prevents key conflicts
*/
export type RendererPersistCacheSchema = {
'example_scope.example_key': string
}
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'example_scope.example_key': 'example default value'
}
// ============================================================================
// Cache Key Types
// ============================================================================
/**
* Key type for renderer persist cache (fixed keys only)
*/
export type RendererPersistCacheKey = keyof RendererPersistCacheSchema
/**
* Key type for shared cache (fixed keys only)
*/
export type SharedCacheKey = keyof SharedCacheSchema
/**
* Key type for memory cache (supports both fixed and template keys).
*
* This type expands all schema keys using ProcessKey, which:
* - Keeps fixed keys unchanged (e.g., 'app.user.avatar')
* - Expands template keys to match patterns (e.g., 'scroll:${id}' -> `scroll:${string}`)
*
* The resulting union type allows TypeScript to accept any concrete key
* that matches either a fixed key or an expanded template pattern.
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
*
* // UseCacheKey becomes: 'app.user.avatar' | `scroll.position:${string}`
*
* // Valid keys:
* const k1: UseCacheKey = 'app.user.avatar' // fixed key
* const k2: UseCacheKey = 'scroll.position:123' // matches template
* const k3: UseCacheKey = 'scroll.position:abc' // matches template
*
* // Invalid keys:
* const k4: UseCacheKey = 'unknown.key' // error: not in schema
* ```
*/
export type UseCacheKey = {
[K in keyof UseCacheSchema]: ProcessKey<K & string>
}[keyof UseCacheSchema]
// ============================================================================
// UseCache Specialized Types
// ============================================================================
/**
* Infers the value type for a given cache key from UseCacheSchema.
*
* Works with both fixed keys and template keys:
* - For fixed keys, returns the exact value type from schema
* - For template keys, matches the key against expanded patterns and returns the value type
*
* If the key doesn't match any schema entry, returns `never`.
*
* @template K - The cache key to infer value type for
* @returns The value type associated with the key, or `never` if not found
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
*
* type T1 = InferUseCacheValue<'app.user.avatar'> // string
* type T2 = InferUseCacheValue<'scroll.position:123'> // number
* type T3 = InferUseCacheValue<'scroll.position:abc'> // number
* type T4 = InferUseCacheValue<'unknown.key'> // never
* ```
*/
export type InferUseCacheValue<K extends string> = {
[S in keyof UseCacheSchema]: K extends ProcessKey<S & string> ? UseCacheSchema[S] : never
}[keyof UseCacheSchema]
/**
* Type guard for casual cache keys that blocks schema-defined keys.
*
* Used to ensure casual API methods (getCasual, setCasual, etc.) cannot
* be called with keys that are defined in the schema (including template patterns).
* This enforces proper API usage: use type-safe methods for schema keys,
* use casual methods only for truly dynamic/unknown keys.
*
* @template K - The key to check
* @returns `K` if the key doesn't match any schema pattern, `never` if it does
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
*
* // These cause compile-time errors (key matches schema):
* getCasual('app.user.avatar') // Error: never
* getCasual('scroll.position:123') // Error: never (matches template)
*
* // These are allowed (key doesn't match any schema pattern):
* getCasual('my.custom.key') // OK
* getCasual('dynamic:xyz:456') // OK
* ```
*/
export type UseCacheCasualKey<K extends string> = K extends UseCacheKey ? never : K