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>
This commit is contained in:
fullex 2026-01-05 12:23:19 +08:00
parent b6a1240bd8
commit fb51df99d0
7 changed files with 669 additions and 58 deletions

View File

@ -51,8 +51,9 @@ cacheService.set('temp.calculation', result, 30000)
- Main process resolves conflicts
### Type Safety
- Schema-based keys for compile-time checking
- Casual methods for dynamic keys with manual typing
- **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:topic-123'`)
- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys)
## Data Categories
@ -120,10 +121,20 @@ cacheService.set('temp.calculation', result, 30000)
For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md).
| Method | Tier | Type Safety |
|--------|------|-------------|
| `useCache` / `get` / `set` | Memory | Schema-based keys |
| `getCasual` / `setCasual` | Memory | Dynamic keys (manual typing) |
| `useSharedCache` / `getShared` / `setShared` | Shared | Schema-based keys |
| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys (manual typing) |
| `usePersistCache` / `getPersist` / `setPersist` | Persist | Schema-based keys only |
### Key Types
| Type | Example Schema | Example Usage | Type Inference |
|------|----------------|---------------|----------------|
| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic |
| Template key | `'scroll.position:${id}': number` | `get('scroll.position:topic-123')` | Automatic |
| Casual key | N/A | `getCasual<T>('my.key')` | Manual |
### API Reference
| Method | Tier | Key Type |
|--------|------|----------|
| `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) |
| `usePersistCache` / `getPersist` / `setPersist` | Persist | Fixed keys only |

View File

@ -155,19 +155,75 @@ const [counter, setCounter] = useCache('ui.counter', 0)
- Use dynamically constructed keys
- Require manual type specification via generics
- No compile-time key validation
- **Cannot use keys that match schema patterns** (including template keys)
```typescript
// Dynamic key, must specify type
const topic = cacheService.getCasual<TopicCache>(`topic:${id}`)
const topic = cacheService.getCasual<TopicCache>(`my.custom.key`)
// Compile error: cannot use schema keys with Casual methods
cacheService.getCasual('app.user.avatar') // Error: matches fixed key
cacheService.getCasual('scroll.position:topic-123') // Error: matches template key
```
### 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.
#### Defining Template Keys
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Fixed key
'app.user.avatar': string
// Template keys - use ${variable} for dynamic segments
'scroll.position:${topicId}': number
'entity.cache:${type}:${id}': EntityData
}
// Default values for templates (shared by all instances)
export const DefaultUseCache: UseCacheSchema = {
'app.user.avatar': '',
'scroll.position:${topicId}': 0,
'entity.cache:${type}:${id}': { loaded: false }
}
```
#### Using Template Keys
```typescript
// TypeScript infers the value type from schema
const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123')
// scrollPos is inferred as `number`
const [entity, setEntity] = useCache('entity.cache:user:456')
// entity is inferred as `EntityData`
// Direct CacheService usage
cacheService.set('scroll.position:my-topic', 150) // OK: value must be number
cacheService.set('scroll.position:my-topic', '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 |
### When to Use Which
| Scenario | Method | Example |
|----------|--------|---------|
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
| Entity caching by ID | Casual | `getCasual<Topic>(\`topic:${id}\`)` |
| Session-based keys | Casual | `setCasual(\`session:${sessionId}\`)` |
| Dynamic keys with known pattern | Template key | `useCache('scroll.position:topic-123')` |
| Entity caching by ID | Template key | `get('entity.cache:user:456')` |
| Completely dynamic keys | Casual | `getCasual<T>(\`unknown.pattern:${x}\`)` |
| UI state | Type-safe | `useSharedCache('window.layout')` |
## Common Patterns
@ -243,17 +299,24 @@ function useCachedWithExpiry<T>(key: string, fetcher: () => Promise<T>, maxAge:
## Adding New Cache Keys
### 1. Add to Cache Schema
### Adding Fixed Keys
#### 1. Add to Cache Schema
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export interface CacheSchema {
export type UseCacheSchema = {
// Existing keys...
'myFeature.data': MyDataType
}
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
'myFeature.data': { items: [], lastUpdated: 0 }
}
```
### 2. Define Value Type (if complex)
#### 2. Define Value Type (if complex)
```typescript
// packages/shared/data/cache/cacheValueTypes.ts
@ -263,13 +326,60 @@ export interface MyDataType {
}
```
### 3. Use in Code
#### 3. Use in Code
```typescript
// Now type-safe
const [data, setData] = useCache('myFeature.data', defaultValue)
const [data, setData] = useCache('myFeature.data')
```
### Adding Template Keys
#### 1. Add Template to Schema
```typescript
// packages/shared/data/cache/cacheSchemas.ts
export type UseCacheSchema = {
// Existing keys...
// Template key with dynamic segment
'scroll.position:${topicId}': number
}
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
// Default shared by all instances of this template
'scroll.position:${topicId}': 0
}
```
#### 2. Use in Code
```typescript
// TypeScript infers number from template pattern
const [scrollPos, setScrollPos] = useCache(`scroll.position:${topicId}`)
// Works with any string in the dynamic segment
const [pos1, setPos1] = useCache('scroll.position:topic-123')
const [pos2, setPos2] = useCache('scroll.position:conversation-abc')
```
### Key Naming Convention
All keys (fixed and template) must follow the naming convention:
- **Format**: `namespace.sub.key_name` or `namespace.key:${variable}`
- **Rules**:
- Start with lowercase letter
- Use lowercase letters, numbers, and underscores
- Separate segments with dots (`.`)
- Use colons (`:`) before template placeholders
- **Examples**:
- ✅ `app.user.avatar`
- ✅ `scroll.position:${id}`
- ✅ `cache.entity:${type}:${id}`
- ❌ `UserAvatar` (no dots)
- ❌ `App.User` (uppercase)
## Shared Cache Ready State
Renderer CacheService provides ready state tracking for SharedCache initialization sync.
@ -303,6 +413,8 @@ unsubscribe()
1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival
2. **Use TTL for stale data**: Prevent serving outdated cached values
3. **Prefer type-safe keys**: Add to schema when possible
4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed
5. **Consider data size**: Persist cache uses localStorage (limited to ~5MB)
6. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync
4. **Use template keys for patterns**: When you have a recurring pattern (e.g., caching by ID), define a template key instead of using casual methods
5. **Reserve casual for truly dynamic keys**: Only use casual methods when the key pattern is completely unknown at development time
6. **Clean up dynamic keys**: Remove casual cache entries when no longer needed
7. **Consider data size**: Persist cache uses localStorage (limited to ~5MB)
8. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync

View File

@ -172,6 +172,9 @@ export default defineConfig([
}
},
// Schema key naming convention (cache & preferences)
// Supports both fixed keys and template keys:
// - Fixed: 'app.user.avatar', 'chat.multi_select_mode'
// - Template: 'scroll.position:${topicId}', 'cache:${type}:${id}'
{
files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'],
plugins: {
@ -181,25 +184,91 @@ export default defineConfig([
meta: {
type: 'problem',
docs: {
description: 'Enforce schema key naming convention: namespace.sub.key_name',
description:
'Enforce schema key naming convention: namespace.sub.key_name or namespace.key:${variable}',
recommended: true
},
messages: {
invalidKey:
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar).'
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar) or with template: namespace.key:${variable} (e.g., scroll.position:${id}).',
invalidTemplateVar:
'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).'
}
},
create(context) {
const VALID_KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
/**
* Validates a schema key for correct naming convention.
*
* Supports two formats:
* 1. Fixed keys: lowercase segments separated by dots
* Example: 'app.user.avatar', 'chat.multi_select_mode'
*
* 2. Template keys: fixed prefix + template placeholders
* Example: 'scroll.position:${id}', 'cache:${type}:${id}'
*
* Template placeholder rules:
* - Must use ${variableName} syntax
* - Variable name must be valid identifier (start with letter, alphanumeric + underscore)
* - Empty placeholders like ${} are invalid
*
* @param {string} key - The schema key to validate
* @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }}
*/
function validateKey(key) {
// Check if key contains template placeholders
const hasTemplate = key.includes('${')
if (hasTemplate) {
// Template key validation
// Must have at least one dot-separated segment before any template or colon
// Example valid: 'scroll.position:${id}', 'cache:${type}:${id}'
// Example invalid: '${id}', ':${id}'
// Extract and validate all template variables
const templateVarPattern = /\$\{([^}]*)\}/g
let match
while ((match = templateVarPattern.exec(key)) !== null) {
const varName = match[1]
// Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore
if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) {
return { valid: false, error: 'invalidTemplateVar' }
}
}
// Replace template placeholders with a marker to validate the structure
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, '__TEMPLATE__')
// Template key structure:
// - Must start with a valid segment (lowercase letters, numbers, underscores)
// - Segments separated by dots or colons
// - Must have at least one dot-separated segment
// - Can end with template placeholder
const templateKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*(:[a-z0-9_]*|:__TEMPLATE__)*$/
if (!templateKeyPattern.test(keyWithoutTemplates)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
} else {
// Fixed key validation: standard dot-separated format
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(key)) {
return { valid: false, error: 'invalidKey' }
}
return { valid: true }
}
}
return {
TSPropertySignature(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
const key = node.key.value
if (!VALID_KEY_PATTERN.test(key)) {
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: 'invalidKey',
messageId: result.error,
data: { key }
})
}
@ -208,10 +277,11 @@ export default defineConfig([
Property(node) {
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
const key = node.key.value
if (!VALID_KEY_PATTERN.test(key)) {
const result = validateKey(key)
if (!result.valid) {
context.report({
node: node.key,
messageId: 'invalidKey',
messageId: result.error,
data: { key }
})
}

View File

@ -19,9 +19,87 @@ import type * as CacheValueTypes from './cacheValueTypes'
* - '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
*/
@ -121,9 +199,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'example_scope.example_key': 'example default value'
}
// ============================================================================
// Cache Key Types
// ============================================================================
/**
* Type-safe cache key
* Key type for renderer persist cache (fixed keys only)
*/
export type RendererPersistCacheKey = keyof RendererPersistCacheSchema
export type UseCacheKey = keyof UseCacheSchema
/**
* 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

View File

@ -19,12 +19,12 @@
import { loggerService } from '@logger'
import type {
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
SharedCacheKey,
SharedCacheSchema,
UseCacheKey,
UseCacheSchema
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes'
@ -102,17 +102,57 @@ export class CacheService {
/**
* Get value from memory cache with TTL validation (type-safe)
* @param key - Schema-defined cache key
*
* Supports both fixed keys and template keys:
* - Fixed keys: `get('app.user.avatar')`
* - Template keys: `get('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`)
*
* DESIGN NOTE: Returns `undefined` when cache miss or TTL expired.
* This is intentional - developers need to know when a value doesn't exist
* (e.g., after explicit deletion) and handle it appropriately.
* For UI components that always need a value, use `useCache` hook instead,
* which provides automatic default value fallback.
*
* @template K - The cache key type (inferred from UseCacheKey, supports template patterns)
* @param key - Schema-defined cache key (fixed or matching template pattern)
* @returns Cached value or undefined if not found or expired
*
* @example
* ```typescript
* // Fixed key - handle undefined explicitly
* const avatar = cacheService.get('app.user.avatar') ?? ''
*
* // Template key (schema: 'scroll.position:${id}': number)
* const scrollPos = cacheService.get('scroll.position:topic-123') ?? 0
* ```
*/
get<K extends UseCacheKey>(key: K): UseCacheSchema[K] {
get<K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined {
return this.getInternal(key)
}
/**
* Get value from memory cache with TTL validation (casual, dynamic key)
* @param key - Dynamic cache key (e.g., `topic:${id}`)
*
* Use this for fully dynamic keys that don't match any schema pattern.
* For keys matching schema patterns (including templates), use `get()` instead.
*
* Note: Due to TypeScript limitations with template literal types, compile-time
* blocking of schema keys works best with literal string arguments. Variable
* keys are accepted but may not trigger compile errors.
*
* @template T - The expected value type (must be specified manually)
* @param key - Dynamic cache key that doesn't match any schema pattern
* @returns Cached value or undefined if not found or expired
*
* @example
* ```typescript
* // Dynamic key with manual type specification
* const data = cacheService.getCasual<MyDataType>('custom.dynamic.key')
*
* // Schema keys should use type-safe methods:
* // Use: cacheService.get('app.user.avatar')
* // Instead of: cacheService.getCasual('app.user.avatar')
* ```
*/
getCasual<T>(key: Exclude<string, UseCacheKey>): T | undefined {
return this.getInternal(key)
@ -138,19 +178,54 @@ export class CacheService {
/**
* Set value in memory cache with optional TTL (type-safe)
* @param key - Schema-defined cache key
* @param value - Value to cache (type inferred from schema)
*
* Supports both fixed keys and template keys:
* - Fixed keys: `set('app.user.avatar', 'url')`
* - Template keys: `set('scroll.position:topic-123', 100)`
*
* @template K - The cache key type (inferred from UseCacheKey, supports template patterns)
* @param key - Schema-defined cache key (fixed or matching template pattern)
* @param value - Value to cache (type inferred from schema via template matching)
* @param ttl - Time to live in milliseconds (optional)
*
* @example
* ```typescript
* // Fixed key
* cacheService.set('app.user.avatar', 'https://example.com/avatar.png')
*
* // Template key (schema: 'scroll.position:${id}': number)
* cacheService.set('scroll.position:topic-123', 150)
*
* // With TTL (expires after 30 seconds)
* cacheService.set('chat.generating', true, 30000)
* ```
*/
set<K extends UseCacheKey>(key: K, value: UseCacheSchema[K], ttl?: number): void {
set<K extends UseCacheKey>(key: K, value: InferUseCacheValue<K>, ttl?: number): void {
this.setInternal(key, value, ttl)
}
/**
* Set value in memory cache with optional TTL (casual, dynamic key)
* @param key - Dynamic cache key (e.g., `topic:${id}`)
*
* Use this for fully dynamic keys that don't match any schema pattern.
* For keys matching schema patterns (including templates), use `set()` instead.
*
* @template T - The value type to cache
* @param key - Dynamic cache key that doesn't match any schema pattern
* @param value - Value to cache
* @param ttl - Time to live in milliseconds (optional)
*
* @example
* ```typescript
* // Dynamic key usage
* cacheService.setCasual('my.custom.key', { data: 'value' })
*
* // With TTL (expires after 60 seconds)
* cacheService.setCasual('temp.data', result, 60000)
*
* // Schema keys should use type-safe methods:
* // Use: cacheService.set('app.user.avatar', 'url')
* ```
*/
setCasual<T>(key: Exclude<string, UseCacheKey>, value: T, ttl?: number): void {
this.setInternal(key, value, ttl)
@ -196,8 +271,19 @@ export class CacheService {
/**
* Check if key exists in memory cache and is not expired (casual, dynamic key)
* @param key - Dynamic cache key
*
* Use this for fully dynamic keys that don't match any schema pattern.
* For keys matching schema patterns (including templates), use `has()` instead.
*
* @param key - Dynamic cache key that doesn't match any schema pattern
* @returns True if key exists and is valid, false otherwise
*
* @example
* ```typescript
* if (cacheService.hasCasual('my.custom.key')) {
* const data = cacheService.getCasual<MyType>('my.custom.key')
* }
* ```
*/
hasCasual(key: Exclude<string, UseCacheKey>): boolean {
return this.hasInternal(key)
@ -233,8 +319,18 @@ export class CacheService {
/**
* Delete from memory cache with hook protection (casual, dynamic key)
* @param key - Dynamic cache key
*
* Use this for fully dynamic keys that don't match any schema pattern.
* For keys matching schema patterns (including templates), use `delete()` instead.
*
* @param key - Dynamic cache key that doesn't match any schema pattern
* @returns True if deletion succeeded, false if key is protected by active hooks
*
* @example
* ```typescript
* // Delete dynamic cache entry
* cacheService.deleteCasual('my.custom.key')
* ```
*/
deleteCasual(key: Exclude<string, UseCacheKey>): boolean {
return this.deleteInternal(key)
@ -274,8 +370,19 @@ export class CacheService {
/**
* Check if a key has TTL set in memory cache (casual, dynamic key)
* @param key - Dynamic cache key
*
* Use this for fully dynamic keys that don't match any schema pattern.
* For keys matching schema patterns (including templates), use `hasTTL()` instead.
*
* @param key - Dynamic cache key that doesn't match any schema pattern
* @returns True if key has TTL configured
*
* @example
* ```typescript
* if (cacheService.hasTTLCasual('my.custom.key')) {
* console.log('This cache entry will expire')
* }
* ```
*/
hasTTLCasual(key: Exclude<string, UseCacheKey>): boolean {
const entry = this.memoryCache.get(key)

View File

@ -1,6 +1,7 @@
import { cacheService } from '@data/CacheService'
import { loggerService } from '@logger'
import type {
InferUseCacheValue,
RendererPersistCacheKey,
RendererPersistCacheSchema,
SharedCacheKey,
@ -10,59 +11,192 @@ import type {
} from '@shared/data/cache/cacheSchemas'
import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas'
import { useCallback, useEffect, useSyncExternalStore } from 'react'
const logger = loggerService.withContext('useCache')
// ============================================================================
// Template Matching Utilities
// ============================================================================
/**
* Checks if a schema key is a template key (contains ${...} placeholder).
*
* @param key - The schema key to check
* @returns true if the key contains template placeholder syntax
*
* @example
* ```typescript
* isTemplateKey('scroll.position:${id}') // true
* isTemplateKey('app.user.avatar') // false
* ```
*/
function isTemplateKey(key: string): boolean {
return key.includes('${') && key.includes('}')
}
/**
* Converts a template key pattern into a RegExp for matching concrete keys.
*
* Each `${variable}` placeholder is replaced with a pattern that matches
* any non-empty string of word characters, dots, and hyphens.
*
* @param template - The template key pattern (e.g., 'scroll.position:${id}')
* @returns A RegExp that matches concrete keys for this template
*
* @example
* ```typescript
* const regex = templateToRegex('scroll.position:${id}')
* regex.test('scroll.position:topic-123') // true
* regex.test('scroll.position:') // false
* regex.test('other.key:123') // false
* ```
*/
function templateToRegex(template: string): RegExp {
// Escape special regex characters except for ${...} placeholders
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
// Don't escape the ${...} syntax, we'll handle it specially
if (match === '$' || match === '{' || match === '}') {
return match
}
return '\\' + match
})
// Replace ${...} placeholders with a pattern matching non-empty strings
// Allows: word chars, dots, hyphens, underscores, colons
const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w.\\-_:]+)')
return new RegExp(`^${pattern}$`)
}
/**
* Finds the schema key that matches a given concrete key.
*
* First checks for exact match (fixed keys), then checks template patterns.
* This is used to look up default values for template keys.
*
* @param key - The concrete key to find a match for
* @returns The matching schema key, or undefined if no match found
*
* @example
* ```typescript
* // Given schema has 'app.user.avatar' and 'scroll.position:${id}'
*
* findMatchingUseCacheSchemaKey('app.user.avatar') // 'app.user.avatar'
* findMatchingUseCacheSchemaKey('scroll.position:123') // 'scroll.position:${id}'
* findMatchingUseCacheSchemaKey('unknown.key') // undefined
* ```
*/
function findMatchingUseCacheSchemaKey(key: string): keyof UseCacheSchema | undefined {
// First, check for exact match (fixed keys)
if (key in DefaultUseCache) {
return key as keyof UseCacheSchema
}
// Then, check template patterns
const schemaKeys = Object.keys(DefaultUseCache) as Array<keyof UseCacheSchema>
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 cache key from the schema.
*
* Works with both fixed keys (direct lookup) and concrete keys that
* match template patterns (finds template, returns its default).
*
* @param key - The cache key (fixed or concrete template instance)
* @returns The default value from schema, or undefined if not found
*
* @example
* ```typescript
* // Given schema:
* // 'app.user.avatar': '' (default)
* // 'scroll.position:${id}': 0 (default)
*
* getUseCacheDefaultValue('app.user.avatar') // ''
* getUseCacheDefaultValue('scroll.position:123') // 0
* getUseCacheDefaultValue('unknown.key') // undefined
* ```
*/
function getUseCacheDefaultValue<K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined {
const schemaKey = findMatchingUseCacheSchemaKey(key)
if (schemaKey) {
return DefaultUseCache[schemaKey] as InferUseCacheValue<K>
}
return undefined
}
/**
* React hook for component-level memory cache
*
* Use this for data that needs to be shared between components in the same window.
* Data is lost when the app restarts.
*
* @param key - Cache key from the predefined schema
* Supports both fixed keys and template keys:
* - Fixed keys: `useCache('app.user.avatar')`
* - Template keys: `useCache('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`)
*
* @template K - The cache key type (inferred from UseCacheKey)
* @param key - Cache key from the predefined schema (fixed or matching template pattern)
* @param initValue - Initial value (optional, uses schema default if not provided)
* @returns [value, setValue] - Similar to useState but shared across components
*
* @example
* ```typescript
* // Basic usage
* const [theme, setTheme] = useCache('ui.theme')
* // Fixed key usage
* const [avatar, setAvatar] = useCache('app.user.avatar')
*
* // Template key usage (schema: 'scroll.position:${id}': number)
* const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123')
* // TypeScript infers scrollPos as number
*
* // With custom initial value
* const [count, setCount] = useCache('counter', 0)
* const [generating, setGenerating] = useCache('chat.generating', true)
*
* // Update the value
* setTheme('dark')
* setAvatar('new-avatar-url')
* ```
*/
export function useCache<K extends UseCacheKey>(
key: K,
initValue?: UseCacheSchema[K]
): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] {
initValue?: InferUseCacheValue<K>
): [InferUseCacheValue<K>, (value: InferUseCacheValue<K>) => void] {
// Get the default value for this key (works with both fixed and template keys)
const defaultValue = getUseCacheDefaultValue(key)
/**
* Subscribe to cache changes using React's useSyncExternalStore
* This ensures the component re-renders when the cache value changes
*/
const value = useSyncExternalStore(
useCallback((callback) => cacheService.subscribe(key, callback), [key]),
useCallback(() => cacheService.get(key), [key]),
useCallback(() => cacheService.get(key), [key]) // SSR snapshot
useCallback(() => cacheService.get(key) as InferUseCacheValue<K> | undefined, [key]),
useCallback(() => cacheService.get(key) as InferUseCacheValue<K> | undefined, [key]) // SSR snapshot
)
/**
* Initialize cache with default value if it doesn't exist
* Priority: existing cache value > custom initValue > schema default
* Priority: existing cache value > custom initValue > schema default (via template matching)
*/
useEffect(() => {
if (cacheService.has(key)) {
return
}
if (initValue === undefined) {
cacheService.set(key, DefaultUseCache[key])
} else {
if (initValue !== undefined) {
cacheService.set(key, initValue)
} else if (defaultValue !== undefined) {
cacheService.set(key, defaultValue)
}
}, [key, initValue])
}, [key, initValue, defaultValue])
/**
* Register this hook as actively using the cache key
@ -90,13 +224,13 @@ export function useCache<K extends UseCacheKey>(
* @param newValue - New value to store in cache
*/
const setValue = useCallback(
(newValue: UseCacheSchema[K]) => {
(newValue: InferUseCacheValue<K>) => {
cacheService.set(key, newValue)
},
[key]
)
return [value ?? initValue ?? DefaultUseCache[key], setValue]
return [value ?? initValue ?? defaultValue!, setValue]
}
/**

View File

@ -191,7 +191,8 @@ class WebSearchService {
*
*/
private async setWebSearchStatus(requestId: string, status: WebSearchStatus, delayMs?: number) {
const activeSearches = cacheService.get('chat.websearch.active_searches')
// Use ?? {} to handle cache miss (cacheService.get returns undefined when not cached)
const activeSearches = cacheService.get('chat.websearch.active_searches') ?? {}
activeSearches[requestId] = status
cacheService.set('chat.websearch.active_searches', activeSearches)