@open-mercato/cache 0.6.7 → 0.6.8-canary.319.1.5b4be4bc52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -6
- package/dist/service.js +1 -1
- package/dist/service.js.map +2 -2
- package/dist/strategies/redis.js +28 -0
- package/dist/strategies/redis.js.map +2 -2
- package/package.json +5 -4
- package/src/__tests__/cache-di-contract-docs.test.ts +160 -0
- package/src/__tests__/redis.strategy.test.ts +271 -0
- package/src/__tests__/service.test.ts +27 -0
- package/src/service.ts +1 -1
- package/src/strategies/redis.ts +72 -0
package/AGENTS.md
CHANGED
|
@@ -21,7 +21,7 @@ The memory strategy is bounded so a process-shared instance (`OM_BOOTSTRAP_CACHE
|
|
|
21
21
|
|
|
22
22
|
## Always
|
|
23
23
|
|
|
24
|
-
1. **MUST resolve via DI** — always use `container.resolve('
|
|
24
|
+
1. **MUST resolve via DI** — always use `container.resolve('cache')`, never instantiate cache directly. The DI token is `cache` (registered in `packages/core/src/bootstrap.ts`); there is no `cacheService` registration.
|
|
25
25
|
2. **MUST scope to tenant** — include `tenantId` in cache keys or use `runWithCacheTenant()` for automatic scoping
|
|
26
26
|
3. **MUST use tag-based invalidation** for CRUD side effects — tag entries so related data can be invalidated together
|
|
27
27
|
|
|
@@ -48,22 +48,27 @@ yarn workspace @open-mercato/cache build
|
|
|
48
48
|
Use tags when cached data relates to a specific entity or scope. Invalidating a tag clears all entries with that tag.
|
|
49
49
|
|
|
50
50
|
```typescript
|
|
51
|
+
const cache = container.resolve('cache')
|
|
52
|
+
|
|
51
53
|
// When caching, attach tags
|
|
52
|
-
await
|
|
54
|
+
await cache.set('key', value, { tags: ['tenant:123', 'customers'] })
|
|
53
55
|
|
|
54
|
-
// When data changes, invalidate by tag
|
|
55
|
-
|
|
56
|
+
// When data changes, invalidate by tag — `deleteByTags` takes an ARRAY of tags
|
|
57
|
+
// (any entry carrying ANY of them is dropped) and returns the number of deleted keys.
|
|
58
|
+
await cache.deleteByTags(['customers']) // Clears all customer-related cache
|
|
56
59
|
```
|
|
57
60
|
|
|
61
|
+
`CacheStrategy` is the only cache surface: `get`, `set`, `has`, `delete`, `deleteByTags`, `clear`, `keys`, `stats`, plus optional `healthcheck`, `cleanup`, `close`. There is no `invalidateTag` — MUST NOT invent per-tag helpers in module code.
|
|
62
|
+
|
|
58
63
|
## Consistency vs commit timing
|
|
59
64
|
|
|
60
65
|
Cache invalidation and query-index side effects (`emitCrudSideEffects`) MUST fire **after** the originating domain write commits — the same rule that keeps them OUTSIDE the `withAtomicFlush` block (see `packages/core/AGENTS.md` → "Entity Update Safety — `withAtomicFlush`"). Because invalidation runs post-commit and the query-index read-projection tail (search tokens, vectors, fulltext, coverage) converges asynchronously, reads can briefly see a short convergence window after a write.
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
The opt-in env flag `OM_CACHE_SAFETY_ALWAYS_CONSISTENT` (default **OFF**, 100% backward compatible) makes that read-projection tail converge synchronously on write and propagates index-write failures instead of swallowing them — at the cost of added write latency. It does not move query-index side effects into the domain-write transaction. See `.ai/specs/2026-06-05-cache-safety-always-consistent.md`.
|
|
63
68
|
|
|
64
69
|
## Adding Caching to a Module
|
|
65
70
|
|
|
66
|
-
1. Resolve `
|
|
71
|
+
1. Resolve the `cache` service from DI (`container.resolve('cache')`) in your service or route handler
|
|
67
72
|
2. Define cache keys with tenant scoping: `${tenantId}:${module}:${identifier}`
|
|
68
73
|
3. Tag entries with entity type and tenant for targeted invalidation
|
|
69
74
|
4. Add cache invalidation to CRUD side effects (`emitCrudSideEffects` with `cacheAliases`)
|
|
@@ -82,3 +87,4 @@ packages/cache/src/
|
|
|
82
87
|
- Follow the strategy pattern — add new strategies in `strategies/` with the same interface
|
|
83
88
|
- Run `yarn test` in `packages/cache` after changes
|
|
84
89
|
- Verify tag invalidation works across all strategies when modifying invalidation logic
|
|
90
|
+
- The DI token and method names quoted in this file and in `.ai/review-checklist.md` are pinned to the real sources by `src/__tests__/cache-di-contract-docs.test.ts` (it re-derives the token from `packages/core/src/bootstrap.ts` and the members from `CacheStrategy`). Renaming the token or a method MUST update both docs in the same change.
|
package/dist/service.js
CHANGED
|
@@ -41,7 +41,7 @@ function resolveTenantPrefixes() {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
function hashIdentifier(input) {
|
|
44
|
-
return createHash("
|
|
44
|
+
return createHash("sha256").update(input).digest("hex");
|
|
45
45
|
}
|
|
46
46
|
function storageKey(originalKey, prefixes) {
|
|
47
47
|
return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`;
|
package/dist/service.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/service.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CacheStrategy, CacheServiceOptions, CacheGetOptions, CacheSetOptions, CacheValue } from './types'\nimport { createMemoryStrategy } from './strategies/memory'\nimport { createRedisStrategy } from './strategies/redis'\nimport { createSqliteStrategy } from './strategies/sqlite'\nimport { createJsonFileStrategy } from './strategies/jsonfile'\nimport { getCurrentCacheTenant } from './tenantContext'\nimport { createHash } from 'node:crypto'\nimport { CacheDependencyUnavailableError } from './errors'\nimport { matchCacheKeyPattern } from './patterns'\n\nfunction normalizeTenantKey(raw: string | null | undefined): string {\n const value = typeof raw === 'string' ? raw.trim() : ''\n if (!value) return 'global'\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\ntype TenantPrefixes = {\n keyPrefix: string\n tagPrefix: string\n scopeTag: string\n}\n\ntype CacheMetadata = {\n key: string\n expiresAt: number | null\n}\n\nfunction isCacheMetadata(value: CacheValue | null): value is CacheMetadata {\n if (typeof value !== 'object' || value === null) {\n return false\n }\n const record = value as Record<string, unknown>\n const hasValidKey = typeof record.key === 'string'\n const hasValidExpiresAt =\n !('expiresAt' in record)\n || record.expiresAt === null\n || typeof record.expiresAt === 'number'\n\n return hasValidKey && hasValidExpiresAt\n}\n\ntype CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>\nconst KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']\nconst TENANT_META_KEY_PATTERN = 'tenant:*:key:meta:*'\nconst TENANT_META_KEY_MARKER = ':key:meta:'\n\ntype TenantAwareCacheStrategy = CacheStrategy & {\n deleteByPatternsAcrossScopes(patterns: string[]): Promise<number>\n}\n\nfunction isCacheStrategyName(value: string | undefined): value is CacheStrategyName {\n if (!value) return false\n return KNOWN_STRATEGIES.includes(value as CacheStrategyName)\n}\n\nfunction resolveConfiguredStrategyType(options?: CacheServiceOptions): CacheStrategyName {\n const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)\n ? process.env.CACHE_STRATEGY\n : undefined\n return options?.strategy ?? envStrategy ?? 'memory'\n}\n\nfunction resolveTenantPrefixes(): TenantPrefixes {\n const tenant = normalizeTenantKey(getCurrentCacheTenant())\n const base = `tenant:${tenant}:`\n return {\n keyPrefix: `${base}key:`,\n tagPrefix: `${base}tag:`,\n scopeTag: `${base}tag:__scope__`,\n }\n}\n\nfunction hashIdentifier(input: string): string {\n return createHash('sha1').update(input).digest('hex')\n}\n\nfunction storageKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`\n}\n\nfunction metaKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}meta:${hashIdentifier(originalKey)}`\n}\n\nfunction hashedTag(tag: string, prefixes: TenantPrefixes): string {\n return `${prefixes.tagPrefix}t:${hashIdentifier(tag)}`\n}\n\nfunction buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, includeScope: boolean): string[] {\n const scoped = new Set<string>()\n if (includeScope) scoped.add(prefixes.scopeTag)\n if (Array.isArray(tags)) {\n for (const tag of tags) {\n if (typeof tag === 'string' && tag.length > 0) scoped.add(hashedTag(tag, prefixes))\n }\n }\n return Array.from(scoped)\n}\n\nfunction createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean): TenantAwareCacheStrategy {\n function normalizeDeletionCount(raw: number): number {\n if (!raw) return raw\n if (!Number.isFinite(raw)) return raw\n return Math.ceil(raw / 2)\n }\n\n const get = async (key: string, options?: CacheGetOptions) => {\n const prefixes = resolveTenantPrefixes()\n return base.get(storageKey(key, prefixes), options)\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions) => {\n const prefixes = resolveTenantPrefixes()\n const hashedTags = buildTagSet(options?.tags, prefixes, true)\n const ttl = options?.ttl ?? undefined\n const nextOptions: CacheSetOptions | undefined = options\n ? { ...options, tags: hashedTags }\n : { tags: hashedTags }\n await base.set(storageKey(key, prefixes), value, nextOptions)\n const metaPayload: CacheMetadata = { key, expiresAt: ttl ? Date.now() + ttl : null }\n await base.set(metaKey(key, prefixes), metaPayload, {\n ttl,\n tags: hashedTags,\n })\n }\n\n const has = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n return base.has(storageKey(key, prefixes))\n }\n\n const del = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n const primary = await base.delete(storageKey(key, prefixes))\n await base.delete(metaKey(key, prefixes))\n return primary\n }\n\n const deleteByTags = async (tags: string[]) => {\n const prefixes = resolveTenantPrefixes()\n const scopedTags = buildTagSet(tags, prefixes, false)\n if (!scopedTags.length) return 0\n const removed = await base.deleteByTags(scopedTags)\n return normalizeDeletionCount(removed)\n }\n\n const clear = async () => {\n const prefixes = resolveTenantPrefixes()\n const removed = await base.deleteByTags([prefixes.scopeTag])\n return normalizeDeletionCount(removed)\n }\n\n const keys = async (pattern?: string) => {\n const prefixes = resolveTenantPrefixes()\n const metaPattern = `${prefixes.keyPrefix}meta:*`\n const metaKeys = await base.keys(metaPattern)\n const originals: string[] = []\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n if (pattern && !matchCacheKeyPattern(original, pattern)) continue\n originals.push(original)\n }\n return originals\n }\n\n const deleteByPatternsAcrossScopes = async (patterns: string[]) => {\n const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)))\n if (normalizedPatterns.length === 0) return 0\n\n const metaKeys = await base.keys(TENANT_META_KEY_PATTERN)\n let deleted = 0\n for (const metadataKey of metaKeys) {\n const metadataValue = await base.get(metadataKey, { returnExpired: true })\n if (!metadataValue) continue\n const metadata = typeof metadataValue === 'string'\n ? null\n : (isCacheMetadata(metadataValue) ? metadataValue : null)\n const originalKey = typeof metadataValue === 'string' ? metadataValue : metadata?.key\n if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {\n continue\n }\n\n const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER)\n if (markerIndex < 0) continue\n const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`\n if (await base.delete(primaryKey)) deleted += 1\n await base.delete(metadataKey)\n }\n return deleted\n }\n\n const stats = async () => {\n const prefixes = resolveTenantPrefixes()\n const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)\n let size = 0\n let expired = 0\n const now = Date.now()\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n size++\n const expiresAt = metadata?.expiresAt ?? null\n if (expiresAt !== null && expiresAt <= now) expired++\n }\n // size/expired stay tenant-scoped (derived from this tenant's meta keys).\n // Only memory-backed strategies track the process-global bound counters, so\n // skip the extra base.stats() round-trip for redis/sqlite/jsonfile and omit\n // the optional fields (the stats() contract marks them optional). Keyed off\n // the configured strategy: a backend degraded to the memory fallback\n // intentionally does not surface these diagnostics.\n if (!reportsBoundedCounters) {\n return { size, expired }\n }\n const { evictions, sweeps, lastSweepReclaimed } = await base.stats()\n return { size, expired, evictions, sweeps, lastSweepReclaimed }\n }\n\n const cleanup = base.cleanup\n ? async () => normalizeDeletionCount(await base.cleanup!())\n : undefined\n\n const close = base.close\n ? async () => base.close!()\n : undefined\n const healthcheck = base.healthcheck\n ? async () => base.healthcheck!()\n : undefined\n\n return {\n get,\n set,\n has,\n delete: del,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n deleteByPatternsAcrossScopes,\n }\n}\n\nfunction createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): TenantAwareCacheStrategy {\n const strategyType = resolveConfiguredStrategyType(options)\n\n const envTtl = process.env.CACHE_TTL\n const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined\n const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)\n\n const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES\n const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined\n const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)\n\n const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)\n const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)\n const reportsBoundedCounters = strategyType === 'memory'\n\n return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters)\n}\n\n/**\n * Cache service that provides a unified interface to different cache strategies\n *\n * Configuration via environment variables:\n * - CACHE_STRATEGY: 'memory' | 'redis' | 'sqlite' | 'jsonfile' (default: 'memory')\n * - CACHE_TTL: Default TTL in milliseconds (optional)\n * - CACHE_REDIS_URL: Redis connection URL (for redis strategy)\n * - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)\n * - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)\n *\n * @example\n * const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })\n * await cache.set('user:123', { name: 'John' }, { tags: ['users', 'user:123'] })\n * const user = await cache.get('user:123')\n * await cache.deleteByTags(['users']) // Invalidate all user-related cache\n */\nexport function createCacheService(options?: CacheServiceOptions): CacheStrategy {\n return createConfiguredTenantAwareStrategy(options)\n}\n\n/**\n * Delete matching logical keys across every tenant scope in the configured\n * stock cache backend without bootstrapping application DI or querying the\n * tenant database. Intended for maintenance tasks that already operate on all\n * scopes, such as post-generation structural invalidation.\n */\nexport async function purgeConfiguredCachePatternsAcrossTenantScopes(\n patterns: string[],\n options?: CacheServiceOptions,\n): Promise<number> {\n // A newly-created memory strategy cannot reach the running app's process-local\n // cache, so constructing one here would only allocate and immediately discard\n // an empty store. Persistent stock backends remain cross-process maintainable.\n if (resolveConfiguredStrategyType(options) === 'memory') return 0\n\n const cache = createConfiguredTenantAwareStrategy(options)\n try {\n return await cache.deleteByPatternsAcrossScopes(patterns)\n } finally {\n await cache.close?.()\n }\n}\n\n/**\n * CacheService class wrapper for DI integration\n * Provides the same interface as the functional API but as a class\n */\nexport class CacheService implements CacheStrategy {\n private strategy: CacheStrategy\n\n constructor(options?: CacheServiceOptions) {\n this.strategy = createCacheService(options)\n }\n\n async get(key: string, options?: CacheGetOptions): Promise<CacheValue | null> {\n return this.strategy.get(key, options)\n }\n\n async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {\n return this.strategy.set(key, value, options)\n }\n\n async has(key: string): Promise<boolean> {\n return this.strategy.has(key)\n }\n\n async delete(key: string): Promise<boolean> {\n return this.strategy.delete(key)\n }\n\n async deleteByTags(tags: string[]): Promise<number> {\n return this.strategy.deleteByTags(tags)\n }\n\n async clear(): Promise<number> {\n return this.strategy.clear()\n }\n\n async keys(pattern?: string): Promise<string[]> {\n return this.strategy.keys(pattern)\n }\n\n async stats(): Promise<{\n size: number\n expired: number\n evictions?: number\n sweeps?: number\n lastSweepReclaimed?: number\n }> {\n return this.strategy.stats()\n }\n\n async cleanup(): Promise<number> {\n if (this.strategy.cleanup) {\n return this.strategy.cleanup()\n }\n return 0\n }\n\n async close(): Promise<void> {\n if (this.strategy.close) {\n return this.strategy.close()\n }\n }\n}\n\nfunction createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n switch (strategyType) {\n case 'redis':\n return createRedisStrategy(options?.redisUrl, { defaultTtl })\n case 'sqlite':\n return createSqliteStrategy(options?.sqlitePath, { defaultTtl })\n case 'jsonfile':\n return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })\n case 'memory':\n default:\n return createMemoryStrategy({ defaultTtl, maxEntries })\n }\n}\n\nfunction describeDependencyFailure(error: CacheDependencyUnavailableError): string {\n const originalError = error.originalError\n if (!(originalError instanceof Error)) {\n return `missing dependency: ${error.dependency}`\n }\n\n const message = originalError.message.trim()\n if (message.length === 0) {\n return `missing dependency: ${error.dependency}`\n }\n\n if (message.includes('compiled against a different Node.js version') || message.includes('NODE_MODULE_VERSION')) {\n return `${error.dependency} native module needs rebuild for the current Node.js version`\n }\n\n if (message.includes('Cannot find module')) {\n return `missing dependency: ${error.dependency}`\n }\n\n return `${error.dependency} failed to load`\n}\n\nfunction withDependencyFallback(strategy: CacheStrategy, strategyType: CacheStrategyName, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n if (strategyType === 'memory') return strategy\n\n let activeStrategy = strategy\n let fallbackStrategy: CacheStrategy | null = null\n let warned = false\n\n const ensureFallback = (error: CacheDependencyUnavailableError) => {\n if (!fallbackStrategy) {\n fallbackStrategy = createMemoryStrategy({ defaultTtl, maxEntries })\n }\n if (!warned) {\n const dependencyMessage = error.dependency\n ? ` (${describeDependencyFailure(error)})`\n : ''\n console.warn(`[cache] ${error.strategy} strategy unavailable${dependencyMessage}. Falling back to memory strategy.`)\n warned = true\n }\n activeStrategy = fallbackStrategy\n }\n\n const wrapMethod = <K extends keyof CacheStrategy>(method: K): CacheStrategy[K] => {\n const handler = async (...args: Parameters<NonNullable<CacheStrategy[K]>>) => {\n const fn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n\n try {\n return await fn(...args)\n } catch (error) {\n if (error instanceof CacheDependencyUnavailableError) {\n ensureFallback(error)\n const fallbackFn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fallbackFn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n return fallbackFn(...args)\n }\n throw error\n }\n }\n\n return handler as CacheStrategy[K]\n }\n\n return {\n get: wrapMethod('get'),\n set: wrapMethod('set'),\n has: wrapMethod('has'),\n delete: wrapMethod('delete'),\n deleteByTags: wrapMethod('deleteByTags'),\n clear: wrapMethod('clear'),\n keys: wrapMethod('keys'),\n stats: wrapMethod('stats'),\n healthcheck: typeof strategy.healthcheck === 'function'\n ? async () => strategy.healthcheck!()\n : undefined,\n cleanup: typeof strategy.cleanup === 'function' ? wrapMethod('cleanup') : undefined,\n close: typeof strategy.close === 'function' ? wrapMethod('close') : undefined,\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;AACpC,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AACvC,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAC3B,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;AAErC,SAAS,mBAAmB,KAAwC;AAClE,QAAM,QAAQ,OAAO,QAAQ,WAAW,IAAI,KAAK,IAAI;AACrD,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAaA,SAAS,gBAAgB,OAAkD;AACzE,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AACA,QAAM,SAAS;AACf,QAAM,cAAc,OAAO,OAAO,QAAQ;AAC1C,QAAM,oBACJ,EAAE,eAAe,WACd,OAAO,cAAc,QACrB,OAAO,OAAO,cAAc;AAEjC,SAAO,eAAe;AACxB;AAGA,MAAM,mBAAwC,CAAC,UAAU,SAAS,UAAU,UAAU;AACtF,MAAM,0BAA0B;AAChC,MAAM,yBAAyB;AAM/B,SAAS,oBAAoB,OAAuD;AAClF,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,iBAAiB,SAAS,KAA0B;AAC7D;AAEA,SAAS,8BAA8B,SAAkD;AACvF,QAAM,cAAc,oBAAoB,QAAQ,IAAI,cAAc,IAC9D,QAAQ,IAAI,iBACZ;AACJ,SAAO,SAAS,YAAY,eAAe;AAC7C;AAEA,SAAS,wBAAwC;AAC/C,QAAM,SAAS,mBAAmB,sBAAsB,CAAC;AACzD,QAAM,OAAO,UAAU,MAAM;AAC7B,SAAO;AAAA,IACL,WAAW,GAAG,IAAI;AAAA,IAClB,WAAW,GAAG,IAAI;AAAA,IAClB,UAAU,GAAG,IAAI;AAAA,EACnB;AACF;AAEA,SAAS,eAAe,OAAuB;AAC7C,SAAO,WAAW,
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy, CacheServiceOptions, CacheGetOptions, CacheSetOptions, CacheValue } from './types'\nimport { createMemoryStrategy } from './strategies/memory'\nimport { createRedisStrategy } from './strategies/redis'\nimport { createSqliteStrategy } from './strategies/sqlite'\nimport { createJsonFileStrategy } from './strategies/jsonfile'\nimport { getCurrentCacheTenant } from './tenantContext'\nimport { createHash } from 'node:crypto'\nimport { CacheDependencyUnavailableError } from './errors'\nimport { matchCacheKeyPattern } from './patterns'\n\nfunction normalizeTenantKey(raw: string | null | undefined): string {\n const value = typeof raw === 'string' ? raw.trim() : ''\n if (!value) return 'global'\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\ntype TenantPrefixes = {\n keyPrefix: string\n tagPrefix: string\n scopeTag: string\n}\n\ntype CacheMetadata = {\n key: string\n expiresAt: number | null\n}\n\nfunction isCacheMetadata(value: CacheValue | null): value is CacheMetadata {\n if (typeof value !== 'object' || value === null) {\n return false\n }\n const record = value as Record<string, unknown>\n const hasValidKey = typeof record.key === 'string'\n const hasValidExpiresAt =\n !('expiresAt' in record)\n || record.expiresAt === null\n || typeof record.expiresAt === 'number'\n\n return hasValidKey && hasValidExpiresAt\n}\n\ntype CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>\nconst KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']\nconst TENANT_META_KEY_PATTERN = 'tenant:*:key:meta:*'\nconst TENANT_META_KEY_MARKER = ':key:meta:'\n\ntype TenantAwareCacheStrategy = CacheStrategy & {\n deleteByPatternsAcrossScopes(patterns: string[]): Promise<number>\n}\n\nfunction isCacheStrategyName(value: string | undefined): value is CacheStrategyName {\n if (!value) return false\n return KNOWN_STRATEGIES.includes(value as CacheStrategyName)\n}\n\nfunction resolveConfiguredStrategyType(options?: CacheServiceOptions): CacheStrategyName {\n const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)\n ? process.env.CACHE_STRATEGY\n : undefined\n return options?.strategy ?? envStrategy ?? 'memory'\n}\n\nfunction resolveTenantPrefixes(): TenantPrefixes {\n const tenant = normalizeTenantKey(getCurrentCacheTenant())\n const base = `tenant:${tenant}:`\n return {\n keyPrefix: `${base}key:`,\n tagPrefix: `${base}tag:`,\n scopeTag: `${base}tag:__scope__`,\n }\n}\n\nfunction hashIdentifier(input: string): string {\n return createHash('sha256').update(input).digest('hex')\n}\n\nfunction storageKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`\n}\n\nfunction metaKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}meta:${hashIdentifier(originalKey)}`\n}\n\nfunction hashedTag(tag: string, prefixes: TenantPrefixes): string {\n return `${prefixes.tagPrefix}t:${hashIdentifier(tag)}`\n}\n\nfunction buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, includeScope: boolean): string[] {\n const scoped = new Set<string>()\n if (includeScope) scoped.add(prefixes.scopeTag)\n if (Array.isArray(tags)) {\n for (const tag of tags) {\n if (typeof tag === 'string' && tag.length > 0) scoped.add(hashedTag(tag, prefixes))\n }\n }\n return Array.from(scoped)\n}\n\nfunction createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean): TenantAwareCacheStrategy {\n function normalizeDeletionCount(raw: number): number {\n if (!raw) return raw\n if (!Number.isFinite(raw)) return raw\n return Math.ceil(raw / 2)\n }\n\n const get = async (key: string, options?: CacheGetOptions) => {\n const prefixes = resolveTenantPrefixes()\n return base.get(storageKey(key, prefixes), options)\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions) => {\n const prefixes = resolveTenantPrefixes()\n const hashedTags = buildTagSet(options?.tags, prefixes, true)\n const ttl = options?.ttl ?? undefined\n const nextOptions: CacheSetOptions | undefined = options\n ? { ...options, tags: hashedTags }\n : { tags: hashedTags }\n await base.set(storageKey(key, prefixes), value, nextOptions)\n const metaPayload: CacheMetadata = { key, expiresAt: ttl ? Date.now() + ttl : null }\n await base.set(metaKey(key, prefixes), metaPayload, {\n ttl,\n tags: hashedTags,\n })\n }\n\n const has = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n return base.has(storageKey(key, prefixes))\n }\n\n const del = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n const primary = await base.delete(storageKey(key, prefixes))\n await base.delete(metaKey(key, prefixes))\n return primary\n }\n\n const deleteByTags = async (tags: string[]) => {\n const prefixes = resolveTenantPrefixes()\n const scopedTags = buildTagSet(tags, prefixes, false)\n if (!scopedTags.length) return 0\n const removed = await base.deleteByTags(scopedTags)\n return normalizeDeletionCount(removed)\n }\n\n const clear = async () => {\n const prefixes = resolveTenantPrefixes()\n const removed = await base.deleteByTags([prefixes.scopeTag])\n return normalizeDeletionCount(removed)\n }\n\n const keys = async (pattern?: string) => {\n const prefixes = resolveTenantPrefixes()\n const metaPattern = `${prefixes.keyPrefix}meta:*`\n const metaKeys = await base.keys(metaPattern)\n const originals: string[] = []\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n if (pattern && !matchCacheKeyPattern(original, pattern)) continue\n originals.push(original)\n }\n return originals\n }\n\n const deleteByPatternsAcrossScopes = async (patterns: string[]) => {\n const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)))\n if (normalizedPatterns.length === 0) return 0\n\n const metaKeys = await base.keys(TENANT_META_KEY_PATTERN)\n let deleted = 0\n for (const metadataKey of metaKeys) {\n const metadataValue = await base.get(metadataKey, { returnExpired: true })\n if (!metadataValue) continue\n const metadata = typeof metadataValue === 'string'\n ? null\n : (isCacheMetadata(metadataValue) ? metadataValue : null)\n const originalKey = typeof metadataValue === 'string' ? metadataValue : metadata?.key\n if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {\n continue\n }\n\n const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER)\n if (markerIndex < 0) continue\n const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`\n if (await base.delete(primaryKey)) deleted += 1\n await base.delete(metadataKey)\n }\n return deleted\n }\n\n const stats = async () => {\n const prefixes = resolveTenantPrefixes()\n const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)\n let size = 0\n let expired = 0\n const now = Date.now()\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n size++\n const expiresAt = metadata?.expiresAt ?? null\n if (expiresAt !== null && expiresAt <= now) expired++\n }\n // size/expired stay tenant-scoped (derived from this tenant's meta keys).\n // Only memory-backed strategies track the process-global bound counters, so\n // skip the extra base.stats() round-trip for redis/sqlite/jsonfile and omit\n // the optional fields (the stats() contract marks them optional). Keyed off\n // the configured strategy: a backend degraded to the memory fallback\n // intentionally does not surface these diagnostics.\n if (!reportsBoundedCounters) {\n return { size, expired }\n }\n const { evictions, sweeps, lastSweepReclaimed } = await base.stats()\n return { size, expired, evictions, sweeps, lastSweepReclaimed }\n }\n\n const cleanup = base.cleanup\n ? async () => normalizeDeletionCount(await base.cleanup!())\n : undefined\n\n const close = base.close\n ? async () => base.close!()\n : undefined\n const healthcheck = base.healthcheck\n ? async () => base.healthcheck!()\n : undefined\n\n return {\n get,\n set,\n has,\n delete: del,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n deleteByPatternsAcrossScopes,\n }\n}\n\nfunction createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): TenantAwareCacheStrategy {\n const strategyType = resolveConfiguredStrategyType(options)\n\n const envTtl = process.env.CACHE_TTL\n const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined\n const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)\n\n const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES\n const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined\n const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)\n\n const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)\n const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)\n const reportsBoundedCounters = strategyType === 'memory'\n\n return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters)\n}\n\n/**\n * Cache service that provides a unified interface to different cache strategies\n *\n * Configuration via environment variables:\n * - CACHE_STRATEGY: 'memory' | 'redis' | 'sqlite' | 'jsonfile' (default: 'memory')\n * - CACHE_TTL: Default TTL in milliseconds (optional)\n * - CACHE_REDIS_URL: Redis connection URL (for redis strategy)\n * - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)\n * - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)\n *\n * @example\n * const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })\n * await cache.set('user:123', { name: 'John' }, { tags: ['users', 'user:123'] })\n * const user = await cache.get('user:123')\n * await cache.deleteByTags(['users']) // Invalidate all user-related cache\n */\nexport function createCacheService(options?: CacheServiceOptions): CacheStrategy {\n return createConfiguredTenantAwareStrategy(options)\n}\n\n/**\n * Delete matching logical keys across every tenant scope in the configured\n * stock cache backend without bootstrapping application DI or querying the\n * tenant database. Intended for maintenance tasks that already operate on all\n * scopes, such as post-generation structural invalidation.\n */\nexport async function purgeConfiguredCachePatternsAcrossTenantScopes(\n patterns: string[],\n options?: CacheServiceOptions,\n): Promise<number> {\n // A newly-created memory strategy cannot reach the running app's process-local\n // cache, so constructing one here would only allocate and immediately discard\n // an empty store. Persistent stock backends remain cross-process maintainable.\n if (resolveConfiguredStrategyType(options) === 'memory') return 0\n\n const cache = createConfiguredTenantAwareStrategy(options)\n try {\n return await cache.deleteByPatternsAcrossScopes(patterns)\n } finally {\n await cache.close?.()\n }\n}\n\n/**\n * CacheService class wrapper for DI integration\n * Provides the same interface as the functional API but as a class\n */\nexport class CacheService implements CacheStrategy {\n private strategy: CacheStrategy\n\n constructor(options?: CacheServiceOptions) {\n this.strategy = createCacheService(options)\n }\n\n async get(key: string, options?: CacheGetOptions): Promise<CacheValue | null> {\n return this.strategy.get(key, options)\n }\n\n async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {\n return this.strategy.set(key, value, options)\n }\n\n async has(key: string): Promise<boolean> {\n return this.strategy.has(key)\n }\n\n async delete(key: string): Promise<boolean> {\n return this.strategy.delete(key)\n }\n\n async deleteByTags(tags: string[]): Promise<number> {\n return this.strategy.deleteByTags(tags)\n }\n\n async clear(): Promise<number> {\n return this.strategy.clear()\n }\n\n async keys(pattern?: string): Promise<string[]> {\n return this.strategy.keys(pattern)\n }\n\n async stats(): Promise<{\n size: number\n expired: number\n evictions?: number\n sweeps?: number\n lastSweepReclaimed?: number\n }> {\n return this.strategy.stats()\n }\n\n async cleanup(): Promise<number> {\n if (this.strategy.cleanup) {\n return this.strategy.cleanup()\n }\n return 0\n }\n\n async close(): Promise<void> {\n if (this.strategy.close) {\n return this.strategy.close()\n }\n }\n}\n\nfunction createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n switch (strategyType) {\n case 'redis':\n return createRedisStrategy(options?.redisUrl, { defaultTtl })\n case 'sqlite':\n return createSqliteStrategy(options?.sqlitePath, { defaultTtl })\n case 'jsonfile':\n return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })\n case 'memory':\n default:\n return createMemoryStrategy({ defaultTtl, maxEntries })\n }\n}\n\nfunction describeDependencyFailure(error: CacheDependencyUnavailableError): string {\n const originalError = error.originalError\n if (!(originalError instanceof Error)) {\n return `missing dependency: ${error.dependency}`\n }\n\n const message = originalError.message.trim()\n if (message.length === 0) {\n return `missing dependency: ${error.dependency}`\n }\n\n if (message.includes('compiled against a different Node.js version') || message.includes('NODE_MODULE_VERSION')) {\n return `${error.dependency} native module needs rebuild for the current Node.js version`\n }\n\n if (message.includes('Cannot find module')) {\n return `missing dependency: ${error.dependency}`\n }\n\n return `${error.dependency} failed to load`\n}\n\nfunction withDependencyFallback(strategy: CacheStrategy, strategyType: CacheStrategyName, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n if (strategyType === 'memory') return strategy\n\n let activeStrategy = strategy\n let fallbackStrategy: CacheStrategy | null = null\n let warned = false\n\n const ensureFallback = (error: CacheDependencyUnavailableError) => {\n if (!fallbackStrategy) {\n fallbackStrategy = createMemoryStrategy({ defaultTtl, maxEntries })\n }\n if (!warned) {\n const dependencyMessage = error.dependency\n ? ` (${describeDependencyFailure(error)})`\n : ''\n console.warn(`[cache] ${error.strategy} strategy unavailable${dependencyMessage}. Falling back to memory strategy.`)\n warned = true\n }\n activeStrategy = fallbackStrategy\n }\n\n const wrapMethod = <K extends keyof CacheStrategy>(method: K): CacheStrategy[K] => {\n const handler = async (...args: Parameters<NonNullable<CacheStrategy[K]>>) => {\n const fn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n\n try {\n return await fn(...args)\n } catch (error) {\n if (error instanceof CacheDependencyUnavailableError) {\n ensureFallback(error)\n const fallbackFn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fallbackFn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n return fallbackFn(...args)\n }\n throw error\n }\n }\n\n return handler as CacheStrategy[K]\n }\n\n return {\n get: wrapMethod('get'),\n set: wrapMethod('set'),\n has: wrapMethod('has'),\n delete: wrapMethod('delete'),\n deleteByTags: wrapMethod('deleteByTags'),\n clear: wrapMethod('clear'),\n keys: wrapMethod('keys'),\n stats: wrapMethod('stats'),\n healthcheck: typeof strategy.healthcheck === 'function'\n ? async () => strategy.healthcheck!()\n : undefined,\n cleanup: typeof strategy.cleanup === 'function' ? wrapMethod('cleanup') : undefined,\n close: typeof strategy.close === 'function' ? wrapMethod('close') : undefined,\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;AACpC,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AACvC,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAC3B,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;AAErC,SAAS,mBAAmB,KAAwC;AAClE,QAAM,QAAQ,OAAO,QAAQ,WAAW,IAAI,KAAK,IAAI;AACrD,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,QAAQ,oBAAoB,GAAG;AAC9C;AAaA,SAAS,gBAAgB,OAAkD;AACzE,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,WAAO;AAAA,EACT;AACA,QAAM,SAAS;AACf,QAAM,cAAc,OAAO,OAAO,QAAQ;AAC1C,QAAM,oBACJ,EAAE,eAAe,WACd,OAAO,cAAc,QACrB,OAAO,OAAO,cAAc;AAEjC,SAAO,eAAe;AACxB;AAGA,MAAM,mBAAwC,CAAC,UAAU,SAAS,UAAU,UAAU;AACtF,MAAM,0BAA0B;AAChC,MAAM,yBAAyB;AAM/B,SAAS,oBAAoB,OAAuD;AAClF,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,iBAAiB,SAAS,KAA0B;AAC7D;AAEA,SAAS,8BAA8B,SAAkD;AACvF,QAAM,cAAc,oBAAoB,QAAQ,IAAI,cAAc,IAC9D,QAAQ,IAAI,iBACZ;AACJ,SAAO,SAAS,YAAY,eAAe;AAC7C;AAEA,SAAS,wBAAwC;AAC/C,QAAM,SAAS,mBAAmB,sBAAsB,CAAC;AACzD,QAAM,OAAO,UAAU,MAAM;AAC7B,SAAO;AAAA,IACL,WAAW,GAAG,IAAI;AAAA,IAClB,WAAW,GAAG,IAAI;AAAA,IAClB,UAAU,GAAG,IAAI;AAAA,EACnB;AACF;AAEA,SAAS,eAAe,OAAuB;AAC7C,SAAO,WAAW,QAAQ,EAAE,OAAO,KAAK,EAAE,OAAO,KAAK;AACxD;AAEA,SAAS,WAAW,aAAqB,UAAkC;AACzE,SAAO,GAAG,SAAS,SAAS,KAAK,eAAe,WAAW,CAAC;AAC9D;AAEA,SAAS,QAAQ,aAAqB,UAAkC;AACtE,SAAO,GAAG,SAAS,SAAS,QAAQ,eAAe,WAAW,CAAC;AACjE;AAEA,SAAS,UAAU,KAAa,UAAkC;AAChE,SAAO,GAAG,SAAS,SAAS,KAAK,eAAe,GAAG,CAAC;AACtD;AAEA,SAAS,YAAY,MAA4B,UAA0B,cAAiC;AAC1G,QAAM,SAAS,oBAAI,IAAY;AAC/B,MAAI,aAAc,QAAO,IAAI,SAAS,QAAQ;AAC9C,MAAI,MAAM,QAAQ,IAAI,GAAG;AACvB,eAAW,OAAO,MAAM;AACtB,UAAI,OAAO,QAAQ,YAAY,IAAI,SAAS,EAAG,QAAO,IAAI,UAAU,KAAK,QAAQ,CAAC;AAAA,IACpF;AAAA,EACF;AACA,SAAO,MAAM,KAAK,MAAM;AAC1B;AAEA,SAAS,yBAAyB,MAAqB,wBAA2D;AAChH,WAAS,uBAAuB,KAAqB;AACnD,QAAI,CAAC,IAAK,QAAO;AACjB,QAAI,CAAC,OAAO,SAAS,GAAG,EAAG,QAAO;AAClC,WAAO,KAAK,KAAK,MAAM,CAAC;AAAA,EAC1B;AAEA,QAAM,MAAM,OAAO,KAAa,YAA8B;AAC5D,UAAM,WAAW,sBAAsB;AACvC,WAAO,KAAK,IAAI,WAAW,KAAK,QAAQ,GAAG,OAAO;AAAA,EACpD;AAEA,QAAM,MAAM,OAAO,KAAa,OAAmB,YAA8B;AAC/E,UAAM,WAAW,sBAAsB;AACvC,UAAM,aAAa,YAAY,SAAS,MAAM,UAAU,IAAI;AAC5D,UAAM,MAAM,SAAS,OAAO;AAC5B,UAAM,cAA2C,UAC7C,EAAE,GAAG,SAAS,MAAM,WAAW,IAC/B,EAAE,MAAM,WAAW;AACvB,UAAM,KAAK,IAAI,WAAW,KAAK,QAAQ,GAAG,OAAO,WAAW;AAC5D,UAAM,cAA6B,EAAE,KAAK,WAAW,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK;AACnF,UAAM,KAAK,IAAI,QAAQ,KAAK,QAAQ,GAAG,aAAa;AAAA,MAClD;AAAA,MACA,MAAM;AAAA,IACR,CAAC;AAAA,EACH;AAEA,QAAM,MAAM,OAAO,QAAgB;AACjC,UAAM,WAAW,sBAAsB;AACvC,WAAO,KAAK,IAAI,WAAW,KAAK,QAAQ,CAAC;AAAA,EAC3C;AAEA,QAAM,MAAM,OAAO,QAAgB;AACjC,UAAM,WAAW,sBAAsB;AACvC,UAAM,UAAU,MAAM,KAAK,OAAO,WAAW,KAAK,QAAQ,CAAC;AAC3D,UAAM,KAAK,OAAO,QAAQ,KAAK,QAAQ,CAAC;AACxC,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,OAAO,SAAmB;AAC7C,UAAM,WAAW,sBAAsB;AACvC,UAAM,aAAa,YAAY,MAAM,UAAU,KAAK;AACpD,QAAI,CAAC,WAAW,OAAQ,QAAO;AAC/B,UAAM,UAAU,MAAM,KAAK,aAAa,UAAU;AAClD,WAAO,uBAAuB,OAAO;AAAA,EACvC;AAEA,QAAM,QAAQ,YAAY;AACxB,UAAM,WAAW,sBAAsB;AACvC,UAAM,UAAU,MAAM,KAAK,aAAa,CAAC,SAAS,QAAQ,CAAC;AAC3D,WAAO,uBAAuB,OAAO;AAAA,EACvC;AAEA,QAAM,OAAO,OAAO,YAAqB;AACvC,UAAM,WAAW,sBAAsB;AACvC,UAAM,cAAc,GAAG,SAAS,SAAS;AACzC,UAAM,WAAW,MAAM,KAAK,KAAK,WAAW;AAC5C,UAAM,YAAsB,CAAC;AAC7B,eAAWA,YAAW,UAAU;AAC9B,YAAM,YAAY,MAAM,KAAK,IAAIA,UAAS,EAAE,eAAe,KAAK,CAAC;AACjE,UAAI,CAAC,UAAW;AAChB,YAAM,WAAW,OAAO,cAAc,WAAW,OAAQ,gBAAgB,SAAS,IAAI,YAAY;AAClG,YAAM,WAAW,OAAO,cAAc,WAAW,YAAY,UAAU;AACvE,UAAI,CAAC,SAAU;AACf,UAAI,WAAW,CAAC,qBAAqB,UAAU,OAAO,EAAG;AACzD,gBAAU,KAAK,QAAQ;AAAA,IACzB;AACA,WAAO;AAAA,EACT;AAEA,QAAM,+BAA+B,OAAO,aAAuB;AACjE,UAAM,qBAAqB,MAAM,KAAK,IAAI,IAAI,SAAS,IAAI,CAAC,YAAY,QAAQ,KAAK,CAAC,EAAE,OAAO,OAAO,CAAC,CAAC;AACxG,QAAI,mBAAmB,WAAW,EAAG,QAAO;AAE5C,UAAM,WAAW,MAAM,KAAK,KAAK,uBAAuB;AACxD,QAAI,UAAU;AACd,eAAW,eAAe,UAAU;AAClC,YAAM,gBAAgB,MAAM,KAAK,IAAI,aAAa,EAAE,eAAe,KAAK,CAAC;AACzE,UAAI,CAAC,cAAe;AACpB,YAAM,WAAW,OAAO,kBAAkB,WACtC,OACC,gBAAgB,aAAa,IAAI,gBAAgB;AACtD,YAAM,cAAc,OAAO,kBAAkB,WAAW,gBAAgB,UAAU;AAClF,UAAI,CAAC,eAAe,CAAC,mBAAmB,KAAK,CAAC,YAAY,qBAAqB,aAAa,OAAO,CAAC,GAAG;AACrG;AAAA,MACF;AAEA,YAAM,cAAc,YAAY,YAAY,sBAAsB;AAClE,UAAI,cAAc,EAAG;AACrB,YAAM,aAAa,GAAG,YAAY,MAAM,GAAG,WAAW,CAAC,UAAU,YAAY,MAAM,cAAc,uBAAuB,MAAM,CAAC;AAC/H,UAAI,MAAM,KAAK,OAAO,UAAU,EAAG,YAAW;AAC9C,YAAM,KAAK,OAAO,WAAW;AAAA,IAC/B;AACA,WAAO;AAAA,EACT;AAEA,QAAM,QAAQ,YAAY;AACxB,UAAM,WAAW,sBAAsB;AACvC,UAAM,WAAW,MAAM,KAAK,KAAK,GAAG,SAAS,SAAS,QAAQ;AAC9D,QAAI,OAAO;AACX,QAAI,UAAU;AACd,UAAM,MAAM,KAAK,IAAI;AACrB,eAAWA,YAAW,UAAU;AAC9B,YAAM,YAAY,MAAM,KAAK,IAAIA,UAAS,EAAE,eAAe,KAAK,CAAC;AACjE,UAAI,CAAC,UAAW;AAChB,YAAM,WAAW,OAAO,cAAc,WAAW,OAAQ,gBAAgB,SAAS,IAAI,YAAY;AAClG,YAAM,WAAW,OAAO,cAAc,WAAW,YAAY,UAAU;AACvE,UAAI,CAAC,SAAU;AACf;AACA,YAAM,YAAY,UAAU,aAAa;AACzC,UAAI,cAAc,QAAQ,aAAa,IAAK;AAAA,IAC9C;AAOA,QAAI,CAAC,wBAAwB;AAC3B,aAAO,EAAE,MAAM,QAAQ;AAAA,IACzB;AACA,UAAM,EAAE,WAAW,QAAQ,mBAAmB,IAAI,MAAM,KAAK,MAAM;AACnE,WAAO,EAAE,MAAM,SAAS,WAAW,QAAQ,mBAAmB;AAAA,EAChE;AAEA,QAAM,UAAU,KAAK,UACjB,YAAY,uBAAuB,MAAM,KAAK,QAAS,CAAC,IACxD;AAEJ,QAAM,QAAQ,KAAK,QACf,YAAY,KAAK,MAAO,IACxB;AACJ,QAAM,cAAc,KAAK,cACrB,YAAY,KAAK,YAAa,IAC9B;AAEJ,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAEA,SAAS,oCAAoC,SAAyD;AACpG,QAAM,eAAe,8BAA8B,OAAO;AAE1D,QAAM,SAAS,QAAQ,IAAI;AAC3B,QAAM,eAAe,SAAS,OAAO,SAAS,QAAQ,EAAE,IAAI;AAC5D,QAAM,aAAa,SAAS,eAAe,OAAO,iBAAiB,YAAY,OAAO,SAAS,YAAY,IAAI,eAAe;AAE9H,QAAM,gBAAgB,QAAQ,IAAI;AAClC,QAAM,sBAAsB,gBAAgB,OAAO,SAAS,eAAe,EAAE,IAAI;AACjF,QAAM,aAAa,SAAS,eAAe,OAAO,wBAAwB,YAAY,OAAO,SAAS,mBAAmB,IAAI,sBAAsB;AAEnJ,QAAM,eAAe,sBAAsB,cAAc,SAAS,YAAY,UAAU;AACxF,QAAM,oBAAoB,uBAAuB,cAAc,cAAc,YAAY,UAAU;AACnG,QAAM,yBAAyB,iBAAiB;AAEhD,SAAO,yBAAyB,mBAAmB,sBAAsB;AAC3E;AAkBO,SAAS,mBAAmB,SAA8C;AAC/E,SAAO,oCAAoC,OAAO;AACpD;AAQA,eAAsB,+CACpB,UACA,SACiB;AAIjB,MAAI,8BAA8B,OAAO,MAAM,SAAU,QAAO;AAEhE,QAAM,QAAQ,oCAAoC,OAAO;AACzD,MAAI;AACF,WAAO,MAAM,MAAM,6BAA6B,QAAQ;AAAA,EAC1D,UAAE;AACA,UAAM,MAAM,QAAQ;AAAA,EACtB;AACF;AAMO,MAAM,aAAsC;AAAA,EAGjD,YAAY,SAA+B;AACzC,SAAK,WAAW,mBAAmB,OAAO;AAAA,EAC5C;AAAA,EAEA,MAAM,IAAI,KAAa,SAAuD;AAC5E,WAAO,KAAK,SAAS,IAAI,KAAK,OAAO;AAAA,EACvC;AAAA,EAEA,MAAM,IAAI,KAAa,OAAmB,SAA0C;AAClF,WAAO,KAAK,SAAS,IAAI,KAAK,OAAO,OAAO;AAAA,EAC9C;AAAA,EAEA,MAAM,IAAI,KAA+B;AACvC,WAAO,KAAK,SAAS,IAAI,GAAG;AAAA,EAC9B;AAAA,EAEA,MAAM,OAAO,KAA+B;AAC1C,WAAO,KAAK,SAAS,OAAO,GAAG;AAAA,EACjC;AAAA,EAEA,MAAM,aAAa,MAAiC;AAClD,WAAO,KAAK,SAAS,aAAa,IAAI;AAAA,EACxC;AAAA,EAEA,MAAM,QAAyB;AAC7B,WAAO,KAAK,SAAS,MAAM;AAAA,EAC7B;AAAA,EAEA,MAAM,KAAK,SAAqC;AAC9C,WAAO,KAAK,SAAS,KAAK,OAAO;AAAA,EACnC;AAAA,EAEA,MAAM,QAMH;AACD,WAAO,KAAK,SAAS,MAAM;AAAA,EAC7B;AAAA,EAEA,MAAM,UAA2B;AAC/B,QAAI,KAAK,SAAS,SAAS;AACzB,aAAO,KAAK,SAAS,QAAQ;AAAA,IAC/B;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI,KAAK,SAAS,OAAO;AACvB,aAAO,KAAK,SAAS,MAAM;AAAA,IAC7B;AAAA,EACF;AACF;AAEA,SAAS,sBAAsB,cAAiC,SAA+B,YAAqB,YAAoC;AACtJ,UAAQ,cAAc;AAAA,IACpB,KAAK;AACH,aAAO,oBAAoB,SAAS,UAAU,EAAE,WAAW,CAAC;AAAA,IAC9D,KAAK;AACH,aAAO,qBAAqB,SAAS,YAAY,EAAE,WAAW,CAAC;AAAA,IACjE,KAAK;AACH,aAAO,uBAAuB,SAAS,cAAc,EAAE,WAAW,CAAC;AAAA,IACrE,KAAK;AAAA,IACL;AACE,aAAO,qBAAqB,EAAE,YAAY,WAAW,CAAC;AAAA,EAC1D;AACF;AAEA,SAAS,0BAA0B,OAAgD;AACjF,QAAM,gBAAgB,MAAM;AAC5B,MAAI,EAAE,yBAAyB,QAAQ;AACrC,WAAO,uBAAuB,MAAM,UAAU;AAAA,EAChD;AAEA,QAAM,UAAU,cAAc,QAAQ,KAAK;AAC3C,MAAI,QAAQ,WAAW,GAAG;AACxB,WAAO,uBAAuB,MAAM,UAAU;AAAA,EAChD;AAEA,MAAI,QAAQ,SAAS,8CAA8C,KAAK,QAAQ,SAAS,qBAAqB,GAAG;AAC/G,WAAO,GAAG,MAAM,UAAU;AAAA,EAC5B;AAEA,MAAI,QAAQ,SAAS,oBAAoB,GAAG;AAC1C,WAAO,uBAAuB,MAAM,UAAU;AAAA,EAChD;AAEA,SAAO,GAAG,MAAM,UAAU;AAC5B;AAEA,SAAS,uBAAuB,UAAyB,cAAiC,YAAqB,YAAoC;AACjJ,MAAI,iBAAiB,SAAU,QAAO;AAEtC,MAAI,iBAAiB;AACrB,MAAI,mBAAyC;AAC7C,MAAI,SAAS;AAEb,QAAM,iBAAiB,CAAC,UAA2C;AACjE,QAAI,CAAC,kBAAkB;AACrB,yBAAmB,qBAAqB,EAAE,YAAY,WAAW,CAAC;AAAA,IACpE;AACA,QAAI,CAAC,QAAQ;AACX,YAAM,oBAAoB,MAAM,aAC5B,KAAK,0BAA0B,KAAK,CAAC,MACrC;AACJ,cAAQ,KAAK,WAAW,MAAM,QAAQ,wBAAwB,iBAAiB,oCAAoC;AACnH,eAAS;AAAA,IACX;AACA,qBAAiB;AAAA,EACnB;AAEA,QAAM,aAAa,CAAgC,WAAgC;AACjF,UAAM,UAAU,UAAU,SAAoD;AAC5E,YAAM,KAAK,eAAe,MAAM;AAChC,UAAI,CAAC,IAAI;AACP,eAAO;AAAA,MACT;AAEA,UAAI;AACF,eAAO,MAAM,GAAG,GAAG,IAAI;AAAA,MACzB,SAAS,OAAO;AACd,YAAI,iBAAiB,iCAAiC;AACpD,yBAAe,KAAK;AACpB,gBAAM,aAAa,eAAe,MAAM;AACxC,cAAI,CAAC,YAAY;AACf,mBAAO;AAAA,UACT;AACA,iBAAO,WAAW,GAAG,IAAI;AAAA,QAC3B;AACA,cAAM;AAAA,MACR;AAAA,IACF;AAEA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,KAAK,WAAW,KAAK;AAAA,IACrB,KAAK,WAAW,KAAK;AAAA,IACrB,KAAK,WAAW,KAAK;AAAA,IACrB,QAAQ,WAAW,QAAQ;AAAA,IAC3B,cAAc,WAAW,cAAc;AAAA,IACvC,OAAO,WAAW,OAAO;AAAA,IACzB,MAAM,WAAW,MAAM;AAAA,IACvB,OAAO,WAAW,OAAO;AAAA,IACzB,aAAa,OAAO,SAAS,gBAAgB,aACzC,YAAY,SAAS,YAAa,IAClC;AAAA,IACJ,SAAS,OAAO,SAAS,YAAY,aAAa,WAAW,SAAS,IAAI;AAAA,IAC1E,OAAO,OAAO,SAAS,UAAU,aAAa,WAAW,OAAO,IAAI;AAAA,EACtE;AACF;",
|
|
6
6
|
"names": ["metaKey"]
|
|
7
7
|
}
|
package/dist/strategies/redis.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { CacheDependencyUnavailableError } from "../errors.js";
|
|
2
2
|
import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
|
|
3
3
|
import { matchCacheKeyPattern } from "../patterns.js";
|
|
4
|
+
const REAP_ORPHANED_TAG_MEMBERS = `
|
|
5
|
+
local removed = 0
|
|
6
|
+
for i = 2, #KEYS do
|
|
7
|
+
if redis.call('EXISTS', KEYS[i]) == 0 then
|
|
8
|
+
removed = removed + redis.call('SREM', KEYS[1], ARGV[i - 1])
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
return removed
|
|
12
|
+
`;
|
|
13
|
+
const REAP_CHUNK_SIZE = 256;
|
|
4
14
|
let redisModulePromise = null;
|
|
5
15
|
const redisRegistry = /* @__PURE__ */ new Map();
|
|
6
16
|
function resolveRequire() {
|
|
@@ -221,17 +231,35 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
221
231
|
const deleteByTags = async (tags) => {
|
|
222
232
|
const client = await getRedisClient();
|
|
223
233
|
const keysToDelete = /* @__PURE__ */ new Set();
|
|
234
|
+
const membersByTagKey = /* @__PURE__ */ new Map();
|
|
224
235
|
for (const tag of tags) {
|
|
225
236
|
const tagKey = getTagKey(tag);
|
|
226
237
|
const keys2 = await client.smembers(tagKey);
|
|
238
|
+
membersByTagKey.set(tagKey, keys2);
|
|
227
239
|
for (const key of keys2) {
|
|
228
240
|
keysToDelete.add(key);
|
|
229
241
|
}
|
|
230
242
|
}
|
|
231
243
|
let deleted = 0;
|
|
244
|
+
const orphans = /* @__PURE__ */ new Set();
|
|
232
245
|
for (const key of keysToDelete) {
|
|
233
246
|
const success = await deleteKey(key);
|
|
234
247
|
if (success) deleted++;
|
|
248
|
+
else orphans.add(key);
|
|
249
|
+
}
|
|
250
|
+
if (orphans.size > 0) {
|
|
251
|
+
const pipeline = client.pipeline();
|
|
252
|
+
let queued = 0;
|
|
253
|
+
for (const [tagKey, members] of membersByTagKey) {
|
|
254
|
+
const tagOrphans = members.filter((member) => orphans.has(member));
|
|
255
|
+
for (let offset = 0; offset < tagOrphans.length; offset += REAP_CHUNK_SIZE) {
|
|
256
|
+
const chunk = tagOrphans.slice(offset, offset + REAP_CHUNK_SIZE);
|
|
257
|
+
const keys2 = [tagKey, ...chunk.map(getCacheKey)];
|
|
258
|
+
pipeline.eval(REAP_ORPHANED_TAG_MEMBERS, keys2.length, ...keys2, ...chunk);
|
|
259
|
+
queued++;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (queued > 0) await pipeline.exec();
|
|
235
263
|
}
|
|
236
264
|
return deleted;
|
|
237
265
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/strategies/redis.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype RedisPipeline = {\n set(key: string, value: string): RedisPipeline\n setex(key: string, ttlSeconds: number, value: string): RedisPipeline\n sadd(key: string, value: string): RedisPipeline\n srem(key: string, member: string): RedisPipeline\n del(key: string): RedisPipeline\n exec(): Promise<unknown>\n}\n\ntype RedisClient = {\n ping(): Promise<string>\n get(key: string): Promise<string | null>\n set(key: string, value: string): Promise<unknown>\n setex(key: string, ttlSeconds: number, value: string): Promise<unknown>\n del(key: string): Promise<unknown>\n exists(key: string): Promise<number>\n keys(pattern: string): Promise<string[]>\n smembers(key: string): Promise<string[]>\n pipeline(): RedisPipeline\n quit(): Promise<void>\n once?(event: 'end', listener: () => void): void\n}\n\ntype RedisConstructor = new (url?: string) => RedisClient\ntype PossibleRedisModule = unknown\n\ntype RequireFn = (id: string) => unknown\n\n/**\n * Redis cache strategy with tag support\n * Persistent across process restarts, can be shared across multiple instances\n * \n * Uses Redis data structures:\n * - Hash for storing cache entries: cache:{key} -> {value, tags, expiresAt, createdAt}\n * - Sets for tag index: tag:{tag} -> Set of keys\n */\nlet redisModulePromise: Promise<PossibleRedisModule> | null = null\ntype RedisRegistryEntry = { client?: RedisClient; creating?: Promise<RedisClient>; refs: number }\nconst redisRegistry = new Map<string, RedisRegistryEntry>()\n\nfunction resolveRequire(): RequireFn | null {\n const nonWebpack = (globalThis as { __non_webpack_require__?: unknown }).__non_webpack_require__\n if (typeof nonWebpack === 'function') return nonWebpack as RequireFn\n if (typeof require === 'function') return require as RequireFn\n if (typeof module !== 'undefined' && typeof module.require === 'function') {\n return module.require.bind(module)\n }\n try {\n const maybeRequire = Function('return typeof require !== \"undefined\" ? require : undefined')()\n if (typeof maybeRequire === 'function') return maybeRequire as RequireFn\n } catch {\n // ignore\n }\n return null\n}\n\nfunction loadRedisModuleViaRequire(): PossibleRedisModule | null {\n const resolver = resolveRequire()\n if (!resolver) return null\n try {\n return resolver('ioredis') as PossibleRedisModule\n } catch {\n return null\n }\n}\n\nfunction pickRedisConstructor(mod: PossibleRedisModule): RedisConstructor | null {\n const queue: unknown[] = [mod]\n const seen = new Set<unknown>()\n while (queue.length) {\n const current = queue.shift()\n if (!current || seen.has(current)) continue\n seen.add(current)\n if (typeof current === 'function') return current as RedisConstructor\n if (typeof current === 'object') {\n queue.push((current as { default?: unknown }).default)\n queue.push((current as { Redis?: unknown }).Redis)\n queue.push((current as { module?: { exports?: unknown } }).module?.exports)\n queue.push((current as { exports?: unknown }).exports)\n }\n }\n return null\n}\n\nasync function loadRedisModule(): Promise<PossibleRedisModule> {\n if (!redisModulePromise) {\n redisModulePromise = (async () => {\n const required = loadRedisModuleViaRequire() ?? (await import('ioredis'))\n return required as PossibleRedisModule\n })().catch((error) => {\n redisModulePromise = null\n throw new CacheDependencyUnavailableError('redis', 'ioredis', error)\n })\n }\n return redisModulePromise\n}\n\nfunction retainRedisEntry(key: string): RedisRegistryEntry {\n let entry = redisRegistry.get(key)\n if (!entry) {\n entry = { refs: 0 }\n redisRegistry.set(key, entry)\n }\n entry.refs += 1\n return entry\n}\n\nasync function acquireRedisClient(key: string, entry: RedisRegistryEntry): Promise<RedisClient> {\n if (entry.client) return entry.client\n if (entry.creating) return entry.creating\n entry.creating = loadRedisModule()\n .then((mod) => {\n const ctor = pickRedisConstructor(mod)\n if (!ctor) {\n throw new CacheDependencyUnavailableError('redis', 'ioredis', new Error('No usable Redis constructor'))\n }\n const client = new ctor(key)\n entry.client = client\n entry.creating = undefined\n client.once?.('end', () => {\n if (redisRegistry.get(key) === entry && entry.refs === 0) {\n redisRegistry.delete(key)\n } else if (redisRegistry.get(key) === entry) {\n entry.client = undefined\n }\n })\n return client\n })\n .catch((error) => {\n entry.creating = undefined\n throw error\n })\n return entry.creating\n}\n\nasync function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promise<void> {\n entry.refs = Math.max(0, entry.refs - 1)\n if (entry.refs > 0) return\n redisRegistry.delete(key)\n if (entry.client) {\n try {\n await entry.client.quit()\n } catch {\n // ignore shutdown errors\n } finally {\n entry.client = undefined\n }\n }\n}\n\nexport function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {\n const defaultTtl = options?.defaultTtl\n const keyPrefix = 'cache:'\n const tagPrefix = 'tag:'\n const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')\n const registryEntry = retainRedisEntry(connectionUrl)\n let redis: RedisClient | null = registryEntry.client ?? null\n\n async function getRedisClient(): Promise<RedisClient> {\n if (redis) return redis\n\n redis = await acquireRedisClient(connectionUrl, registryEntry)\n return redis\n }\n\n function getCacheKey(key: string): string {\n return `${keyPrefix}${key}`\n }\n\n function getTagKey(tag: string): string {\n return `${tagPrefix}${tag}`\n }\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const data = await client.get(cacheKey)\n\n if (!data) return null\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return entry.value\n } catch {\n // Invalid JSON, remove it\n await client.del(cacheKey)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Remove old entry from tag index if it exists\n const oldData = await client.get(cacheKey)\n if (oldData) {\n try {\n const oldEntry: CacheEntry = JSON.parse(oldData)\n // Remove from old tags\n const pipeline = client.pipeline()\n for (const tag of oldEntry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n await pipeline.exec()\n } catch {\n // Ignore parse errors\n }\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n const pipeline = client.pipeline()\n\n // Store the entry\n const serialized = JSON.stringify(entry)\n if (ttl) {\n pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)\n } else {\n pipeline.set(cacheKey, serialized)\n }\n\n // Add to tag index\n for (const tag of tags) {\n pipeline.sadd(getTagKey(tag), key)\n }\n\n await pipeline.exec()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const exists = await client.exists(cacheKey)\n\n if (!exists) return false\n\n // Check if expired\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n await deleteKey(key)\n return false\n }\n return true\n } catch {\n return false\n }\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Get entry to remove from tag index\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n const pipeline = client.pipeline()\n\n // Remove from tag index\n for (const tag of entry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n\n // Delete the cache entry\n pipeline.del(cacheKey)\n\n await pipeline.exec()\n return true\n } catch {\n // Just delete the key if we can't parse it\n await client.del(cacheKey)\n return true\n }\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const client = await getRedisClient()\n const keysToDelete = new Set<string>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const tagKey = getTagKey(tag)\n const keys = await client.smembers(tagKey)\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const client = await getRedisClient()\n \n // Get all cache keys\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n const tagKeys = await client.keys(`${tagPrefix}*`)\n\n if (cacheKeys.length === 0 && tagKeys.length === 0) return 0\n\n const pipeline = client.pipeline()\n for (const key of [...cacheKeys, ...tagKeys]) {\n pipeline.del(key)\n }\n\n await pipeline.exec()\n return cacheKeys.length\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const client = await getRedisClient()\n const searchPattern = pattern \n ? `${keyPrefix}${pattern}` \n : `${keyPrefix}*`\n \n const cacheKeys = await client.keys(searchPattern)\n \n // Remove prefix from keys\n const result = cacheKeys.map((key: string) => key.substring(keyPrefix.length))\n \n if (!pattern) return result\n \n // Apply pattern matching (Redis KEYS command uses glob pattern, but we want our pattern)\n return result.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let expired = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n expired++\n }\n } catch {\n // Ignore parse errors\n }\n }\n }\n\n return { size: cacheKeys.length, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let removed = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n const key = cacheKey.substring(keyPrefix.length)\n await deleteKey(key)\n removed++\n }\n } catch {\n // Remove invalid entries\n await client.del(cacheKey)\n removed++\n }\n }\n }\n\n return removed\n }\n\n const close = async (): Promise<void> => {\n await releaseRedisEntry(connectionUrl, registryEntry)\n redis = null\n }\n\n const healthcheck = async (): Promise<void> => {\n const client = await getRedisClient()\n await client.ping()\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,uCAAuC;AAChD,SAAS,0BAA0B;AACnC,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype RedisPipeline = {\n set(key: string, value: string): RedisPipeline\n setex(key: string, ttlSeconds: number, value: string): RedisPipeline\n sadd(key: string, value: string): RedisPipeline\n srem(key: string, member: string): RedisPipeline\n del(key: string): RedisPipeline\n eval(script: string, numKeys: number, ...args: (string | number)[]): RedisPipeline\n exec(): Promise<unknown>\n}\n\ntype RedisClient = {\n ping(): Promise<string>\n get(key: string): Promise<string | null>\n set(key: string, value: string): Promise<unknown>\n setex(key: string, ttlSeconds: number, value: string): Promise<unknown>\n del(key: string): Promise<unknown>\n exists(key: string): Promise<number>\n keys(pattern: string): Promise<string[]>\n smembers(key: string): Promise<string[]>\n pipeline(): RedisPipeline\n quit(): Promise<void>\n once?(event: 'end', listener: () => void): void\n}\n\ntype RedisConstructor = new (url?: string) => RedisClient\ntype PossibleRedisModule = unknown\n\ntype RequireFn = (id: string) => unknown\n\n/**\n * Reaps orphaned members from ONE tag set atomically.\n *\n * `KEYS[1]` is the tag key and `KEYS[2..]` are the candidate value keys, with\n * `ARGV[1..]` the member names aligned to them. Every key the script touches is\n * declared in `KEYS` so it stays valid against a slot-aware deployment.\n *\n * The `EXISTS` check and the `SREM` run in one atomic step, which is the whole\n * point: a member may only be dropped while its value key is genuinely absent.\n * A concurrent `set()` either lands first (`EXISTS` is 1, so the member is left\n * alone) or lands after (its own `sadd` re-adds the member). Either ordering\n * leaves a live key indexed.\n *\n * Deliberately single-tag. Pairing every orphan with every requested tag would\n * make the work quadratic in the tag count \u2014 and because a script blocks the\n * server for its whole run, that lands as a stall rather than as throughput.\n * Callers reap per tag using the membership each `smembers` actually returned.\n */\nconst REAP_ORPHANED_TAG_MEMBERS = `\nlocal removed = 0\nfor i = 2, #KEYS do\n if redis.call('EXISTS', KEYS[i]) == 0 then\n removed = removed + redis.call('SREM', KEYS[1], ARGV[i - 1])\n end\nend\nreturn removed\n`\n\n/** Bounds a single script's blocking time on the server, and the request size. */\nconst REAP_CHUNK_SIZE = 256\n\n/**\n * Redis cache strategy with tag support\n * Persistent across process restarts, can be shared across multiple instances\n * \n * Uses Redis data structures:\n * - Hash for storing cache entries: cache:{key} -> {value, tags, expiresAt, createdAt}\n * - Sets for tag index: tag:{tag} -> Set of keys\n */\nlet redisModulePromise: Promise<PossibleRedisModule> | null = null\ntype RedisRegistryEntry = { client?: RedisClient; creating?: Promise<RedisClient>; refs: number }\nconst redisRegistry = new Map<string, RedisRegistryEntry>()\n\nfunction resolveRequire(): RequireFn | null {\n const nonWebpack = (globalThis as { __non_webpack_require__?: unknown }).__non_webpack_require__\n if (typeof nonWebpack === 'function') return nonWebpack as RequireFn\n if (typeof require === 'function') return require as RequireFn\n if (typeof module !== 'undefined' && typeof module.require === 'function') {\n return module.require.bind(module)\n }\n try {\n const maybeRequire = Function('return typeof require !== \"undefined\" ? require : undefined')()\n if (typeof maybeRequire === 'function') return maybeRequire as RequireFn\n } catch {\n // ignore\n }\n return null\n}\n\nfunction loadRedisModuleViaRequire(): PossibleRedisModule | null {\n const resolver = resolveRequire()\n if (!resolver) return null\n try {\n return resolver('ioredis') as PossibleRedisModule\n } catch {\n return null\n }\n}\n\nfunction pickRedisConstructor(mod: PossibleRedisModule): RedisConstructor | null {\n const queue: unknown[] = [mod]\n const seen = new Set<unknown>()\n while (queue.length) {\n const current = queue.shift()\n if (!current || seen.has(current)) continue\n seen.add(current)\n if (typeof current === 'function') return current as RedisConstructor\n if (typeof current === 'object') {\n queue.push((current as { default?: unknown }).default)\n queue.push((current as { Redis?: unknown }).Redis)\n queue.push((current as { module?: { exports?: unknown } }).module?.exports)\n queue.push((current as { exports?: unknown }).exports)\n }\n }\n return null\n}\n\nasync function loadRedisModule(): Promise<PossibleRedisModule> {\n if (!redisModulePromise) {\n redisModulePromise = (async () => {\n const required = loadRedisModuleViaRequire() ?? (await import('ioredis'))\n return required as PossibleRedisModule\n })().catch((error) => {\n redisModulePromise = null\n throw new CacheDependencyUnavailableError('redis', 'ioredis', error)\n })\n }\n return redisModulePromise\n}\n\nfunction retainRedisEntry(key: string): RedisRegistryEntry {\n let entry = redisRegistry.get(key)\n if (!entry) {\n entry = { refs: 0 }\n redisRegistry.set(key, entry)\n }\n entry.refs += 1\n return entry\n}\n\nasync function acquireRedisClient(key: string, entry: RedisRegistryEntry): Promise<RedisClient> {\n if (entry.client) return entry.client\n if (entry.creating) return entry.creating\n entry.creating = loadRedisModule()\n .then((mod) => {\n const ctor = pickRedisConstructor(mod)\n if (!ctor) {\n throw new CacheDependencyUnavailableError('redis', 'ioredis', new Error('No usable Redis constructor'))\n }\n const client = new ctor(key)\n entry.client = client\n entry.creating = undefined\n client.once?.('end', () => {\n if (redisRegistry.get(key) === entry && entry.refs === 0) {\n redisRegistry.delete(key)\n } else if (redisRegistry.get(key) === entry) {\n entry.client = undefined\n }\n })\n return client\n })\n .catch((error) => {\n entry.creating = undefined\n throw error\n })\n return entry.creating\n}\n\nasync function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promise<void> {\n entry.refs = Math.max(0, entry.refs - 1)\n if (entry.refs > 0) return\n redisRegistry.delete(key)\n if (entry.client) {\n try {\n await entry.client.quit()\n } catch {\n // ignore shutdown errors\n } finally {\n entry.client = undefined\n }\n }\n}\n\nexport function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {\n const defaultTtl = options?.defaultTtl\n const keyPrefix = 'cache:'\n const tagPrefix = 'tag:'\n const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')\n const registryEntry = retainRedisEntry(connectionUrl)\n let redis: RedisClient | null = registryEntry.client ?? null\n\n async function getRedisClient(): Promise<RedisClient> {\n if (redis) return redis\n\n redis = await acquireRedisClient(connectionUrl, registryEntry)\n return redis\n }\n\n function getCacheKey(key: string): string {\n return `${keyPrefix}${key}`\n }\n\n function getTagKey(tag: string): string {\n return `${tagPrefix}${tag}`\n }\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const data = await client.get(cacheKey)\n\n if (!data) return null\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return entry.value\n } catch {\n // Invalid JSON, remove it\n await client.del(cacheKey)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Remove old entry from tag index if it exists\n const oldData = await client.get(cacheKey)\n if (oldData) {\n try {\n const oldEntry: CacheEntry = JSON.parse(oldData)\n // Remove from old tags\n const pipeline = client.pipeline()\n for (const tag of oldEntry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n await pipeline.exec()\n } catch {\n // Ignore parse errors\n }\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n const pipeline = client.pipeline()\n\n // Store the entry\n const serialized = JSON.stringify(entry)\n if (ttl) {\n pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)\n } else {\n pipeline.set(cacheKey, serialized)\n }\n\n // Add to tag index\n for (const tag of tags) {\n pipeline.sadd(getTagKey(tag), key)\n }\n\n await pipeline.exec()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const exists = await client.exists(cacheKey)\n\n if (!exists) return false\n\n // Check if expired\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n await deleteKey(key)\n return false\n }\n return true\n } catch {\n return false\n }\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Get entry to remove from tag index\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n const pipeline = client.pipeline()\n\n // Remove from tag index\n for (const tag of entry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n\n // Delete the cache entry\n pipeline.del(cacheKey)\n\n await pipeline.exec()\n return true\n } catch {\n // Just delete the key if we can't parse it\n await client.del(cacheKey)\n return true\n }\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const client = await getRedisClient()\n const keysToDelete = new Set<string>()\n // Keep which tag each key came from. Reaping needs it to stay linear in the\n // memberships that actually exist rather than in tags x keys.\n const membersByTagKey = new Map<string, string[]>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const tagKey = getTagKey(tag)\n const keys = await client.smembers(tagKey)\n membersByTagKey.set(tagKey, keys)\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n const orphans = new Set<string>()\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n else orphans.add(key)\n }\n\n // A key whose value has already expired leaves its name behind in every tag\n // set it was added to: `set()` only prunes tags when it overwrites a live\n // key, `deleteKey` only prunes when it finds one, and `cleanup()` scans\n // value keys rather than tag sets. Nothing else reaps them, so the sets grow\n // without bound whenever TTL'd entries also rotate their key names \u2014 and\n // every later invalidation pays a GET per orphan. Reap what we just walked.\n //\n // `deleteKey` reported these missing some time ago \u2014 the walk above is one\n // round trip per member \u2014 so a concurrent request may have rebuilt any of\n // them since. Re-check each value key atomically with the removal rather\n // than trusting that stale read: dropping a live key from the index would\n // silently exempt it from every future invalidation of this tag.\n //\n // Reap per tag, over that tag's own members, so the work stays linear in the\n // memberships `smembers` actually returned. Each script is capped at\n // REAP_CHUNK_SIZE members, so no single atomic call can stall the server for\n // longer than a fixed bound however many tags were requested. The calls are\n // pipelined into one round trip, but stay separate scripts so Redis can\n // serve other clients between them.\n if (orphans.size > 0) {\n const pipeline = client.pipeline()\n let queued = 0\n for (const [tagKey, members] of membersByTagKey) {\n const tagOrphans = members.filter((member) => orphans.has(member))\n for (let offset = 0; offset < tagOrphans.length; offset += REAP_CHUNK_SIZE) {\n const chunk = tagOrphans.slice(offset, offset + REAP_CHUNK_SIZE)\n const keys = [tagKey, ...chunk.map(getCacheKey)]\n pipeline.eval(REAP_ORPHANED_TAG_MEMBERS, keys.length, ...keys, ...chunk)\n queued++\n }\n }\n if (queued > 0) await pipeline.exec()\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const client = await getRedisClient()\n \n // Get all cache keys\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n const tagKeys = await client.keys(`${tagPrefix}*`)\n\n if (cacheKeys.length === 0 && tagKeys.length === 0) return 0\n\n const pipeline = client.pipeline()\n for (const key of [...cacheKeys, ...tagKeys]) {\n pipeline.del(key)\n }\n\n await pipeline.exec()\n return cacheKeys.length\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const client = await getRedisClient()\n const searchPattern = pattern \n ? `${keyPrefix}${pattern}` \n : `${keyPrefix}*`\n \n const cacheKeys = await client.keys(searchPattern)\n \n // Remove prefix from keys\n const result = cacheKeys.map((key: string) => key.substring(keyPrefix.length))\n \n if (!pattern) return result\n \n // Apply pattern matching (Redis KEYS command uses glob pattern, but we want our pattern)\n return result.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let expired = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n expired++\n }\n } catch {\n // Ignore parse errors\n }\n }\n }\n\n return { size: cacheKeys.length, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let removed = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n const key = cacheKey.substring(keyPrefix.length)\n await deleteKey(key)\n removed++\n }\n } catch {\n // Remove invalid entries\n await client.del(cacheKey)\n removed++\n }\n }\n }\n\n return removed\n }\n\n const close = async (): Promise<void> => {\n await releaseRedisEntry(connectionUrl, registryEntry)\n redis = null\n }\n\n const healthcheck = async (): Promise<void> => {\n const client = await getRedisClient()\n await client.ping()\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["options", "pipeline", "keys"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cache",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8-canary.319.1.5b4be4bc52",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Multi-strategy cache service with tag-based invalidation support",
|
|
6
6
|
"type": "module",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/jest": "^30.0.0",
|
|
62
|
-
"@types/node": "^26.
|
|
62
|
+
"@types/node": "^26.1.2",
|
|
63
63
|
"jest": "^30.4.2",
|
|
64
|
-
"ts-jest": "^29.4.
|
|
64
|
+
"ts-jest": "^29.4.12",
|
|
65
65
|
"typescript": "7.0.2"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
@@ -71,5 +71,6 @@
|
|
|
71
71
|
"type": "git",
|
|
72
72
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
73
73
|
"directory": "packages/cache"
|
|
74
|
-
}
|
|
74
|
+
},
|
|
75
|
+
"stableVersion": "0.6.7"
|
|
75
76
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cache DI-contract documentation audit.
|
|
6
|
+
*
|
|
7
|
+
* `packages/cache/AGENTS.md` and `.ai/review-checklist.md` are the two places an agent
|
|
8
|
+
* looks up how to reach the cache. Both had drifted away from the runtime: they told
|
|
9
|
+
* readers to resolve a `cacheService` DI token (nothing registers that name — the real
|
|
10
|
+
* token is `cache`, registered in `packages/core/src/bootstrap.ts`) and to invalidate a
|
|
11
|
+
* tag with `invalidateTag('customers')` (no such member exists — `CacheStrategy` exposes
|
|
12
|
+
* `deleteByTags(tags: string[])`). Both snippets throw at runtime, so the docs were
|
|
13
|
+
* actively producing broken module code.
|
|
14
|
+
*
|
|
15
|
+
* This audit re-derives the ground truth from the sources on every run — the registered
|
|
16
|
+
* token from the bootstrap registration, the callable members from the `CacheStrategy`
|
|
17
|
+
* type — and fails when either document quotes a token or a method that no longer exists.
|
|
18
|
+
* Renaming the token or a strategy method therefore breaks this test until the docs are
|
|
19
|
+
* updated in the same change.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const repoRoot = join(__dirname, '..', '..', '..', '..')
|
|
23
|
+
const bootstrapPath = join(repoRoot, 'packages', 'core', 'src', 'bootstrap.ts')
|
|
24
|
+
const cacheTypesPath = join(repoRoot, 'packages', 'cache', 'src', 'types.ts')
|
|
25
|
+
const cacheAgentsPath = join(repoRoot, 'packages', 'cache', 'AGENTS.md')
|
|
26
|
+
const reviewChecklistPath = join(repoRoot, '.ai', 'review-checklist.md')
|
|
27
|
+
|
|
28
|
+
function readSource(path: string): string {
|
|
29
|
+
return readFileSync(path, 'utf8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The DI token `bootstrap()` registers the cache service under. */
|
|
33
|
+
function registeredCacheToken(): string {
|
|
34
|
+
const source = readSource(bootstrapPath)
|
|
35
|
+
const match = source.match(/container\.register\(\{\s*([A-Za-z_$][\w$]*)\s*:\s*asValue\(cache\)\s*\}\)/)
|
|
36
|
+
if (!match) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'[internal] Could not locate the cache registration in packages/core/src/bootstrap.ts. ' +
|
|
39
|
+
'Update this audit if the registration shape changed.',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return match[1]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The body of the `CacheStrategy` type declaration. */
|
|
46
|
+
function cacheStrategyBody(): string {
|
|
47
|
+
const source = readSource(cacheTypesPath)
|
|
48
|
+
const start = source.indexOf('export type CacheStrategy = {')
|
|
49
|
+
if (start === -1) throw new Error('[internal] CacheStrategy type not found in packages/cache/src/types.ts')
|
|
50
|
+
const rest = source.slice(start)
|
|
51
|
+
const endOffset = rest.search(/^\}/m)
|
|
52
|
+
if (endOffset === -1) throw new Error('[internal] CacheStrategy type body is not terminated')
|
|
53
|
+
return rest.slice(0, endOffset)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every callable member of the `CacheStrategy` contract, optional ones included. */
|
|
57
|
+
function cacheStrategyMembers(body: string): Set<string> {
|
|
58
|
+
const members = new Set<string>()
|
|
59
|
+
const memberPattern = /^ {2}([A-Za-z_$][\w$]*)\??\(/gm
|
|
60
|
+
let match = memberPattern.exec(body)
|
|
61
|
+
while (match) {
|
|
62
|
+
members.add(match[1])
|
|
63
|
+
match = memberPattern.exec(body)
|
|
64
|
+
}
|
|
65
|
+
return members
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The member that performs tag-based invalidation — the one taking a `string[]` of tags. */
|
|
69
|
+
function tagInvalidationMember(body: string): string {
|
|
70
|
+
const match = body.match(/^ {2}([A-Za-z_$][\w$]*)\??\(tags: string\[\]\)/m)
|
|
71
|
+
if (!match) throw new Error('[internal] CacheStrategy no longer declares a tags: string[] member')
|
|
72
|
+
return match[1]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every `container.resolve('x')` / `resolve('x')` token quoted by a document. */
|
|
76
|
+
function documentedResolveTokens(markdown: string): string[] {
|
|
77
|
+
const tokens: string[] = []
|
|
78
|
+
const pattern = /\bresolve\(\s*['"`]([^'"`]+)['"`]\s*\)/g
|
|
79
|
+
let match = pattern.exec(markdown)
|
|
80
|
+
while (match) {
|
|
81
|
+
tokens.push(match[1])
|
|
82
|
+
match = pattern.exec(markdown)
|
|
83
|
+
}
|
|
84
|
+
return tokens
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every method invoked on a cache-shaped identifier inside a document. */
|
|
88
|
+
function documentedCacheMethodCalls(markdown: string): string[] {
|
|
89
|
+
const calls: string[] = []
|
|
90
|
+
const pattern = /\b([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(/g
|
|
91
|
+
let match = pattern.exec(markdown)
|
|
92
|
+
while (match) {
|
|
93
|
+
if (/cache/i.test(match[1])) calls.push(match[2])
|
|
94
|
+
match = pattern.exec(markdown)
|
|
95
|
+
}
|
|
96
|
+
return calls
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The review checklist documents every subsystem, so only its Cache section is in scope —
|
|
101
|
+
* other sections legitimately quote other DI tokens.
|
|
102
|
+
*/
|
|
103
|
+
function cacheSectionOf(markdown: string): string {
|
|
104
|
+
const heading = markdown.match(/^## \d+\. Cache$/m)
|
|
105
|
+
if (!heading || heading.index === undefined) {
|
|
106
|
+
throw new Error('[internal] .ai/review-checklist.md no longer has a "## <n>. Cache" section')
|
|
107
|
+
}
|
|
108
|
+
const rest = markdown.slice(heading.index + heading[0].length)
|
|
109
|
+
const nextHeading = rest.search(/^## /m)
|
|
110
|
+
return nextHeading === -1 ? rest : rest.slice(0, nextHeading)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type AuditedDocument = { file: string; text: string }
|
|
114
|
+
|
|
115
|
+
function auditedDocuments(): AuditedDocument[] {
|
|
116
|
+
return [
|
|
117
|
+
{ file: 'packages/cache/AGENTS.md', text: readSource(cacheAgentsPath) },
|
|
118
|
+
{ file: '.ai/review-checklist.md', text: cacheSectionOf(readSource(reviewChecklistPath)) },
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('cache DI contract documentation', () => {
|
|
123
|
+
const token = registeredCacheToken()
|
|
124
|
+
const strategyBody = cacheStrategyBody()
|
|
125
|
+
const members = cacheStrategyMembers(strategyBody)
|
|
126
|
+
const tagMember = tagInvalidationMember(strategyBody)
|
|
127
|
+
const documents = auditedDocuments()
|
|
128
|
+
|
|
129
|
+
it('derives the ground truth it audits against', () => {
|
|
130
|
+
expect(token).toBe('cache')
|
|
131
|
+
const required = ['get', 'set', 'has', 'delete', 'deleteByTags', 'clear', 'keys', 'stats']
|
|
132
|
+
expect(required.filter((member) => !members.has(member))).toEqual([])
|
|
133
|
+
expect(members.has('invalidateTag')).toBe(false)
|
|
134
|
+
expect(tagMember).toBe('deleteByTags')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('every audited document resolves only the registered DI token', () => {
|
|
138
|
+
for (const { file, text } of documents) {
|
|
139
|
+
const quoted = documentedResolveTokens(text)
|
|
140
|
+
expect({ file, quotedCount: quoted.length > 0 }).toEqual({ file, quotedCount: true })
|
|
141
|
+
for (const quotedToken of quoted) {
|
|
142
|
+
expect({ file, quotedToken }).toEqual({ file, quotedToken: token })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('every audited document only calls methods that exist on CacheStrategy', () => {
|
|
148
|
+
for (const { file, text } of documents) {
|
|
149
|
+
for (const method of documentedCacheMethodCalls(text)) {
|
|
150
|
+
expect({ file, method, exists: members.has(method) }).toEqual({ file, method, exists: true })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('every audited document names tag invalidation by its real member', () => {
|
|
156
|
+
for (const { file, text } of documents) {
|
|
157
|
+
expect({ file, documented: text.includes(`${tagMember}(`) }).toEqual({ file, documented: true })
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
import type { CacheStrategy } from '../types'
|
|
3
|
+
import { createRedisStrategy } from '../strategies/redis'
|
|
4
|
+
|
|
5
|
+
type PipelineOp = () => void
|
|
6
|
+
|
|
7
|
+
class FakeRedis {
|
|
8
|
+
values = new Map<string, string>()
|
|
9
|
+
sets = new Map<string, Set<string>>()
|
|
10
|
+
/** Records the shape of each reap script call so tests can assert its cost. */
|
|
11
|
+
evalCalls: { numKeys: number; memberCount: number }[] = []
|
|
12
|
+
|
|
13
|
+
async ping(): Promise<string> {
|
|
14
|
+
return 'PONG'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async get(key: string): Promise<string | null> {
|
|
18
|
+
return this.values.get(key) ?? null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async set(key: string, value: string): Promise<unknown> {
|
|
22
|
+
this.values.set(key, value)
|
|
23
|
+
return 'OK'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async setex(key: string, _ttlSeconds: number, value: string): Promise<unknown> {
|
|
27
|
+
this.values.set(key, value)
|
|
28
|
+
return 'OK'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async del(key: string): Promise<unknown> {
|
|
32
|
+
return this.values.delete(key) ? 1 : 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async exists(key: string): Promise<number> {
|
|
36
|
+
return this.values.has(key) ? 1 : 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async keys(pattern: string): Promise<string[]> {
|
|
40
|
+
const prefix = pattern.endsWith('*') ? pattern.slice(0, -1) : pattern
|
|
41
|
+
return [...this.values.keys()].filter((key) => key.startsWith(prefix))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async smembers(key: string): Promise<string[]> {
|
|
45
|
+
return [...(this.sets.get(key) ?? [])]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mirrors the reap script's KEYS/ARGV contract rather than interpreting Lua —
|
|
50
|
+
* there is no Lua runtime here, and pulling one in for a single script is not
|
|
51
|
+
* worth the dependency. What this pins down is the strategy's side of the
|
|
52
|
+
* contract (key ordering, member alignment, chunking) and the exists-guarded
|
|
53
|
+
* semantics. The script *body* is not covered; verify it against a real Redis
|
|
54
|
+
* when changing it.
|
|
55
|
+
*/
|
|
56
|
+
runEval(_script: string, numKeys: number, ...args: (string | number)[]): number {
|
|
57
|
+
const [tagKey, ...valueKeys] = args.slice(0, numKeys) as string[]
|
|
58
|
+
const members = args.slice(numKeys).map(String)
|
|
59
|
+
|
|
60
|
+
if (valueKeys.length !== members.length) {
|
|
61
|
+
throw new Error(`[internal] eval contract: ${valueKeys.length} value keys vs ${members.length} members`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let removed = 0
|
|
65
|
+
for (const [index, valueKey] of valueKeys.entries()) {
|
|
66
|
+
if (this.values.has(valueKey)) continue
|
|
67
|
+
const set = this.sets.get(tagKey)
|
|
68
|
+
if (!set?.delete(members[index])) continue
|
|
69
|
+
removed++
|
|
70
|
+
if (set.size === 0) this.sets.delete(tagKey)
|
|
71
|
+
}
|
|
72
|
+
return removed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pipeline() {
|
|
76
|
+
const ops: PipelineOp[] = []
|
|
77
|
+
const chain = {
|
|
78
|
+
set: (key: string, value: string) => {
|
|
79
|
+
ops.push(() => void this.values.set(key, value))
|
|
80
|
+
return chain
|
|
81
|
+
},
|
|
82
|
+
setex: (key: string, _ttlSeconds: number, value: string) => {
|
|
83
|
+
ops.push(() => void this.values.set(key, value))
|
|
84
|
+
return chain
|
|
85
|
+
},
|
|
86
|
+
sadd: (key: string, member: string) => {
|
|
87
|
+
ops.push(() => {
|
|
88
|
+
const members = this.sets.get(key) ?? new Set<string>()
|
|
89
|
+
members.add(member)
|
|
90
|
+
this.sets.set(key, members)
|
|
91
|
+
})
|
|
92
|
+
return chain
|
|
93
|
+
},
|
|
94
|
+
srem: (key: string, member: string) => {
|
|
95
|
+
ops.push(() => {
|
|
96
|
+
const members = this.sets.get(key)
|
|
97
|
+
if (!members) return
|
|
98
|
+
members.delete(member)
|
|
99
|
+
// Redis drops a set once its last member is removed.
|
|
100
|
+
if (members.size === 0) this.sets.delete(key)
|
|
101
|
+
})
|
|
102
|
+
return chain
|
|
103
|
+
},
|
|
104
|
+
del: (key: string) => {
|
|
105
|
+
ops.push(() => void this.values.delete(key))
|
|
106
|
+
return chain
|
|
107
|
+
},
|
|
108
|
+
eval: (script: string, numKeys: number, ...args: (string | number)[]) => {
|
|
109
|
+
this.evalCalls.push({ numKeys, memberCount: args.length - numKeys })
|
|
110
|
+
ops.push(() => void this.runEval(script, numKeys, ...args))
|
|
111
|
+
return chain
|
|
112
|
+
},
|
|
113
|
+
exec: async () => {
|
|
114
|
+
for (const op of ops) op()
|
|
115
|
+
ops.length = 0
|
|
116
|
+
return []
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
return chain
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async quit(): Promise<void> {}
|
|
123
|
+
|
|
124
|
+
/** Simulate a TTL firing: Redis expiry deletes the value key and nothing else. */
|
|
125
|
+
expire(cacheKey: string): void {
|
|
126
|
+
this.values.delete(cacheKey)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let currentRedis: FakeRedis
|
|
131
|
+
|
|
132
|
+
jest.mock('ioredis', () => ({
|
|
133
|
+
__esModule: true,
|
|
134
|
+
default: class {
|
|
135
|
+
constructor() {
|
|
136
|
+
return currentRedis as unknown as object
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}))
|
|
140
|
+
|
|
141
|
+
describe('redis strategy tag index', () => {
|
|
142
|
+
let strategy: CacheStrategy
|
|
143
|
+
let urlCounter = 0
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
currentRedis = new FakeRedis()
|
|
147
|
+
urlCounter += 1
|
|
148
|
+
// The client registry is module-global and keyed by URL; keep runs isolated.
|
|
149
|
+
strategy = createRedisStrategy(`redis://cache-test-${urlCounter}`)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
await strategy.close?.()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('reaps tag members whose value key already expired', async () => {
|
|
157
|
+
await strategy.set('nav:sidebar:abc123:pl:user-1', { groups: [] }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
158
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual(['nav:sidebar:abc123:pl:user-1'])
|
|
159
|
+
|
|
160
|
+
currentRedis.expire('cache:nav:sidebar:abc123:pl:user-1')
|
|
161
|
+
|
|
162
|
+
const deleted = await strategy.deleteByTags(['rbac:tenant:t1'])
|
|
163
|
+
|
|
164
|
+
expect(deleted).toBe(0)
|
|
165
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual([])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not accumulate members when TTL entries rotate their key names', async () => {
|
|
169
|
+
for (const fingerprint of ['abc123', 'def456', 'ghi789']) {
|
|
170
|
+
const key = `nav:sidebar:${fingerprint}:pl:user-1`
|
|
171
|
+
await strategy.set(key, { groups: [] }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
172
|
+
currentRedis.expire(`cache:${key}`)
|
|
173
|
+
await strategy.deleteByTags(['rbac:tenant:t1'])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('leaves live keys in the tag index alone and still reports them deleted', async () => {
|
|
180
|
+
await strategy.set('live', { value: 1 }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
181
|
+
await strategy.set('expired', { value: 2 }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
182
|
+
currentRedis.expire('cache:expired')
|
|
183
|
+
|
|
184
|
+
const deleted = await strategy.deleteByTags(['rbac:tenant:t1'])
|
|
185
|
+
|
|
186
|
+
expect(deleted).toBe(1)
|
|
187
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual([])
|
|
188
|
+
expect(await strategy.get('live')).toBeNull()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps a key indexed when a concurrent write rebuilds it mid-walk', async () => {
|
|
192
|
+
await strategy.set('nav:user-1', { v: 'old' }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
193
|
+
currentRedis.expire('cache:nav:user-1')
|
|
194
|
+
|
|
195
|
+
// deleteByTags walks members one round trip at a time, so an entry it read
|
|
196
|
+
// as missing can be rebuilt before the reap runs. Land that write in the
|
|
197
|
+
// window by hooking the GET that reports the key absent.
|
|
198
|
+
const readValue = currentRedis.get.bind(currentRedis)
|
|
199
|
+
let rebuilt = false
|
|
200
|
+
currentRedis.get = async (key: string) => {
|
|
201
|
+
const result = await readValue(key)
|
|
202
|
+
if (!rebuilt && key === 'cache:nav:user-1' && result === null) {
|
|
203
|
+
rebuilt = true
|
|
204
|
+
await strategy.set('nav:user-1', { v: 'fresh' }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
205
|
+
}
|
|
206
|
+
return result
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await strategy.deleteByTags(['rbac:tenant:t1'])
|
|
210
|
+
currentRedis.get = readValue
|
|
211
|
+
|
|
212
|
+
// The rebuilt entry must stay in the index, or it silently outlives every
|
|
213
|
+
// future invalidation of this tag and serves stale data until its TTL.
|
|
214
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual(['nav:user-1'])
|
|
215
|
+
expect(await strategy.deleteByTags(['rbac:tenant:t1'])).toBe(1)
|
|
216
|
+
expect(await strategy.get('nav:user-1')).toBeNull()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('reaps orphans spanning multiple chunks', async () => {
|
|
220
|
+
const keyCount = 600 // REAP_CHUNK_SIZE is 256, so this spans three chunks.
|
|
221
|
+
for (let index = 0; index < keyCount; index++) {
|
|
222
|
+
const key = `bulk-${index}`
|
|
223
|
+
await strategy.set(key, { index }, { ttl: 60_000, tags: ['rbac:tenant:t1'] })
|
|
224
|
+
currentRedis.expire(`cache:${key}`)
|
|
225
|
+
}
|
|
226
|
+
expect((await currentRedis.smembers('tag:rbac:tenant:t1')).length).toBe(keyCount)
|
|
227
|
+
|
|
228
|
+
expect(await strategy.deleteByTags(['rbac:tenant:t1'])).toBe(0)
|
|
229
|
+
expect(await currentRedis.smembers('tag:rbac:tenant:t1')).toEqual([])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('keeps reap work linear in real memberships across disjoint tags', async () => {
|
|
233
|
+
const tagCount = 10
|
|
234
|
+
const perTag = 10
|
|
235
|
+
for (let tag = 0; tag < tagCount; tag++) {
|
|
236
|
+
for (let index = 0; index < perTag; index++) {
|
|
237
|
+
const key = `t${tag}-k${index}`
|
|
238
|
+
await strategy.set(key, { index }, { ttl: 60_000, tags: [`tag-${tag}`] })
|
|
239
|
+
currentRedis.expire(`cache:${key}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
currentRedis.evalCalls = []
|
|
244
|
+
await strategy.deleteByTags(Array.from({ length: tagCount }, (_, tag) => `tag-${tag}`))
|
|
245
|
+
|
|
246
|
+
// Pairing every orphan with every requested tag would make this tagCount
|
|
247
|
+
// times larger — 1000 removals to clear 100 real memberships.
|
|
248
|
+
const removals = currentRedis.evalCalls.reduce((total, call) => total + call.memberCount, 0)
|
|
249
|
+
expect(removals).toBe(tagCount * perTag)
|
|
250
|
+
|
|
251
|
+
// No single atomic call may exceed the chunk bound, whatever the tag count.
|
|
252
|
+
for (const call of currentRedis.evalCalls) {
|
|
253
|
+
expect(call.memberCount).toBeLessThanOrEqual(256)
|
|
254
|
+
expect(call.numKeys).toBeLessThanOrEqual(256 + 1)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (let tag = 0; tag < tagCount; tag++) {
|
|
258
|
+
expect(await currentRedis.smembers(`tag:tag-${tag}`)).toEqual([])
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('only reaps orphans from the tags it was asked to invalidate', async () => {
|
|
263
|
+
await strategy.set('key-1', { value: 1 }, { ttl: 60_000, tags: ['tag-a', 'tag-b'] })
|
|
264
|
+
currentRedis.expire('cache:key-1')
|
|
265
|
+
|
|
266
|
+
await strategy.deleteByTags(['tag-a'])
|
|
267
|
+
|
|
268
|
+
expect(await currentRedis.smembers('tag:tag-a')).toEqual([])
|
|
269
|
+
expect(await currentRedis.smembers('tag:tag-b')).toEqual(['key-1'])
|
|
270
|
+
})
|
|
271
|
+
})
|
|
@@ -181,6 +181,33 @@ describe('Cache Service', () => {
|
|
|
181
181
|
}
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
it('uses SHA-256 identifiers for scoped storage keys and tags', async () => {
|
|
185
|
+
const root = fs.mkdtempSync(path.join(process.cwd(), '.cache-hash-'))
|
|
186
|
+
const jsonFilePath = path.join(root, 'cache.json')
|
|
187
|
+
try {
|
|
188
|
+
const cache = createCacheService({ strategy: 'jsonfile', jsonFilePath })
|
|
189
|
+
await runWithCacheTenant('tenant-a', async () => {
|
|
190
|
+
await cache.set('customer:user-123', 'value', { tags: ['customer:user-123'] })
|
|
191
|
+
})
|
|
192
|
+
await cache.close?.()
|
|
193
|
+
|
|
194
|
+
const storage = JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')) as {
|
|
195
|
+
entries: Record<string, unknown>
|
|
196
|
+
tagIndex: Record<string, string[]>
|
|
197
|
+
}
|
|
198
|
+
expect(Object.keys(storage.entries)).toHaveLength(2)
|
|
199
|
+
expect(Object.keys(storage.entries)).toEqual(expect.arrayContaining([
|
|
200
|
+
expect.stringMatching(/^tenant:tenant-a:key:k:[0-9a-f]{64}$/),
|
|
201
|
+
expect.stringMatching(/^tenant:tenant-a:key:meta:[0-9a-f]{64}$/),
|
|
202
|
+
]))
|
|
203
|
+
expect(Object.keys(storage.tagIndex)).toEqual(expect.arrayContaining([
|
|
204
|
+
expect.stringMatching(/^tenant:tenant-a:tag:t:[0-9a-f]{64}$/),
|
|
205
|
+
]))
|
|
206
|
+
} finally {
|
|
207
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
184
211
|
it('should handle concurrent operations', async () => {
|
|
185
212
|
const cache = createCacheService()
|
|
186
213
|
|
package/src/service.ts
CHANGED
|
@@ -71,7 +71,7 @@ function resolveTenantPrefixes(): TenantPrefixes {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function hashIdentifier(input: string): string {
|
|
74
|
-
return createHash('
|
|
74
|
+
return createHash('sha256').update(input).digest('hex')
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function storageKey(originalKey: string, prefixes: TenantPrefixes): string {
|
package/src/strategies/redis.ts
CHANGED
|
@@ -9,6 +9,7 @@ type RedisPipeline = {
|
|
|
9
9
|
sadd(key: string, value: string): RedisPipeline
|
|
10
10
|
srem(key: string, member: string): RedisPipeline
|
|
11
11
|
del(key: string): RedisPipeline
|
|
12
|
+
eval(script: string, numKeys: number, ...args: (string | number)[]): RedisPipeline
|
|
12
13
|
exec(): Promise<unknown>
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -31,6 +32,37 @@ type PossibleRedisModule = unknown
|
|
|
31
32
|
|
|
32
33
|
type RequireFn = (id: string) => unknown
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Reaps orphaned members from ONE tag set atomically.
|
|
37
|
+
*
|
|
38
|
+
* `KEYS[1]` is the tag key and `KEYS[2..]` are the candidate value keys, with
|
|
39
|
+
* `ARGV[1..]` the member names aligned to them. Every key the script touches is
|
|
40
|
+
* declared in `KEYS` so it stays valid against a slot-aware deployment.
|
|
41
|
+
*
|
|
42
|
+
* The `EXISTS` check and the `SREM` run in one atomic step, which is the whole
|
|
43
|
+
* point: a member may only be dropped while its value key is genuinely absent.
|
|
44
|
+
* A concurrent `set()` either lands first (`EXISTS` is 1, so the member is left
|
|
45
|
+
* alone) or lands after (its own `sadd` re-adds the member). Either ordering
|
|
46
|
+
* leaves a live key indexed.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately single-tag. Pairing every orphan with every requested tag would
|
|
49
|
+
* make the work quadratic in the tag count — and because a script blocks the
|
|
50
|
+
* server for its whole run, that lands as a stall rather than as throughput.
|
|
51
|
+
* Callers reap per tag using the membership each `smembers` actually returned.
|
|
52
|
+
*/
|
|
53
|
+
const REAP_ORPHANED_TAG_MEMBERS = `
|
|
54
|
+
local removed = 0
|
|
55
|
+
for i = 2, #KEYS do
|
|
56
|
+
if redis.call('EXISTS', KEYS[i]) == 0 then
|
|
57
|
+
removed = removed + redis.call('SREM', KEYS[1], ARGV[i - 1])
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
return removed
|
|
61
|
+
`
|
|
62
|
+
|
|
63
|
+
/** Bounds a single script's blocking time on the server, and the request size. */
|
|
64
|
+
const REAP_CHUNK_SIZE = 256
|
|
65
|
+
|
|
34
66
|
/**
|
|
35
67
|
* Redis cache strategy with tag support
|
|
36
68
|
* Persistent across process restarts, can be shared across multiple instances
|
|
@@ -313,11 +345,15 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
313
345
|
const deleteByTags = async (tags: string[]): Promise<number> => {
|
|
314
346
|
const client = await getRedisClient()
|
|
315
347
|
const keysToDelete = new Set<string>()
|
|
348
|
+
// Keep which tag each key came from. Reaping needs it to stay linear in the
|
|
349
|
+
// memberships that actually exist rather than in tags x keys.
|
|
350
|
+
const membersByTagKey = new Map<string, string[]>()
|
|
316
351
|
|
|
317
352
|
// Collect all keys that have any of the specified tags
|
|
318
353
|
for (const tag of tags) {
|
|
319
354
|
const tagKey = getTagKey(tag)
|
|
320
355
|
const keys = await client.smembers(tagKey)
|
|
356
|
+
membersByTagKey.set(tagKey, keys)
|
|
321
357
|
for (const key of keys) {
|
|
322
358
|
keysToDelete.add(key)
|
|
323
359
|
}
|
|
@@ -325,9 +361,45 @@ export function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?:
|
|
|
325
361
|
|
|
326
362
|
// Delete all collected keys
|
|
327
363
|
let deleted = 0
|
|
364
|
+
const orphans = new Set<string>()
|
|
328
365
|
for (const key of keysToDelete) {
|
|
329
366
|
const success = await deleteKey(key)
|
|
330
367
|
if (success) deleted++
|
|
368
|
+
else orphans.add(key)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A key whose value has already expired leaves its name behind in every tag
|
|
372
|
+
// set it was added to: `set()` only prunes tags when it overwrites a live
|
|
373
|
+
// key, `deleteKey` only prunes when it finds one, and `cleanup()` scans
|
|
374
|
+
// value keys rather than tag sets. Nothing else reaps them, so the sets grow
|
|
375
|
+
// without bound whenever TTL'd entries also rotate their key names — and
|
|
376
|
+
// every later invalidation pays a GET per orphan. Reap what we just walked.
|
|
377
|
+
//
|
|
378
|
+
// `deleteKey` reported these missing some time ago — the walk above is one
|
|
379
|
+
// round trip per member — so a concurrent request may have rebuilt any of
|
|
380
|
+
// them since. Re-check each value key atomically with the removal rather
|
|
381
|
+
// than trusting that stale read: dropping a live key from the index would
|
|
382
|
+
// silently exempt it from every future invalidation of this tag.
|
|
383
|
+
//
|
|
384
|
+
// Reap per tag, over that tag's own members, so the work stays linear in the
|
|
385
|
+
// memberships `smembers` actually returned. Each script is capped at
|
|
386
|
+
// REAP_CHUNK_SIZE members, so no single atomic call can stall the server for
|
|
387
|
+
// longer than a fixed bound however many tags were requested. The calls are
|
|
388
|
+
// pipelined into one round trip, but stay separate scripts so Redis can
|
|
389
|
+
// serve other clients between them.
|
|
390
|
+
if (orphans.size > 0) {
|
|
391
|
+
const pipeline = client.pipeline()
|
|
392
|
+
let queued = 0
|
|
393
|
+
for (const [tagKey, members] of membersByTagKey) {
|
|
394
|
+
const tagOrphans = members.filter((member) => orphans.has(member))
|
|
395
|
+
for (let offset = 0; offset < tagOrphans.length; offset += REAP_CHUNK_SIZE) {
|
|
396
|
+
const chunk = tagOrphans.slice(offset, offset + REAP_CHUNK_SIZE)
|
|
397
|
+
const keys = [tagKey, ...chunk.map(getCacheKey)]
|
|
398
|
+
pipeline.eval(REAP_ORPHANED_TAG_MEMBERS, keys.length, ...keys, ...chunk)
|
|
399
|
+
queued++
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (queued > 0) await pipeline.exec()
|
|
331
403
|
}
|
|
332
404
|
|
|
333
405
|
return deleted
|