mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-08 06:19:05 +08:00
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:
parent
b6a1240bd8
commit
fb51df99d0
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
180
packages/shared/data/cache/cacheSchemas.ts
vendored
180
packages/shared/data/cache/cacheSchemas.ts
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user