From 2093452e691a197ad5ae144759c561be2b09e42d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 13:17:58 +0800 Subject: [PATCH] fix(cache): enforce dot-separated naming for template keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/en/references/data/cache-overview.md | 8 +- docs/en/references/data/cache-usage.md | 48 +++--- eslint.config.mjs | 49 +++--- packages/shared/data/cache/cacheSchemas.ts | 71 +++++---- src/renderer/src/data/CacheService.ts | 17 +- .../hooks/__tests__/useCache.types.test.ts | 149 ++++++++++++++++++ src/renderer/src/data/hooks/useCache.ts | 42 +++-- tests/__mocks__/renderer/CacheService.ts | 22 +-- tests/__mocks__/renderer/useCache.ts | 94 ++++++++--- 9 files changed, 362 insertions(+), 138 deletions(-) create mode 100644 src/renderer/src/data/hooks/__tests__/useCache.types.test.ts diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md index 159609388b..ab0f366b27 100644 --- a/docs/en/references/data/cache-overview.md +++ b/docs/en/references/data/cache-overview.md @@ -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('my.key')` | Manual | +| Template key | `'scroll.position.${id}': number` | `get('scroll.position.topic123')` | Automatic | +| Casual key | N/A | `getCasual('my.custom.key')` | Manual | ### API Reference diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md index 2b30d12471..54066e700f 100644 --- a/docs/en/references/data/cache-usage.md +++ b/docs/en/references/data/cache-usage.md @@ -163,13 +163,15 @@ const topic = cacheService.getCasual(`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(\`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(\`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) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8a77021cbc..2e6a2cc311 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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' } } diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 8ec2bdaff0..892009ea0a 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -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}\${${string}}${string}` ? true : false @@ -69,11 +72,11 @@ export type IsTemplateKey = 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 `${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 = IsTemplateKey extends true ? ExpandTemplateKey : K @@ -135,6 +138,10 @@ export type UseCacheSchema = { 'agent.active_id': string | null 'agent.session.active_id_map': Record 'agent.session.waiting_id_map': Record + + // 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 = { @@ -291,15 +302,15 @@ export type InferUseCacheValue = { * ```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 UseCacheKey ? never : K diff --git a/src/renderer/src/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index f2f15697f4..f88432ee10 100644 --- a/src/renderer/src/data/CacheService.ts +++ b/src/renderer/src/data/CacheService.ts @@ -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(key: K): InferUseCacheValue | 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) diff --git a/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts b/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts new file mode 100644 index 0000000000..fee022e2a6 --- /dev/null +++ b/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts @@ -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().toBeNever() + }) + }) + + describe('UseCacheCasualKey', () => { + it('should block fixed schema keys', () => { + // Fixed keys should resolve to never + type BlockedFixed = UseCacheCasualKey<'app.user.avatar'> + expectTypeOf().toBeNever() + }) + + it('should block template pattern matches', () => { + // Keys matching template patterns should resolve to never + type BlockedTemplate = UseCacheCasualKey<'scroll.position.topic123'> + expectTypeOf().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) + }) + }) +}) diff --git a/src/renderer/src/data/hooks/useCache.ts b/src/renderer/src/data/hooks/useCache.ts index 9ebfa4e687..d13196bc11 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -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(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(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 diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index a66d8ddba6..69f41f8689 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -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((key: K): UseCacheSchema[K] => { + get: vi.fn((key: K): InferUseCacheValue | 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 }), - set: vi.fn((key: K, value: UseCacheSchema[K], ttl?: number): void => { + set: vi.fn((key: K, value: InferUseCacheValue, 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(key: K): UseCacheSchema[K] { - return mockCacheService.get(key) + get(key: K): InferUseCacheValue | undefined { + return mockCacheService.get(key) as unknown as InferUseCacheValue | undefined } - set(key: K, value: UseCacheSchema[K], ttl?: number): void { - return mockCacheService.set(key, value, ttl) + set(key: K, value: InferUseCacheValue, ttl?: number): void { + mockCacheService.set(key, value as unknown as InferUseCacheValue, ttl) } has(key: K): boolean { diff --git a/tests/__mocks__/renderer/useCache.ts b/tests/__mocks__/renderer/useCache.ts index 77f9dd5dd1..4f323d31d6 100644 --- a/tests/__mocks__/renderer/useCache.ts +++ b/tests/__mocks__/renderer/useCache.ts @@ -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() +// Mock cache state storage (using string for memory cache to support template keys) +const mockMemoryCache = new Map() const mockSharedCache = new Map() const mockPersistCache = new Map() // 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 void>>() +// Mock subscribers for cache changes (using string for memory to support template keys) +const mockMemorySubscribers = new Map void>>() const mockSharedSubscribers = new Map void>>() const mockPersistSubscribers = new Map 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 + 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 = (key: K): InferUseCacheValue | undefined => { + const schemaKey = findMatchingSchemaKey(key) + if (schemaKey) { + return DefaultUseCache[schemaKey] as InferUseCacheValue + } + return undefined +} + /** * Mock useCache hook (memory cache) */ export const mockUseCache = vi.fn( ( key: K, - initValue?: UseCacheSchema[K] - ): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] => { + initValue?: InferUseCacheValue + ): [InferUseCacheValue, (value: InferUseCacheValue) => 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) => { 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: (key: K, value: UseCacheSchema[K]) => { + setCacheValue: (key: K, value: InferUseCacheValue) => { mockMemoryCache.set(key, value) notifyMemorySubscribers(key) }, @@ -213,8 +267,8 @@ export const MockUseCacheUtils = { /** * Get cache value (memory cache) */ - getCacheValue: (key: K): UseCacheSchema[K] => { - return mockMemoryCache.get(key) ?? DefaultUseCache[key] + getCacheValue: (key: K): InferUseCacheValue | undefined => { + return mockMemoryCache.get(key) ?? getDefaultValue(key) }, /** @@ -283,7 +337,7 @@ export const MockUseCacheUtils = { /** * Simulate cache change from external source */ - simulateExternalCacheChange: (key: K, value: UseCacheSchema[K]) => { + simulateExternalCacheChange: (key: K, value: InferUseCacheValue) => { mockMemoryCache.set(key, value) notifyMemorySubscribers(key) }, @@ -293,17 +347,17 @@ export const MockUseCacheUtils = { */ mockCacheReturn: ( key: K, - value: UseCacheSchema[K], - setValue?: (value: UseCacheSchema[K]) => void + value: InferUseCacheValue, + setValue?: (value: InferUseCacheValue) => 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 }) },