mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 07:19:02 +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
|
- Main process resolves conflicts
|
||||||
|
|
||||||
### Type Safety
|
### Type Safety
|
||||||
- Schema-based keys for compile-time checking
|
- **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`)
|
||||||
- Casual methods for dynamic keys with manual typing
|
- **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
|
## 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).
|
For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md).
|
||||||
|
|
||||||
| Method | Tier | Type Safety |
|
### Key Types
|
||||||
|--------|------|-------------|
|
|
||||||
| `useCache` / `get` / `set` | Memory | Schema-based keys |
|
| Type | Example Schema | Example Usage | Type Inference |
|
||||||
| `getCasual` / `setCasual` | Memory | Dynamic keys (manual typing) |
|
|------|----------------|---------------|----------------|
|
||||||
| `useSharedCache` / `getShared` / `setShared` | Shared | Schema-based keys |
|
| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic |
|
||||||
| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys (manual typing) |
|
| Template key | `'scroll.position:${id}': number` | `get('scroll.position:topic-123')` | Automatic |
|
||||||
| `usePersistCache` / `getPersist` / `setPersist` | Persist | Schema-based keys only |
|
| 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
|
- Use dynamically constructed keys
|
||||||
- Require manual type specification via generics
|
- Require manual type specification via generics
|
||||||
- No compile-time key validation
|
- No compile-time key validation
|
||||||
|
- **Cannot use keys that match schema patterns** (including template keys)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Dynamic key, must specify type
|
// 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
|
### When to Use Which
|
||||||
|
|
||||||
| Scenario | Method | Example |
|
| Scenario | Method | Example |
|
||||||
|----------|--------|---------|
|
|----------|--------|---------|
|
||||||
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
|
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
|
||||||
| Entity caching by ID | Casual | `getCasual<Topic>(\`topic:${id}\`)` |
|
| Dynamic keys with known pattern | Template key | `useCache('scroll.position:topic-123')` |
|
||||||
| Session-based keys | Casual | `setCasual(\`session:${sessionId}\`)` |
|
| 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')` |
|
| UI state | Type-safe | `useSharedCache('window.layout')` |
|
||||||
|
|
||||||
## Common Patterns
|
## Common Patterns
|
||||||
@ -243,17 +299,24 @@ function useCachedWithExpiry<T>(key: string, fetcher: () => Promise<T>, maxAge:
|
|||||||
|
|
||||||
## Adding New Cache Keys
|
## Adding New Cache Keys
|
||||||
|
|
||||||
### 1. Add to Cache Schema
|
### Adding Fixed Keys
|
||||||
|
|
||||||
|
#### 1. Add to Cache Schema
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// packages/shared/data/cache/cacheSchemas.ts
|
// packages/shared/data/cache/cacheSchemas.ts
|
||||||
export interface CacheSchema {
|
export type UseCacheSchema = {
|
||||||
// Existing keys...
|
// Existing keys...
|
||||||
'myFeature.data': MyDataType
|
'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
|
```typescript
|
||||||
// packages/shared/data/cache/cacheValueTypes.ts
|
// packages/shared/data/cache/cacheValueTypes.ts
|
||||||
@ -263,13 +326,60 @@ export interface MyDataType {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Use in Code
|
#### 3. Use in Code
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Now type-safe
|
// 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
|
## Shared Cache Ready State
|
||||||
|
|
||||||
Renderer CacheService provides ready state tracking for SharedCache initialization sync.
|
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
|
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
|
2. **Use TTL for stale data**: Prevent serving outdated cached values
|
||||||
3. **Prefer type-safe keys**: Add to schema when possible
|
3. **Prefer type-safe keys**: Add to schema when possible
|
||||||
4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed
|
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. **Consider data size**: Persist cache uses localStorage (limited to ~5MB)
|
5. **Reserve casual for truly dynamic keys**: Only use casual methods when the key pattern is completely unknown at development time
|
||||||
6. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync
|
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)
|
// 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'],
|
files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'],
|
||||||
plugins: {
|
plugins: {
|
||||||
@ -181,25 +184,91 @@ export default defineConfig([
|
|||||||
meta: {
|
meta: {
|
||||||
type: 'problem',
|
type: 'problem',
|
||||||
docs: {
|
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
|
recommended: true
|
||||||
},
|
},
|
||||||
messages: {
|
messages: {
|
||||||
invalidKey:
|
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) {
|
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 {
|
return {
|
||||||
TSPropertySignature(node) {
|
TSPropertySignature(node) {
|
||||||
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
||||||
const key = node.key.value
|
const key = node.key.value
|
||||||
if (!VALID_KEY_PATTERN.test(key)) {
|
const result = validateKey(key)
|
||||||
|
if (!result.valid) {
|
||||||
context.report({
|
context.report({
|
||||||
node: node.key,
|
node: node.key,
|
||||||
messageId: 'invalidKey',
|
messageId: result.error,
|
||||||
data: { key }
|
data: { key }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -208,10 +277,11 @@ export default defineConfig([
|
|||||||
Property(node) {
|
Property(node) {
|
||||||
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
if (node.key.type === 'Literal' && typeof node.key.value === 'string') {
|
||||||
const key = node.key.value
|
const key = node.key.value
|
||||||
if (!VALID_KEY_PATTERN.test(key)) {
|
const result = validateKey(key)
|
||||||
|
if (!result.valid) {
|
||||||
context.report({
|
context.report({
|
||||||
node: node.key,
|
node: node.key,
|
||||||
messageId: 'invalidKey',
|
messageId: result.error,
|
||||||
data: { key }
|
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)
|
* - 'userAvatar' (invalid - missing dot separator)
|
||||||
* - 'App.user' (invalid - uppercase not allowed)
|
* - '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
|
* 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
|
* Use cache schema for renderer hook
|
||||||
*/
|
*/
|
||||||
@ -121,9 +199,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
|
|||||||
'example_scope.example_key': 'example default value'
|
'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 RendererPersistCacheKey = keyof RendererPersistCacheSchema
|
||||||
export type UseCacheKey = keyof UseCacheSchema
|
|
||||||
|
/**
|
||||||
|
* Key type for shared cache (fixed keys only)
|
||||||
|
*/
|
||||||
export type SharedCacheKey = keyof SharedCacheSchema
|
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 { loggerService } from '@logger'
|
||||||
import type {
|
import type {
|
||||||
|
InferUseCacheValue,
|
||||||
RendererPersistCacheKey,
|
RendererPersistCacheKey,
|
||||||
RendererPersistCacheSchema,
|
RendererPersistCacheSchema,
|
||||||
SharedCacheKey,
|
SharedCacheKey,
|
||||||
SharedCacheSchema,
|
SharedCacheSchema,
|
||||||
UseCacheKey,
|
UseCacheKey
|
||||||
UseCacheSchema
|
|
||||||
} from '@shared/data/cache/cacheSchemas'
|
} from '@shared/data/cache/cacheSchemas'
|
||||||
import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas'
|
import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas'
|
||||||
import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes'
|
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)
|
* 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
|
* @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)
|
return this.getInternal(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get value from memory cache with TTL validation (casual, dynamic 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
|
* @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 {
|
getCasual<T>(key: Exclude<string, UseCacheKey>): T | undefined {
|
||||||
return this.getInternal(key)
|
return this.getInternal(key)
|
||||||
@ -138,19 +178,54 @@ export class CacheService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set value in memory cache with optional TTL (type-safe)
|
* 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)
|
* @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)
|
this.setInternal(key, value, ttl)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set value in memory cache with optional TTL (casual, dynamic key)
|
* 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 value - Value to cache
|
||||||
* @param ttl - Time to live in milliseconds (optional)
|
* @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 {
|
setCasual<T>(key: Exclude<string, UseCacheKey>, value: T, ttl?: number): void {
|
||||||
this.setInternal(key, value, ttl)
|
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)
|
* 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
|
* @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 {
|
hasCasual(key: Exclude<string, UseCacheKey>): boolean {
|
||||||
return this.hasInternal(key)
|
return this.hasInternal(key)
|
||||||
@ -233,8 +319,18 @@ export class CacheService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete from memory cache with hook protection (casual, dynamic key)
|
* 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
|
* @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 {
|
deleteCasual(key: Exclude<string, UseCacheKey>): boolean {
|
||||||
return this.deleteInternal(key)
|
return this.deleteInternal(key)
|
||||||
@ -274,8 +370,19 @@ export class CacheService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a key has TTL set in memory cache (casual, dynamic key)
|
* 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
|
* @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 {
|
hasTTLCasual(key: Exclude<string, UseCacheKey>): boolean {
|
||||||
const entry = this.memoryCache.get(key)
|
const entry = this.memoryCache.get(key)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { cacheService } from '@data/CacheService'
|
import { cacheService } from '@data/CacheService'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import type {
|
import type {
|
||||||
|
InferUseCacheValue,
|
||||||
RendererPersistCacheKey,
|
RendererPersistCacheKey,
|
||||||
RendererPersistCacheSchema,
|
RendererPersistCacheSchema,
|
||||||
SharedCacheKey,
|
SharedCacheKey,
|
||||||
@ -10,59 +11,192 @@ import type {
|
|||||||
} from '@shared/data/cache/cacheSchemas'
|
} from '@shared/data/cache/cacheSchemas'
|
||||||
import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas'
|
import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas'
|
||||||
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
const logger = loggerService.withContext('useCache')
|
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
|
* React hook for component-level memory cache
|
||||||
*
|
*
|
||||||
* Use this for data that needs to be shared between components in the same window.
|
* Use this for data that needs to be shared between components in the same window.
|
||||||
* Data is lost when the app restarts.
|
* 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)
|
* @param initValue - Initial value (optional, uses schema default if not provided)
|
||||||
* @returns [value, setValue] - Similar to useState but shared across components
|
* @returns [value, setValue] - Similar to useState but shared across components
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Basic usage
|
* // Fixed key usage
|
||||||
* const [theme, setTheme] = useCache('ui.theme')
|
* 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
|
* // With custom initial value
|
||||||
* const [count, setCount] = useCache('counter', 0)
|
* const [generating, setGenerating] = useCache('chat.generating', true)
|
||||||
*
|
*
|
||||||
* // Update the value
|
* // Update the value
|
||||||
* setTheme('dark')
|
* setAvatar('new-avatar-url')
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useCache<K extends UseCacheKey>(
|
export function useCache<K extends UseCacheKey>(
|
||||||
key: K,
|
key: K,
|
||||||
initValue?: UseCacheSchema[K]
|
initValue?: InferUseCacheValue<K>
|
||||||
): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] {
|
): [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
|
* Subscribe to cache changes using React's useSyncExternalStore
|
||||||
* This ensures the component re-renders when the cache value changes
|
* This ensures the component re-renders when the cache value changes
|
||||||
*/
|
*/
|
||||||
const value = useSyncExternalStore(
|
const value = useSyncExternalStore(
|
||||||
useCallback((callback) => cacheService.subscribe(key, callback), [key]),
|
useCallback((callback) => cacheService.subscribe(key, callback), [key]),
|
||||||
useCallback(() => cacheService.get(key), [key]),
|
useCallback(() => cacheService.get(key) as InferUseCacheValue<K> | undefined, [key]),
|
||||||
useCallback(() => cacheService.get(key), [key]) // SSR snapshot
|
useCallback(() => cacheService.get(key) as InferUseCacheValue<K> | undefined, [key]) // SSR snapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize cache with default value if it doesn't exist
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (cacheService.has(key)) {
|
if (cacheService.has(key)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initValue === undefined) {
|
if (initValue !== undefined) {
|
||||||
cacheService.set(key, DefaultUseCache[key])
|
|
||||||
} else {
|
|
||||||
cacheService.set(key, initValue)
|
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
|
* 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
|
* @param newValue - New value to store in cache
|
||||||
*/
|
*/
|
||||||
const setValue = useCallback(
|
const setValue = useCallback(
|
||||||
(newValue: UseCacheSchema[K]) => {
|
(newValue: InferUseCacheValue<K>) => {
|
||||||
cacheService.set(key, newValue)
|
cacheService.set(key, newValue)
|
||||||
},
|
},
|
||||||
[key]
|
[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) {
|
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
|
activeSearches[requestId] = status
|
||||||
|
|
||||||
cacheService.set('chat.websearch.active_searches', activeSearches)
|
cacheService.set('chat.websearch.active_searches', activeSearches)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user