From fb51df99d00681f9c4e67cc22aeb0c590cd67c76 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 12:23:19 +0800 Subject: [PATCH] feat(cache): add template key support for useCache with type inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add type utilities for template key matching (IsTemplateKey, ExpandTemplateKey, ProcessKey) - Add InferUseCacheValue 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 --- docs/en/references/data/cache-overview.md | 29 ++- docs/en/references/data/cache-usage.md | 134 +++++++++++-- eslint.config.mjs | 84 +++++++- packages/shared/data/cache/cacheSchemas.ts | 180 +++++++++++++++++- src/renderer/src/data/CacheService.ts | 131 +++++++++++-- src/renderer/src/data/hooks/useCache.ts | 166 ++++++++++++++-- src/renderer/src/services/WebSearchService.ts | 3 +- 7 files changed, 669 insertions(+), 58 deletions(-) diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md index 87ab7d66cd..159609388b 100644 --- a/docs/en/references/data/cache-overview.md +++ b/docs/en/references/data/cache-overview.md @@ -51,8 +51,9 @@ cacheService.set('temp.calculation', result, 30000) - Main process resolves conflicts ### Type Safety -- Schema-based keys for compile-time checking -- Casual methods for dynamic keys with manual typing +- **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`) +- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position:${id}'` matches `'scroll.position:topic-123'`) +- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys) ## Data Categories @@ -120,10 +121,20 @@ cacheService.set('temp.calculation', result, 30000) For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md). -| Method | Tier | Type Safety | -|--------|------|-------------| -| `useCache` / `get` / `set` | Memory | Schema-based keys | -| `getCasual` / `setCasual` | Memory | Dynamic keys (manual typing) | -| `useSharedCache` / `getShared` / `setShared` | Shared | Schema-based keys | -| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys (manual typing) | -| `usePersistCache` / `getPersist` / `setPersist` | Persist | Schema-based keys only | +### Key Types + +| Type | Example Schema | Example Usage | Type Inference | +|------|----------------|---------------|----------------| +| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic | +| Template key | `'scroll.position:${id}': number` | `get('scroll.position:topic-123')` | Automatic | +| Casual key | N/A | `getCasual('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 | diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md index 6e1c761362..2b30d12471 100644 --- a/docs/en/references/data/cache-usage.md +++ b/docs/en/references/data/cache-usage.md @@ -155,19 +155,75 @@ const [counter, setCounter] = useCache('ui.counter', 0) - Use dynamically constructed keys - Require manual type specification via generics - No compile-time key validation +- **Cannot use keys that match schema patterns** (including template keys) ```typescript // Dynamic key, must specify type -const topic = cacheService.getCasual(`topic:${id}`) +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 ``` +### Template Keys + +Template keys provide type-safe caching for dynamic key patterns. Define a template in the schema using `${variable}` syntax, and TypeScript will automatically match and infer types for concrete keys. + +#### Defining Template Keys + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export type UseCacheSchema = { + // Fixed key + 'app.user.avatar': string + + // Template keys - use ${variable} for dynamic segments + 'scroll.position:${topicId}': number + 'entity.cache:${type}:${id}': EntityData +} + +// Default values for templates (shared by all instances) +export const DefaultUseCache: UseCacheSchema = { + 'app.user.avatar': '', + 'scroll.position:${topicId}': 0, + 'entity.cache:${type}:${id}': { loaded: false } +} +``` + +#### Using Template Keys + +```typescript +// TypeScript infers the value type from schema +const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') +// scrollPos is inferred as `number` + +const [entity, setEntity] = useCache('entity.cache:user:456') +// entity is inferred as `EntityData` + +// Direct CacheService usage +cacheService.set('scroll.position:my-topic', 150) // OK: value must be number +cacheService.set('scroll.position:my-topic', 'hi') // Error: type mismatch +``` + +#### Template Key Benefits + +| Feature | Fixed Keys | Template Keys | Casual Methods | +|---------|-----------|---------------|----------------| +| Type inference | โœ… Automatic | โœ… Automatic | โŒ Manual | +| Auto-completion | โœ… Full | โœ… Partial (prefix) | โŒ None | +| Compile-time validation | โœ… Yes | โœ… Yes | โŒ No | +| Dynamic IDs | โŒ No | โœ… Yes | โœ… Yes | +| Default values | โœ… Yes | โœ… Shared per template | โŒ No | + ### When to Use Which | Scenario | Method | Example | |----------|--------|---------| | Fixed cache keys | Type-safe | `useCache('ui.counter')` | -| Entity caching by ID | Casual | `getCasual(\`topic:${id}\`)` | -| Session-based keys | Casual | `setCasual(\`session:${sessionId}\`)` | +| Dynamic keys with known pattern | Template key | `useCache('scroll.position:topic-123')` | +| Entity caching by ID | Template key | `get('entity.cache:user:456')` | +| Completely dynamic keys | Casual | `getCasual(\`unknown.pattern:${x}\`)` | | UI state | Type-safe | `useSharedCache('window.layout')` | ## Common Patterns @@ -243,17 +299,24 @@ function useCachedWithExpiry(key: string, fetcher: () => Promise, maxAge: ## Adding New Cache Keys -### 1. Add to Cache Schema +### Adding Fixed Keys + +#### 1. Add to Cache Schema ```typescript // packages/shared/data/cache/cacheSchemas.ts -export interface CacheSchema { +export type UseCacheSchema = { // Existing keys... 'myFeature.data': MyDataType } + +export const DefaultUseCache: UseCacheSchema = { + // Existing defaults... + 'myFeature.data': { items: [], lastUpdated: 0 } +} ``` -### 2. Define Value Type (if complex) +#### 2. Define Value Type (if complex) ```typescript // packages/shared/data/cache/cacheValueTypes.ts @@ -263,13 +326,60 @@ export interface MyDataType { } ``` -### 3. Use in Code +#### 3. Use in Code ```typescript // Now type-safe -const [data, setData] = useCache('myFeature.data', defaultValue) +const [data, setData] = useCache('myFeature.data') ``` +### Adding Template Keys + +#### 1. Add Template to Schema + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export type UseCacheSchema = { + // Existing keys... + // Template key with dynamic segment + 'scroll.position:${topicId}': number +} + +export const DefaultUseCache: UseCacheSchema = { + // Existing defaults... + // Default shared by all instances of this template + 'scroll.position:${topicId}': 0 +} +``` + +#### 2. Use in Code + +```typescript +// TypeScript infers number from template pattern +const [scrollPos, setScrollPos] = useCache(`scroll.position:${topicId}`) + +// Works with any string in the dynamic segment +const [pos1, setPos1] = useCache('scroll.position:topic-123') +const [pos2, setPos2] = useCache('scroll.position:conversation-abc') +``` + +### Key Naming Convention + +All keys (fixed and template) must follow the naming convention: + +- **Format**: `namespace.sub.key_name` or `namespace.key:${variable}` +- **Rules**: + - Start with lowercase letter + - Use lowercase letters, numbers, and underscores + - Separate segments with dots (`.`) + - Use colons (`:`) before template placeholders +- **Examples**: + - โœ… `app.user.avatar` + - โœ… `scroll.position:${id}` + - โœ… `cache.entity:${type}:${id}` + - โŒ `UserAvatar` (no dots) + - โŒ `App.User` (uppercase) + ## Shared Cache Ready State Renderer CacheService provides ready state tracking for SharedCache initialization sync. @@ -303,6 +413,8 @@ unsubscribe() 1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival 2. **Use TTL for stale data**: Prevent serving outdated cached values 3. **Prefer type-safe keys**: Add to schema when possible -4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed -5. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) -6. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync +4. **Use template keys for patterns**: When you have a recurring pattern (e.g., caching by ID), define a template key instead of using casual methods +5. **Reserve casual for truly dynamic keys**: Only use casual methods when the key pattern is completely unknown at development time +6. **Clean up dynamic keys**: Remove casual cache entries when no longer needed +7. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) +8. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync diff --git a/eslint.config.mjs b/eslint.config.mjs index 928aa51008..8a77021cbc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -172,6 +172,9 @@ export default defineConfig([ } }, // Schema key naming convention (cache & preferences) + // Supports both fixed keys and template keys: + // - Fixed: 'app.user.avatar', 'chat.multi_select_mode' + // - Template: 'scroll.position:${topicId}', 'cache:${type}:${id}' { files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'], plugins: { @@ -181,25 +184,91 @@ export default defineConfig([ meta: { type: 'problem', docs: { - description: 'Enforce schema key naming convention: namespace.sub.key_name', + description: + 'Enforce schema key naming convention: namespace.sub.key_name or namespace.key:${variable}', recommended: true }, messages: { invalidKey: - 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar).' + 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar) or with template: namespace.key:${variable} (e.g., scroll.position:${id}).', + invalidTemplateVar: + 'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).' } }, create(context) { - const VALID_KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + /** + * Validates a schema key for correct naming convention. + * + * Supports two formats: + * 1. Fixed keys: lowercase segments separated by dots + * Example: 'app.user.avatar', 'chat.multi_select_mode' + * + * 2. Template keys: fixed prefix + template placeholders + * Example: 'scroll.position:${id}', 'cache:${type}:${id}' + * + * Template placeholder rules: + * - Must use ${variableName} syntax + * - Variable name must be valid identifier (start with letter, alphanumeric + underscore) + * - Empty placeholders like ${} are invalid + * + * @param {string} key - The schema key to validate + * @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }} + */ + function validateKey(key) { + // Check if key contains template placeholders + const hasTemplate = key.includes('${') + + if (hasTemplate) { + // Template key validation + // Must have at least one dot-separated segment before any template or colon + // Example valid: 'scroll.position:${id}', 'cache:${type}:${id}' + // Example invalid: '${id}', ':${id}' + + // Extract and validate all template variables + const templateVarPattern = /\$\{([^}]*)\}/g + let match + while ((match = templateVarPattern.exec(key)) !== null) { + const varName = match[1] + // Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore + if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) { + return { valid: false, error: 'invalidTemplateVar' } + } + } + + // Replace template placeholders with a marker to validate the structure + const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, '__TEMPLATE__') + + // Template key structure: + // - Must start with a valid segment (lowercase letters, numbers, underscores) + // - Segments separated by dots or colons + // - Must have at least one dot-separated segment + // - Can end with template placeholder + const templateKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*(:[a-z0-9_]*|:__TEMPLATE__)*$/ + + if (!templateKeyPattern.test(keyWithoutTemplates)) { + return { valid: false, error: 'invalidKey' } + } + + return { valid: true } + } else { + // Fixed key validation: standard dot-separated format + const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + if (!fixedKeyPattern.test(key)) { + return { valid: false, error: 'invalidKey' } + } + return { valid: true } + } + } return { TSPropertySignature(node) { if (node.key.type === 'Literal' && typeof node.key.value === 'string') { const key = node.key.value - if (!VALID_KEY_PATTERN.test(key)) { + const result = validateKey(key) + if (!result.valid) { context.report({ node: node.key, - messageId: 'invalidKey', + messageId: result.error, data: { key } }) } @@ -208,10 +277,11 @@ export default defineConfig([ Property(node) { if (node.key.type === 'Literal' && typeof node.key.value === 'string') { const key = node.key.value - if (!VALID_KEY_PATTERN.test(key)) { + const result = validateKey(key) + if (!result.valid) { context.report({ node: node.key, - messageId: 'invalidKey', + messageId: result.error, data: { key } }) } diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 16a68f2a92..8ec2bdaff0 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -19,9 +19,87 @@ import type * as CacheValueTypes from './cacheValueTypes' * - 'userAvatar' (invalid - missing dot separator) * - 'App.user' (invalid - uppercase not allowed) * + * ## Template Key Support + * + * Template keys allow type-safe dynamic keys using template literal syntax. + * Define in schema with `${variable}` placeholder, use with actual values. + * + * Examples: + * - Schema: `'scroll.position:${topicId}': number` + * - Usage: `useCache('scroll.position:topic-123')` -> infers `number` type + * + * Multiple placeholders are supported: + * - Schema: `'cache:${type}:${id}': CacheData` + * - Usage: `useCache('cache:user:456')` -> infers `CacheData` type + * * This convention is enforced by ESLint rule: data-schema-key/valid-key */ +// ============================================================================ +// Template Key Type Utilities +// ============================================================================ + +/** + * Detects whether a key string contains template placeholder syntax. + * + * Template keys use `${variable}` syntax to define dynamic segments. + * This type returns `true` if the key contains at least one `${...}` placeholder. + * + * @template K - The key string to check + * @returns `true` if K contains `${...}`, `false` otherwise + * + * @example + * ```typescript + * type Test1 = IsTemplateKey<'scroll:${id}'> // true + * type Test2 = IsTemplateKey<'cache:${a}:${b}'> // true + * type Test3 = IsTemplateKey<'app.user.avatar'> // false + * ``` + */ +export type IsTemplateKey = K extends `${string}\${${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 `${infer Prefix}\${${string}}${infer Suffix}` + ? `${Prefix}${string}${ExpandTemplateKey}` + : 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 = IsTemplateKey extends true ? ExpandTemplateKey : K + /** * Use cache schema for renderer hook */ @@ -121,9 +199,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = { 'example_scope.example_key': 'example default value' } +// ============================================================================ +// Cache Key Types +// ============================================================================ + /** - * Type-safe cache key + * Key type for renderer persist cache (fixed keys only) */ export type RendererPersistCacheKey = keyof RendererPersistCacheSchema -export type UseCacheKey = keyof UseCacheSchema + +/** + * Key type for shared cache (fixed keys only) + */ export type SharedCacheKey = keyof SharedCacheSchema + +/** + * Key type for memory cache (supports both fixed and template keys). + * + * This type expands all schema keys using ProcessKey, which: + * - Keeps fixed keys unchanged (e.g., 'app.user.avatar') + * - Expands template keys to match patterns (e.g., 'scroll:${id}' -> `scroll:${string}`) + * + * The resulting union type allows TypeScript to accept any concrete key + * that matches either a fixed key or an expanded template pattern. + * + * @example + * ```typescript + * // Given schema: + * // 'app.user.avatar': string + * // 'scroll.position:${topicId}': number + * + * // UseCacheKey becomes: 'app.user.avatar' | `scroll.position:${string}` + * + * // Valid keys: + * const k1: UseCacheKey = 'app.user.avatar' // fixed key + * const k2: UseCacheKey = 'scroll.position:123' // matches template + * const k3: UseCacheKey = 'scroll.position:abc' // matches template + * + * // Invalid keys: + * const k4: UseCacheKey = 'unknown.key' // error: not in schema + * ``` + */ +export type UseCacheKey = { + [K in keyof UseCacheSchema]: ProcessKey +}[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 = { + [S in keyof UseCacheSchema]: K extends ProcessKey ? 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 UseCacheKey ? never : K diff --git a/src/renderer/src/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index 1e61ce1ab7..f2f15697f4 100644 --- a/src/renderer/src/data/CacheService.ts +++ b/src/renderer/src/data/CacheService.ts @@ -19,12 +19,12 @@ import { loggerService } from '@logger' import type { + InferUseCacheValue, RendererPersistCacheKey, RendererPersistCacheSchema, SharedCacheKey, SharedCacheSchema, - UseCacheKey, - UseCacheSchema + UseCacheKey } from '@shared/data/cache/cacheSchemas' import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes' @@ -102,17 +102,57 @@ export class CacheService { /** * Get value from memory cache with TTL validation (type-safe) - * @param key - Schema-defined cache key + * + * Supports both fixed keys and template keys: + * - Fixed keys: `get('app.user.avatar')` + * - Template keys: `get('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * + * DESIGN NOTE: Returns `undefined` when cache miss or TTL expired. + * This is intentional - developers need to know when a value doesn't exist + * (e.g., after explicit deletion) and handle it appropriately. + * For UI components that always need a value, use `useCache` hook instead, + * which provides automatic default value fallback. + * + * @template K - The cache key type (inferred from UseCacheKey, supports template patterns) + * @param key - Schema-defined cache key (fixed or matching template pattern) * @returns Cached value or undefined if not found or expired + * + * @example + * ```typescript + * // Fixed key - handle undefined explicitly + * const avatar = cacheService.get('app.user.avatar') ?? '' + * + * // Template key (schema: 'scroll.position:${id}': number) + * const scrollPos = cacheService.get('scroll.position:topic-123') ?? 0 + * ``` */ - get(key: K): UseCacheSchema[K] { + get(key: K): InferUseCacheValue | undefined { return this.getInternal(key) } /** * Get value from memory cache with TTL validation (casual, dynamic key) - * @param key - Dynamic cache key (e.g., `topic:${id}`) + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `get()` instead. + * + * Note: Due to TypeScript limitations with template literal types, compile-time + * blocking of schema keys works best with literal string arguments. Variable + * keys are accepted but may not trigger compile errors. + * + * @template T - The expected value type (must be specified manually) + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns Cached value or undefined if not found or expired + * + * @example + * ```typescript + * // Dynamic key with manual type specification + * const data = cacheService.getCasual('custom.dynamic.key') + * + * // Schema keys should use type-safe methods: + * // Use: cacheService.get('app.user.avatar') + * // Instead of: cacheService.getCasual('app.user.avatar') + * ``` */ getCasual(key: Exclude): T | undefined { return this.getInternal(key) @@ -138,19 +178,54 @@ export class CacheService { /** * Set value in memory cache with optional TTL (type-safe) - * @param key - Schema-defined cache key - * @param value - Value to cache (type inferred from schema) + * + * Supports both fixed keys and template keys: + * - Fixed keys: `set('app.user.avatar', 'url')` + * - Template keys: `set('scroll.position:topic-123', 100)` + * + * @template K - The cache key type (inferred from UseCacheKey, supports template patterns) + * @param key - Schema-defined cache key (fixed or matching template pattern) + * @param value - Value to cache (type inferred from schema via template matching) * @param ttl - Time to live in milliseconds (optional) + * + * @example + * ```typescript + * // Fixed key + * cacheService.set('app.user.avatar', 'https://example.com/avatar.png') + * + * // Template key (schema: 'scroll.position:${id}': number) + * cacheService.set('scroll.position:topic-123', 150) + * + * // With TTL (expires after 30 seconds) + * cacheService.set('chat.generating', true, 30000) + * ``` */ - set(key: K, value: UseCacheSchema[K], ttl?: number): void { + set(key: K, value: InferUseCacheValue, ttl?: number): void { this.setInternal(key, value, ttl) } /** * Set value in memory cache with optional TTL (casual, dynamic key) - * @param key - Dynamic cache key (e.g., `topic:${id}`) + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `set()` instead. + * + * @template T - The value type to cache + * @param key - Dynamic cache key that doesn't match any schema pattern * @param value - Value to cache * @param ttl - Time to live in milliseconds (optional) + * + * @example + * ```typescript + * // Dynamic key usage + * cacheService.setCasual('my.custom.key', { data: 'value' }) + * + * // With TTL (expires after 60 seconds) + * cacheService.setCasual('temp.data', result, 60000) + * + * // Schema keys should use type-safe methods: + * // Use: cacheService.set('app.user.avatar', 'url') + * ``` */ setCasual(key: Exclude, value: T, ttl?: number): void { this.setInternal(key, value, ttl) @@ -196,8 +271,19 @@ export class CacheService { /** * Check if key exists in memory cache and is not expired (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `has()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if key exists and is valid, false otherwise + * + * @example + * ```typescript + * if (cacheService.hasCasual('my.custom.key')) { + * const data = cacheService.getCasual('my.custom.key') + * } + * ``` */ hasCasual(key: Exclude): boolean { return this.hasInternal(key) @@ -233,8 +319,18 @@ export class CacheService { /** * Delete from memory cache with hook protection (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `delete()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if deletion succeeded, false if key is protected by active hooks + * + * @example + * ```typescript + * // Delete dynamic cache entry + * cacheService.deleteCasual('my.custom.key') + * ``` */ deleteCasual(key: Exclude): boolean { return this.deleteInternal(key) @@ -274,8 +370,19 @@ export class CacheService { /** * Check if a key has TTL set in memory cache (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `hasTTL()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if key has TTL configured + * + * @example + * ```typescript + * if (cacheService.hasTTLCasual('my.custom.key')) { + * console.log('This cache entry will expire') + * } + * ``` */ hasTTLCasual(key: Exclude): boolean { const entry = this.memoryCache.get(key) diff --git a/src/renderer/src/data/hooks/useCache.ts b/src/renderer/src/data/hooks/useCache.ts index 00198afd2c..9ebfa4e687 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -1,6 +1,7 @@ import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import type { + InferUseCacheValue, RendererPersistCacheKey, RendererPersistCacheSchema, SharedCacheKey, @@ -10,59 +11,192 @@ import type { } from '@shared/data/cache/cacheSchemas' import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas' import { useCallback, useEffect, useSyncExternalStore } from 'react' + const logger = loggerService.withContext('useCache') +// ============================================================================ +// Template Matching Utilities +// ============================================================================ + +/** + * Checks if a schema key is a template key (contains ${...} placeholder). + * + * @param key - The schema key to check + * @returns true if the key contains template placeholder syntax + * + * @example + * ```typescript + * isTemplateKey('scroll.position:${id}') // true + * isTemplateKey('app.user.avatar') // false + * ``` + */ +function isTemplateKey(key: string): boolean { + return key.includes('${') && key.includes('}') +} + +/** + * Converts a template key pattern into a RegExp for matching concrete keys. + * + * Each `${variable}` placeholder is replaced with a pattern that matches + * any non-empty string of word characters, dots, and hyphens. + * + * @param template - The template key pattern (e.g., 'scroll.position:${id}') + * @returns A RegExp that matches concrete keys for this template + * + * @example + * ```typescript + * const regex = templateToRegex('scroll.position:${id}') + * regex.test('scroll.position:topic-123') // true + * regex.test('scroll.position:') // false + * regex.test('other.key:123') // false + * ``` + */ +function templateToRegex(template: string): RegExp { + // Escape special regex characters except for ${...} placeholders + const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => { + // Don't escape the ${...} syntax, we'll handle it specially + if (match === '$' || match === '{' || match === '}') { + return match + } + return '\\' + match + }) + + // Replace ${...} placeholders with a pattern matching non-empty strings + // Allows: word chars, dots, hyphens, underscores, colons + const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w.\\-_:]+)') + + return new RegExp(`^${pattern}$`) +} + +/** + * Finds the schema key that matches a given concrete key. + * + * First checks for exact match (fixed keys), then checks template patterns. + * This is used to look up default values for template keys. + * + * @param key - The concrete key to find a match for + * @returns The matching schema key, or undefined if no match found + * + * @example + * ```typescript + * // Given schema has 'app.user.avatar' and 'scroll.position:${id}' + * + * findMatchingUseCacheSchemaKey('app.user.avatar') // 'app.user.avatar' + * findMatchingUseCacheSchemaKey('scroll.position:123') // 'scroll.position:${id}' + * findMatchingUseCacheSchemaKey('unknown.key') // undefined + * ``` + */ +function findMatchingUseCacheSchemaKey(key: string): keyof UseCacheSchema | undefined { + // First, check for exact match (fixed keys) + if (key in DefaultUseCache) { + return key as keyof UseCacheSchema + } + + // Then, check template patterns + const schemaKeys = Object.keys(DefaultUseCache) as Array + 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(key: K): InferUseCacheValue | undefined { + const schemaKey = findMatchingUseCacheSchemaKey(key) + if (schemaKey) { + return DefaultUseCache[schemaKey] as InferUseCacheValue + } + return undefined +} + /** * React hook for component-level memory cache * * Use this for data that needs to be shared between components in the same window. * Data is lost when the app restarts. * - * @param key - Cache key from the predefined schema + * Supports both fixed keys and template keys: + * - Fixed keys: `useCache('app.user.avatar')` + * - Template keys: `useCache('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * + * @template K - The cache key type (inferred from UseCacheKey) + * @param key - Cache key from the predefined schema (fixed or matching template pattern) * @param initValue - Initial value (optional, uses schema default if not provided) * @returns [value, setValue] - Similar to useState but shared across components * * @example * ```typescript - * // Basic usage - * const [theme, setTheme] = useCache('ui.theme') + * // Fixed key usage + * const [avatar, setAvatar] = useCache('app.user.avatar') + * + * // Template key usage (schema: 'scroll.position:${id}': number) + * const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') + * // TypeScript infers scrollPos as number * * // With custom initial value - * const [count, setCount] = useCache('counter', 0) + * const [generating, setGenerating] = useCache('chat.generating', true) * * // Update the value - * setTheme('dark') + * setAvatar('new-avatar-url') * ``` */ export function useCache( key: K, - initValue?: UseCacheSchema[K] -): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] { + initValue?: InferUseCacheValue +): [InferUseCacheValue, (value: InferUseCacheValue) => void] { + // Get the default value for this key (works with both fixed and template keys) + const defaultValue = getUseCacheDefaultValue(key) + /** * Subscribe to cache changes using React's useSyncExternalStore * This ensures the component re-renders when the cache value changes */ const value = useSyncExternalStore( useCallback((callback) => cacheService.subscribe(key, callback), [key]), - useCallback(() => cacheService.get(key), [key]), - useCallback(() => cacheService.get(key), [key]) // SSR snapshot + useCallback(() => cacheService.get(key) as InferUseCacheValue | undefined, [key]), + useCallback(() => cacheService.get(key) as InferUseCacheValue | undefined, [key]) // SSR snapshot ) /** * Initialize cache with default value if it doesn't exist - * Priority: existing cache value > custom initValue > schema default + * Priority: existing cache value > custom initValue > schema default (via template matching) */ useEffect(() => { if (cacheService.has(key)) { return } - if (initValue === undefined) { - cacheService.set(key, DefaultUseCache[key]) - } else { + if (initValue !== undefined) { cacheService.set(key, initValue) + } else if (defaultValue !== undefined) { + cacheService.set(key, defaultValue) } - }, [key, initValue]) + }, [key, initValue, defaultValue]) /** * Register this hook as actively using the cache key @@ -90,13 +224,13 @@ export function useCache( * @param newValue - New value to store in cache */ const setValue = useCallback( - (newValue: UseCacheSchema[K]) => { + (newValue: InferUseCacheValue) => { cacheService.set(key, newValue) }, [key] ) - return [value ?? initValue ?? DefaultUseCache[key], setValue] + return [value ?? initValue ?? defaultValue!, setValue] } /** diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index b20b0ede5d..e2ce737bfe 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -191,7 +191,8 @@ class WebSearchService { * ่ฎพ็ฝฎ็ฝ‘็ปœๆœ็ดข็Šถๆ€ */ private async setWebSearchStatus(requestId: string, status: WebSearchStatus, delayMs?: number) { - const activeSearches = cacheService.get('chat.websearch.active_searches') + // Use ?? {} to handle cache miss (cacheService.get returns undefined when not cached) + const activeSearches = cacheService.get('chat.websearch.active_searches') ?? {} activeSearches[requestId] = status cacheService.set('chat.websearch.active_searches', activeSearches)