fix(cache): enforce dot-separated naming for template keys

- Update template key pattern to use dots instead of colons
  (e.g., 'scroll.position.${id}' not 'scroll.position:${id}')
- Template keys follow same naming convention as fixed keys
- Add example template keys to schema for testing
- Add comprehensive type tests for template key inference
- Update mock files to support template key types
- Update documentation with correct template key examples

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
fullex 2026-01-05 13:17:58 +08:00
parent fb51df99d0
commit 2093452e69
9 changed files with 362 additions and 138 deletions

View File

@ -52,9 +52,11 @@ cacheService.set('temp.calculation', result, 30000)
### Type Safety
- **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'`)
- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position.${id}'` matches `'scroll.position.topic123'`)
- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys)
Note: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www`
## Data Categories
### Performance Cache (Memory tier)
@ -126,8 +128,8 @@ For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.
| 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 |
| Template key | `'scroll.position.${id}': number` | `get('scroll.position.topic123')` | Automatic |
| Casual key | N/A | `getCasual<T>('my.custom.key')` | Manual |
### API Reference

View File

@ -163,13 +163,15 @@ 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
cacheService.getCasual('scroll.position.topic123') // 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.
**Important**: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www`
#### Defining Template Keys
```typescript
@ -179,15 +181,16 @@ export type UseCacheSchema = {
'app.user.avatar': string
// Template keys - use ${variable} for dynamic segments
'scroll.position:${topicId}': number
'entity.cache:${type}:${id}': EntityData
// Must follow dot-separated pattern like fixed keys
'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 }
'scroll.position.${topicId}': 0,
'entity.cache.${type}_${id}': { loaded: false }
}
```
@ -195,15 +198,15 @@ export const DefaultUseCache: UseCacheSchema = {
```typescript
// TypeScript infers the value type from schema
const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123')
const [scrollPos, setScrollPos] = useCache('scroll.position.topic123')
// scrollPos is inferred as `number`
const [entity, setEntity] = useCache('entity.cache:user:456')
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
cacheService.set('scroll.position.mytopic', 150) // OK: value must be number
cacheService.set('scroll.position.mytopic', 'hi') // Error: type mismatch
```
#### Template Key Benefits
@ -221,9 +224,9 @@ cacheService.set('scroll.position:my-topic', 'hi') // Error: type mismatch
| Scenario | Method | Example |
|----------|--------|---------|
| Fixed cache keys | Type-safe | `useCache('ui.counter')` |
| 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}\`)` |
| Dynamic keys with known pattern | Template key | `useCache('scroll.position.topic123')` |
| Entity caching by ID | Template key | `get('entity.cache.user_456')` |
| Completely dynamic keys | Casual | `getCasual<T>(\`custom.dynamic.${x}\`)` |
| UI state | Type-safe | `useSharedCache('window.layout')` |
## Common Patterns
@ -342,13 +345,13 @@ const [data, setData] = useCache('myFeature.data')
export type UseCacheSchema = {
// Existing keys...
// Template key with dynamic segment
'scroll.position:${topicId}': number
'scroll.position.${topicId}': number
}
export const DefaultUseCache: UseCacheSchema = {
// Existing defaults...
// Default shared by all instances of this template
'scroll.position:${topicId}': 0
'scroll.position.${topicId}': 0
}
```
@ -356,27 +359,28 @@ export const DefaultUseCache: UseCacheSchema = {
```typescript
// TypeScript infers number from template pattern
const [scrollPos, setScrollPos] = useCache(`scroll.position:${topicId}`)
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')
const [pos1, setPos1] = useCache('scroll.position.topic123')
const [pos2, setPos2] = useCache('scroll.position.conversationabc')
```
### Key Naming Convention
All keys (fixed and template) must follow the naming convention:
All keys (fixed and template) must follow the same naming convention:
- **Format**: `namespace.sub.key_name` or `namespace.key:${variable}`
- **Format**: `namespace.sub.key_name` (template `${xxx}` treated as a literal string segment)
- **Rules**:
- Start with lowercase letter
- Use lowercase letters, numbers, and underscores
- Separate segments with dots (`.`)
- Use colons (`:`) before template placeholders
- Template placeholders `${xxx}` are treated as literal string segments
- **Examples**:
- ✅ `app.user.avatar`
- ✅ `scroll.position:${id}`
- ✅ `cache.entity:${type}:${id}`
- ✅ `scroll.position.${id}`
- ✅ `entity.cache.${type}_${id}`
- ❌ `scroll.position:${id}` (colon not allowed)
- ❌ `UserAvatar` (no dots)
- ❌ `App.User` (uppercase)

View File

@ -174,7 +174,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}'
// - Template: 'scroll.position.${topicId}', 'entity.cache.${type}_${id}'
// Template keys must follow the same dot-separated pattern as fixed keys.
// When ${xxx} placeholders are treated as literal strings, the key must match: xxx.yyy.zzz_www
{
files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'],
plugins: {
@ -185,12 +187,12 @@ export default defineConfig([
type: 'problem',
docs: {
description:
'Enforce schema key naming convention: namespace.sub.key_name or namespace.key:${variable}',
'Enforce schema key naming convention: namespace.sub.key_name (template placeholders treated as literal strings)',
recommended: true
},
messages: {
invalidKey:
'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}).',
'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar, scroll.position.${id}). Template ${xxx} is treated as a literal string segment.',
invalidTemplateVar:
'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).'
}
@ -199,17 +201,14 @@ export default defineConfig([
/**
* 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'
* Both fixed keys and template keys must follow the same pattern:
* - Lowercase segments separated by dots
* - Each segment: starts with letter, contains letters/numbers/underscores
* - At least two segments (must have at least one dot)
*
* 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
* Template keys: ${xxx} placeholders are treated as literal string segments.
* Example valid: 'scroll.position.${id}', 'entity.cache.${type}_${id}'
* Example invalid: 'cache:${type}' (colon not allowed), '${id}' (no dot)
*
* @param {string} key - The schema key to validate
* @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }}
@ -219,12 +218,7 @@ export default defineConfig([
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
// Validate template variable names first
const templateVarPattern = /\$\{([^}]*)\}/g
let match
while ((match = templateVarPattern.exec(key)) !== null) {
@ -235,17 +229,14 @@ export default defineConfig([
}
}
// Replace template placeholders with a marker to validate the structure
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, '__TEMPLATE__')
// Replace template placeholders with a valid segment marker
// Use 'x' as placeholder since it's a valid segment character
const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x')
// 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)) {
// Template key must follow the same pattern as fixed keys
// when ${xxx} is treated as a literal string
const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
if (!fixedKeyPattern.test(keyWithoutTemplates)) {
return { valid: false, error: 'invalidKey' }
}

View File

@ -5,32 +5,35 @@ import type * as CacheValueTypes from './cacheValueTypes'
*
* ## Key Naming Convention
*
* All cache keys MUST follow the format: `namespace.sub.key_name`
* All cache keys (fixed and template) MUST follow the format: `namespace.sub.key_name`
*
* Rules:
* - At least 2 segments separated by dots (.)
* - Each segment uses lowercase letters, numbers, and underscores only
* - Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/
* - Template placeholders `${xxx}` are treated as literal string segments
*
* Examples:
* - 'app.user.avatar' (valid)
* - 'chat.multi_select_mode' (valid)
* - 'minapp.opened_keep_alive' (valid)
* - 'scroll.position.${topicId}' (valid template key)
* - 'userAvatar' (invalid - missing dot separator)
* - 'App.user' (invalid - uppercase not allowed)
* - 'scroll.position:${id}' (invalid - colon 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.
* Template keys follow the same dot-separated pattern as fixed keys.
*
* Examples:
* - Schema: `'scroll.position:${topicId}': number`
* - Usage: `useCache('scroll.position:topic-123')` -> infers `number` type
* - Schema: `'scroll.position.${topicId}': number`
* - Usage: `useCache('scroll.position.topic123')` -> infers `number` type
*
* Multiple placeholders are supported:
* - Schema: `'cache:${type}:${id}': CacheData`
* - Usage: `useCache('cache:user:456')` -> infers `CacheData` type
* - Schema: `'entity.cache.${type}_${id}': CacheData`
* - Usage: `useCache('entity.cache.user_456')` -> infers `CacheData` type
*
* This convention is enforced by ESLint rule: data-schema-key/valid-key
*/
@ -50,9 +53,9 @@ import type * as CacheValueTypes from './cacheValueTypes'
*
* @example
* ```typescript
* type Test1 = IsTemplateKey<'scroll:${id}'> // true
* type Test2 = IsTemplateKey<'cache:${a}:${b}'> // true
* type Test3 = IsTemplateKey<'app.user.avatar'> // false
* type Test1 = IsTemplateKey<'scroll.position.${id}'> // true
* type Test2 = IsTemplateKey<'entity.cache.${a}_${b}'> // true
* type Test3 = IsTemplateKey<'app.user.avatar'> // false
* ```
*/
export type IsTemplateKey<K extends string> = K extends `${string}\${${string}}${string}` ? true : false
@ -69,11 +72,11 @@ export type IsTemplateKey<K extends string> = K extends `${string}\${${string}}$
*
* @example
* ```typescript
* type Test1 = ExpandTemplateKey<'scroll:${id}'>
* // Result: `scroll:${string}` (matches 'scroll:123', 'scroll:abc', etc.)
* type Test1 = ExpandTemplateKey<'scroll.position.${id}'>
* // Result: `scroll.position.${string}` (matches 'scroll.position.123', etc.)
*
* type Test2 = ExpandTemplateKey<'cache:${type}:${id}'>
* // Result: `cache:${string}:${string}` (matches 'cache:user:123', etc.)
* type Test2 = ExpandTemplateKey<'entity.cache.${type}_${id}'>
* // Result: `entity.cache.${string}_${string}` (matches 'entity.cache.user_123', etc.)
*
* type Test3 = ExpandTemplateKey<'app.user.avatar'>
* // Result: 'app.user.avatar' (unchanged for non-template keys)
@ -94,8 +97,8 @@ export type ExpandTemplateKey<T extends string> = T extends `${infer Prefix}\${$
*
* @example
* ```typescript
* type Test1 = ProcessKey<'scroll:${id}'> // `scroll:${string}`
* type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar'
* type Test1 = ProcessKey<'scroll.position.${id}'> // `scroll.position.${string}`
* type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar'
* ```
*/
export type ProcessKey<K extends string> = IsTemplateKey<K> extends true ? ExpandTemplateKey<K> : K
@ -135,6 +138,10 @@ export type UseCacheSchema = {
'agent.active_id': string | null
'agent.session.active_id_map': Record<string, string | null>
'agent.session.waiting_id_map': Record<string, boolean>
// Template key examples (for testing and demonstration)
'scroll.position.${topicId}': number
'entity.cache.${type}_${id}': { loaded: boolean; data: unknown }
}
export const DefaultUseCache: UseCacheSchema = {
@ -173,7 +180,11 @@ export const DefaultUseCache: UseCacheSchema = {
// Agent management
'agent.active_id': null,
'agent.session.active_id_map': {},
'agent.session.waiting_id_map': {}
'agent.session.waiting_id_map': {},
// Template key examples (for testing and demonstration)
'scroll.position.${topicId}': 0,
'entity.cache.${type}_${id}': { loaded: false, data: null }
}
/**
@ -218,7 +229,7 @@ export type SharedCacheKey = keyof SharedCacheSchema
*
* 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}`)
* - Expands template keys to match patterns (e.g., 'scroll.position.${id}' -> `scroll.position.${string}`)
*
* The resulting union type allows TypeScript to accept any concrete key
* that matches either a fixed key or an expanded template pattern.
@ -227,17 +238,17 @@ export type SharedCacheKey = keyof SharedCacheSchema
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
* // 'scroll.position.${topicId}': number
*
* // UseCacheKey becomes: 'app.user.avatar' | `scroll.position:${string}`
* // 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
* 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
* const k4: UseCacheKey = 'unknown.key' // error: not in schema
* ```
*/
export type UseCacheKey = {
@ -264,12 +275,12 @@ export type UseCacheKey = {
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
* // '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
* 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> = {
@ -291,15 +302,15 @@ export type InferUseCacheValue<K extends string> = {
* ```typescript
* // Given schema:
* // 'app.user.avatar': string
* // 'scroll.position:${topicId}': number
* // '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)
* 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
* getCasual('other.dynamic.key') // OK
* ```
*/
export type UseCacheCasualKey<K extends string> = K extends UseCacheKey ? never : K

View File

@ -105,7 +105,10 @@ export class CacheService {
*
* 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}'`)
* - Template keys: `get('scroll.position.topic123')` (matches schema `'scroll.position.${id}'`)
*
* Template keys follow the same dot-separated pattern as fixed keys.
* When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www
*
* DESIGN NOTE: Returns `undefined` when cache miss or TTL expired.
* This is intentional - developers need to know when a value doesn't exist
@ -122,8 +125,8 @@ export class CacheService {
* // 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
* // Template key (schema: 'scroll.position.${id}': number)
* const scrollPos = cacheService.get('scroll.position.topic123') ?? 0
* ```
*/
get<K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined {
@ -181,7 +184,9 @@ export class CacheService {
*
* Supports both fixed keys and template keys:
* - Fixed keys: `set('app.user.avatar', 'url')`
* - Template keys: `set('scroll.position:topic-123', 100)`
* - Template keys: `set('scroll.position.topic123', 100)`
*
* Template keys follow the same dot-separated pattern as fixed keys.
*
* @template K - The cache key type (inferred from UseCacheKey, supports template patterns)
* @param key - Schema-defined cache key (fixed or matching template pattern)
@ -193,8 +198,8 @@ export class CacheService {
* // 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)
* // Template key (schema: 'scroll.position.${id}': number)
* cacheService.set('scroll.position.topic123', 150)
*
* // With TTL (expires after 30 seconds)
* cacheService.set('chat.generating', true, 30000)

View File

@ -0,0 +1,149 @@
/**
* Type-level tests for template key type inference
*
* These tests verify compile-time type behavior of the cache system:
* 1. Template key type inference works correctly
* 2. Casual API blocks schema keys (including template patterns)
* 3. Value types are correctly inferred from schema
*/
import type {
ExpandTemplateKey,
InferUseCacheValue,
IsTemplateKey,
ProcessKey,
UseCacheCasualKey,
UseCacheKey
} from '@shared/data/cache/cacheSchemas'
import { describe, expect, expectTypeOf, it } from 'vitest'
describe('Template Key Type Utilities', () => {
describe('IsTemplateKey', () => {
it('should detect template keys as true', () => {
// Using expectTypeOf for type-level assertions
const templateResult1: IsTemplateKey<'scroll.position.${id}'> = true
const templateResult2: IsTemplateKey<'entity.cache.${type}_${id}'> = true
expect(templateResult1).toBe(true)
expect(templateResult2).toBe(true)
})
it('should detect fixed keys as false', () => {
const fixedResult1: IsTemplateKey<'app.user.avatar'> = false
const fixedResult2: IsTemplateKey<'chat.generating'> = false
expect(fixedResult1).toBe(false)
expect(fixedResult2).toBe(false)
})
})
describe('ExpandTemplateKey', () => {
it('should expand single placeholder', () => {
// Type assertion: 'scroll.position.topic123' should extend the expanded type
type Expanded = ExpandTemplateKey<'scroll.position.${id}'>
const key1: Expanded = 'scroll.position.topic123'
const key2: Expanded = 'scroll.position.abc'
expect(key1).toBe('scroll.position.topic123')
expect(key2).toBe('scroll.position.abc')
})
it('should expand multiple placeholders', () => {
type Expanded = ExpandTemplateKey<'entity.cache.${type}_${id}'>
const key1: Expanded = 'entity.cache.user_123'
const key2: Expanded = 'entity.cache.post_456'
expect(key1).toBe('entity.cache.user_123')
expect(key2).toBe('entity.cache.post_456')
})
it('should leave fixed keys unchanged', () => {
type Expanded = ExpandTemplateKey<'app.user.avatar'>
const key: Expanded = 'app.user.avatar'
expect(key).toBe('app.user.avatar')
})
})
describe('ProcessKey', () => {
it('should expand template keys', () => {
type Processed = ProcessKey<'scroll.position.${topicId}'>
const key: Processed = 'scroll.position.topic123'
expect(key).toBe('scroll.position.topic123')
})
it('should keep fixed keys unchanged', () => {
type Processed = ProcessKey<'app.user.avatar'>
const key: Processed = 'app.user.avatar'
expect(key).toBe('app.user.avatar')
})
})
describe('UseCacheKey', () => {
it('should include fixed keys', () => {
const key1: UseCacheKey = 'app.user.avatar'
const key2: UseCacheKey = 'chat.generating'
expect(key1).toBe('app.user.avatar')
expect(key2).toBe('chat.generating')
})
it('should match template patterns', () => {
const key1: UseCacheKey = 'scroll.position.topic123'
const key2: UseCacheKey = 'scroll.position.abc-def'
const key3: UseCacheKey = 'entity.cache.user_456'
expect(key1).toBe('scroll.position.topic123')
expect(key2).toBe('scroll.position.abc-def')
expect(key3).toBe('entity.cache.user_456')
})
})
describe('InferUseCacheValue', () => {
it('should infer value type for fixed keys', () => {
// These type assertions verify the type system works
const avatarType: InferUseCacheValue<'app.user.avatar'> = 'test'
const generatingType: InferUseCacheValue<'chat.generating'> = true
expectTypeOf(avatarType).toBeString()
expectTypeOf(generatingType).toBeBoolean()
})
it('should infer value type for template key instances', () => {
const scrollType: InferUseCacheValue<'scroll.position.topic123'> = 100
const entityType: InferUseCacheValue<'entity.cache.user_456'> = { loaded: true, data: null }
expectTypeOf(scrollType).toBeNumber()
expectTypeOf(entityType).toMatchTypeOf<{ loaded: boolean; data: unknown }>()
})
it('should return never for unknown keys', () => {
// Unknown key should infer to never
type UnknownValue = InferUseCacheValue<'unknown.key.here'>
expectTypeOf<UnknownValue>().toBeNever()
})
})
describe('UseCacheCasualKey', () => {
it('should block fixed schema keys', () => {
// Fixed keys should resolve to never
type BlockedFixed = UseCacheCasualKey<'app.user.avatar'>
expectTypeOf<BlockedFixed>().toBeNever()
})
it('should block template pattern matches', () => {
// Keys matching template patterns should resolve to never
type BlockedTemplate = UseCacheCasualKey<'scroll.position.topic123'>
expectTypeOf<BlockedTemplate>().toBeNever()
})
it('should allow non-schema keys', () => {
// Non-schema keys should pass through
type AllowedKey = UseCacheCasualKey<'my.custom.key'>
const key: AllowedKey = 'my.custom.key'
expect(key).toBe('my.custom.key')
})
})
describe('Runtime template key detection', () => {
it('should correctly detect template keys', () => {
const isTemplate = (key: string) => key.includes('${') && key.includes('}')
expect(isTemplate('scroll.position.${id}')).toBe(true)
expect(isTemplate('entity.cache.${type}_${id}')).toBe(true)
expect(isTemplate('app.user.avatar')).toBe(false)
expect(isTemplate('chat.generating')).toBe(false)
})
})
})

View File

@ -26,8 +26,8 @@ const logger = loggerService.withContext('useCache')
*
* @example
* ```typescript
* isTemplateKey('scroll.position:${id}') // true
* isTemplateKey('app.user.avatar') // false
* isTemplateKey('scroll.position.${id}') // true
* isTemplateKey('app.user.avatar') // false
* ```
*/
function isTemplateKey(key: string): boolean {
@ -38,17 +38,21 @@ function isTemplateKey(key: string): boolean {
* 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.
* any non-empty string of word characters (letters, numbers, underscores, hyphens).
*
* @param template - The template key pattern (e.g., 'scroll.position:${id}')
* Template keys follow the same dot-separated pattern as fixed keys.
* When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www
*
* @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
* const regex = templateToRegex('scroll.position.${id}')
* regex.test('scroll.position.topic123') // true
* regex.test('scroll.position.topic-123') // true
* regex.test('scroll.position.') // false
* regex.test('other.key.123') // false
* ```
*/
function templateToRegex(template: string): RegExp {
@ -62,8 +66,9 @@ function templateToRegex(template: string): RegExp {
})
// Replace ${...} placeholders with a pattern matching non-empty strings
// Allows: word chars, dots, hyphens, underscores, colons
const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w.\\-_:]+)')
// Allows: word chars (letters, numbers, underscores) and hyphens
// Does NOT allow dots or colons since those are structural separators
const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w\\-]+)')
return new RegExp(`^${pattern}$`)
}
@ -79,10 +84,10 @@ function templateToRegex(template: string): RegExp {
*
* @example
* ```typescript
* // Given schema has 'app.user.avatar' and 'scroll.position:${id}'
* // 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('scroll.position.123') // 'scroll.position.${id}'
* findMatchingUseCacheSchemaKey('unknown.key') // undefined
* ```
*/
@ -119,10 +124,10 @@ function findMatchingUseCacheSchemaKey(key: string): keyof UseCacheSchema | unde
* ```typescript
* // Given schema:
* // 'app.user.avatar': '' (default)
* // 'scroll.position:${id}': 0 (default)
* // 'scroll.position.${id}': 0 (default)
*
* getUseCacheDefaultValue('app.user.avatar') // ''
* getUseCacheDefaultValue('scroll.position:123') // 0
* getUseCacheDefaultValue('scroll.position.123') // 0
* getUseCacheDefaultValue('unknown.key') // undefined
* ```
*/
@ -142,7 +147,10 @@ function getUseCacheDefaultValue<K extends UseCacheKey>(key: K): InferUseCacheVa
*
* 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 keys: `useCache('scroll.position.topic123')` (matches schema `'scroll.position.${id}'`)
*
* Template keys follow the same dot-separated pattern as fixed keys.
* When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www
*
* @template K - The cache key type (inferred from UseCacheKey)
* @param key - Cache key from the predefined schema (fixed or matching template pattern)
@ -154,8 +162,8 @@ function getUseCacheDefaultValue<K extends UseCacheKey>(key: K): InferUseCacheVa
* // 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')
* // Template key usage (schema: 'scroll.position.${id}': number)
* const [scrollPos, setScrollPos] = useCache('scroll.position.topic123')
* // TypeScript infers scrollPos as number
*
* // With custom initial value

View File

@ -2,11 +2,11 @@ import type {
RendererPersistCacheKey,
RendererPersistCacheSchema,
UseCacheKey,
UseCacheSchema,
InferUseCacheValue,
SharedCacheKey,
SharedCacheSchema
} from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas'
import { DefaultRendererPersistCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas'
import type { CacheEntry, CacheSubscriber } from '@shared/data/cache/cacheTypes'
import { vi } from 'vitest'
@ -66,20 +66,20 @@ export const createMockCacheService = (
const mockCacheService = {
// ============ Memory Cache (Type-safe) ============
get: vi.fn(<K extends UseCacheKey>(key: K): UseCacheSchema[K] => {
get: vi.fn(<K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined => {
const entry = memoryCache.get(key)
if (entry === undefined) {
return DefaultUseCache[key]
return undefined
}
if (isExpired(entry)) {
memoryCache.delete(key)
notifySubscribers(key)
return DefaultUseCache[key]
return undefined
}
return entry.value
return entry.value as InferUseCacheValue<K>
}),
set: vi.fn(<K extends UseCacheKey>(key: K, value: UseCacheSchema[K], ttl?: number): void => {
set: vi.fn(<K extends UseCacheKey>(key: K, value: InferUseCacheValue<K>, ttl?: number): void => {
const entry: CacheEntry = {
value,
expireAt: ttl ? Date.now() + ttl : undefined
@ -409,12 +409,12 @@ export const MockCacheService = {
}
// ============ Memory Cache (Type-safe) ============
get<K extends UseCacheKey>(key: K): UseCacheSchema[K] {
return mockCacheService.get(key)
get<K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined {
return mockCacheService.get(key) as unknown as InferUseCacheValue<K> | undefined
}
set<K extends UseCacheKey>(key: K, value: UseCacheSchema[K], ttl?: number): void {
return mockCacheService.set(key, value, ttl)
set<K extends UseCacheKey>(key: K, value: InferUseCacheValue<K>, ttl?: number): void {
mockCacheService.set(key, value as unknown as InferUseCacheValue<UseCacheKey>, ttl)
}
has<K extends UseCacheKey>(key: K): boolean {

View File

@ -3,6 +3,7 @@ import type {
RendererPersistCacheSchema,
UseCacheKey,
UseCacheSchema,
InferUseCacheValue,
SharedCacheKey,
SharedCacheSchema
} from '@shared/data/cache/cacheSchemas'
@ -14,14 +15,14 @@ import { vi } from 'vitest'
* Provides comprehensive mocks for all cache management hooks
*/
// Mock cache state storage
const mockMemoryCache = new Map<UseCacheKey, any>()
// Mock cache state storage (using string for memory cache to support template keys)
const mockMemoryCache = new Map<string, any>()
const mockSharedCache = new Map<SharedCacheKey, any>()
const mockPersistCache = new Map<RendererPersistCacheKey, any>()
// Initialize caches with defaults
Object.entries(DefaultUseCache).forEach(([key, value]) => {
mockMemoryCache.set(key as UseCacheKey, value)
mockMemoryCache.set(key, value)
})
Object.entries(DefaultSharedCache).forEach(([key, value]) => {
@ -32,13 +33,13 @@ Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => {
mockPersistCache.set(key as RendererPersistCacheKey, value)
})
// Mock subscribers for cache changes
const mockMemorySubscribers = new Map<UseCacheKey, Set<() => void>>()
// Mock subscribers for cache changes (using string for memory to support template keys)
const mockMemorySubscribers = new Map<string, Set<() => void>>()
const mockSharedSubscribers = new Map<SharedCacheKey, Set<() => void>>()
const mockPersistSubscribers = new Map<RendererPersistCacheKey, Set<() => void>>()
// Helper functions to notify subscribers
const notifyMemorySubscribers = (key: UseCacheKey) => {
const notifyMemorySubscribers = (key: string) => {
const subscribers = mockMemorySubscribers.get(key)
if (subscribers) {
subscribers.forEach((callback) => {
@ -77,25 +78,78 @@ const notifyPersistSubscribers = (key: RendererPersistCacheKey) => {
}
}
// ============ Template Key Utilities ============
/**
* Checks if a schema key is a template key (contains ${...} placeholder).
*/
const isTemplateKey = (key: string): boolean => {
return key.includes('${') && key.includes('}')
}
/**
* Converts a template key pattern into a RegExp for matching concrete keys.
*/
const templateToRegex = (template: string): RegExp => {
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
if (match === '$' || match === '{' || match === '}') {
return match
}
return '\\' + match
})
const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w\\-]+)')
return new RegExp(`^${pattern}$`)
}
/**
* Finds the schema key that matches a given concrete key.
*/
const findMatchingSchemaKey = (key: string): keyof UseCacheSchema | undefined => {
if (key in DefaultUseCache) {
return key as keyof UseCacheSchema
}
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.
*/
const getDefaultValue = <K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined => {
const schemaKey = findMatchingSchemaKey(key)
if (schemaKey) {
return DefaultUseCache[schemaKey] as InferUseCacheValue<K>
}
return undefined
}
/**
* Mock useCache hook (memory cache)
*/
export const mockUseCache = vi.fn(
<K extends UseCacheKey>(
key: K,
initValue?: UseCacheSchema[K]
): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] => {
initValue?: InferUseCacheValue<K>
): [InferUseCacheValue<K>, (value: InferUseCacheValue<K>) => void] => {
// Get current value
let currentValue = mockMemoryCache.get(key)
if (currentValue === undefined) {
currentValue = initValue ?? DefaultUseCache[key]
currentValue = initValue ?? getDefaultValue(key)
if (currentValue !== undefined) {
mockMemoryCache.set(key, currentValue)
}
}
// Mock setValue function
const setValue = vi.fn((value: UseCacheSchema[K]) => {
const setValue = vi.fn((value: InferUseCacheValue<K>) => {
mockMemoryCache.set(key, value)
notifyMemorySubscribers(key)
})
@ -185,7 +239,7 @@ export const MockUseCacheUtils = {
mockPersistCache.clear()
Object.entries(DefaultUseCache).forEach(([key, value]) => {
mockMemoryCache.set(key as UseCacheKey, value)
mockMemoryCache.set(key, value)
})
Object.entries(DefaultSharedCache).forEach(([key, value]) => {
@ -205,7 +259,7 @@ export const MockUseCacheUtils = {
/**
* Set cache value for testing (memory cache)
*/
setCacheValue: <K extends UseCacheKey>(key: K, value: UseCacheSchema[K]) => {
setCacheValue: <K extends UseCacheKey>(key: K, value: InferUseCacheValue<K>) => {
mockMemoryCache.set(key, value)
notifyMemorySubscribers(key)
},
@ -213,8 +267,8 @@ export const MockUseCacheUtils = {
/**
* Get cache value (memory cache)
*/
getCacheValue: <K extends UseCacheKey>(key: K): UseCacheSchema[K] => {
return mockMemoryCache.get(key) ?? DefaultUseCache[key]
getCacheValue: <K extends UseCacheKey>(key: K): InferUseCacheValue<K> | undefined => {
return mockMemoryCache.get(key) ?? getDefaultValue(key)
},
/**
@ -283,7 +337,7 @@ export const MockUseCacheUtils = {
/**
* Simulate cache change from external source
*/
simulateExternalCacheChange: <K extends UseCacheKey>(key: K, value: UseCacheSchema[K]) => {
simulateExternalCacheChange: <K extends UseCacheKey>(key: K, value: InferUseCacheValue<K>) => {
mockMemoryCache.set(key, value)
notifyMemorySubscribers(key)
},
@ -293,17 +347,17 @@ export const MockUseCacheUtils = {
*/
mockCacheReturn: <K extends UseCacheKey>(
key: K,
value: UseCacheSchema[K],
setValue?: (value: UseCacheSchema[K]) => void
value: InferUseCacheValue<K>,
setValue?: (value: InferUseCacheValue<K>) => void
) => {
mockUseCache.mockImplementation((cacheKey, initValue) => {
if (cacheKey === key) {
return [value, setValue || vi.fn()]
return [value, setValue || vi.fn()] as any
}
// Default behavior for other keys
const defaultValue = mockMemoryCache.get(cacheKey) ?? initValue ?? DefaultUseCache[cacheKey]
return [defaultValue, vi.fn()]
const defaultValue = mockMemoryCache.get(cacheKey) ?? initValue ?? getDefaultValue(cacheKey)
return [defaultValue, vi.fn()] as any
})
},