@open-mercato/cache 0.6.7-develop.6567.1.g5807007168 → 0.6.7-develop.6568.1.9f57ffc393
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/dist/service.js +45 -5
- package/dist/service.js.map +2 -2
- package/package.json +1 -1
- package/src/__tests__/service.test.ts +67 -1
- package/src/service.ts +84 -20
package/dist/service.js
CHANGED
|
@@ -21,10 +21,16 @@ function isCacheMetadata(value) {
|
|
|
21
21
|
return hasValidKey && hasValidExpiresAt;
|
|
22
22
|
}
|
|
23
23
|
const KNOWN_STRATEGIES = ["memory", "redis", "sqlite", "jsonfile"];
|
|
24
|
+
const TENANT_META_KEY_PATTERN = "tenant:*:key:meta:*";
|
|
25
|
+
const TENANT_META_KEY_MARKER = ":key:meta:";
|
|
24
26
|
function isCacheStrategyName(value) {
|
|
25
27
|
if (!value) return false;
|
|
26
28
|
return KNOWN_STRATEGIES.includes(value);
|
|
27
29
|
}
|
|
30
|
+
function resolveConfiguredStrategyType(options) {
|
|
31
|
+
const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY) ? process.env.CACHE_STRATEGY : void 0;
|
|
32
|
+
return options?.strategy ?? envStrategy ?? "memory";
|
|
33
|
+
}
|
|
28
34
|
function resolveTenantPrefixes() {
|
|
29
35
|
const tenant = normalizeTenantKey(getCurrentCacheTenant());
|
|
30
36
|
const base = `tenant:${tenant}:`;
|
|
@@ -116,6 +122,27 @@ function createTenantAwareWrapper(base, reportsBoundedCounters) {
|
|
|
116
122
|
}
|
|
117
123
|
return originals;
|
|
118
124
|
};
|
|
125
|
+
const deleteByPatternsAcrossScopes = async (patterns) => {
|
|
126
|
+
const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)));
|
|
127
|
+
if (normalizedPatterns.length === 0) return 0;
|
|
128
|
+
const metaKeys = await base.keys(TENANT_META_KEY_PATTERN);
|
|
129
|
+
let deleted = 0;
|
|
130
|
+
for (const metadataKey of metaKeys) {
|
|
131
|
+
const metadataValue = await base.get(metadataKey, { returnExpired: true });
|
|
132
|
+
if (!metadataValue) continue;
|
|
133
|
+
const metadata = typeof metadataValue === "string" ? null : isCacheMetadata(metadataValue) ? metadataValue : null;
|
|
134
|
+
const originalKey = typeof metadataValue === "string" ? metadataValue : metadata?.key;
|
|
135
|
+
if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER);
|
|
139
|
+
if (markerIndex < 0) continue;
|
|
140
|
+
const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`;
|
|
141
|
+
if (await base.delete(primaryKey)) deleted += 1;
|
|
142
|
+
await base.delete(metadataKey);
|
|
143
|
+
}
|
|
144
|
+
return deleted;
|
|
145
|
+
};
|
|
119
146
|
const stats = async () => {
|
|
120
147
|
const prefixes = resolveTenantPrefixes();
|
|
121
148
|
const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`);
|
|
@@ -152,12 +179,12 @@ function createTenantAwareWrapper(base, reportsBoundedCounters) {
|
|
|
152
179
|
stats,
|
|
153
180
|
healthcheck,
|
|
154
181
|
cleanup,
|
|
155
|
-
close
|
|
182
|
+
close,
|
|
183
|
+
deleteByPatternsAcrossScopes
|
|
156
184
|
};
|
|
157
185
|
}
|
|
158
|
-
function
|
|
159
|
-
const
|
|
160
|
-
const strategyType = options?.strategy ?? envStrategy ?? "memory";
|
|
186
|
+
function createConfiguredTenantAwareStrategy(options) {
|
|
187
|
+
const strategyType = resolveConfiguredStrategyType(options);
|
|
161
188
|
const envTtl = process.env.CACHE_TTL;
|
|
162
189
|
const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : void 0;
|
|
163
190
|
const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === "number" && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : void 0);
|
|
@@ -169,6 +196,18 @@ function createCacheService(options) {
|
|
|
169
196
|
const reportsBoundedCounters = strategyType === "memory";
|
|
170
197
|
return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters);
|
|
171
198
|
}
|
|
199
|
+
function createCacheService(options) {
|
|
200
|
+
return createConfiguredTenantAwareStrategy(options);
|
|
201
|
+
}
|
|
202
|
+
async function purgeConfiguredCachePatternsAcrossTenantScopes(patterns, options) {
|
|
203
|
+
if (resolveConfiguredStrategyType(options) === "memory") return 0;
|
|
204
|
+
const cache = createConfiguredTenantAwareStrategy(options);
|
|
205
|
+
try {
|
|
206
|
+
return await cache.deleteByPatternsAcrossScopes(patterns);
|
|
207
|
+
} finally {
|
|
208
|
+
await cache.close?.();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
172
211
|
class CacheService {
|
|
173
212
|
constructor(options) {
|
|
174
213
|
this.strategy = createCacheService(options);
|
|
@@ -293,6 +332,7 @@ function withDependencyFallback(strategy, strategyType, defaultTtl, maxEntries)
|
|
|
293
332
|
}
|
|
294
333
|
export {
|
|
295
334
|
CacheService,
|
|
296
|
-
createCacheService
|
|
335
|
+
createCacheService,
|
|
336
|
+
purgeConfiguredCachePatternsAcrossTenantScopes
|
|
297
337
|
};
|
|
298
338
|
//# sourceMappingURL=service.js.map
|
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']\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 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): CacheStrategy {\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 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 }\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 const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)\n ? process.env.CACHE_STRATEGY\n : undefined\n const strategyType: CacheStrategyName = options?.strategy ?? envStrategy ?? 'memory'\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 * 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;
|
|
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": "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",
|
|
6
6
|
"names": ["metaKey"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createCacheService,
|
|
3
|
+
CacheService,
|
|
4
|
+
purgeConfiguredCachePatternsAcrossTenantScopes,
|
|
5
|
+
} from '../service'
|
|
2
6
|
import fs from 'node:fs'
|
|
3
7
|
import path from 'node:path'
|
|
4
8
|
import { DEFAULT_JSON_FILE_CACHE_PATH, DEFAULT_SQLITE_CACHE_PATH } from '../defaults'
|
|
5
9
|
import type { CacheStrategy } from '../types'
|
|
10
|
+
import { runWithCacheTenant } from '../tenantContext'
|
|
6
11
|
import * as memoryStrategyModule from '../strategies/memory'
|
|
7
12
|
import * as sqliteStrategyModule from '../strategies/sqlite'
|
|
8
13
|
|
|
@@ -115,6 +120,67 @@ describe('Cache Service', () => {
|
|
|
115
120
|
})
|
|
116
121
|
|
|
117
122
|
describe('Complex scenarios', () => {
|
|
123
|
+
it('does not allocate a throwaway cache for cross-process memory maintenance', async () => {
|
|
124
|
+
const createMemorySpy = jest.spyOn(memoryStrategyModule, 'createMemoryStrategy')
|
|
125
|
+
await expect(
|
|
126
|
+
purgeConfiguredCachePatternsAcrossTenantScopes(['nav:*'], { strategy: 'memory' }),
|
|
127
|
+
).resolves.toBe(0)
|
|
128
|
+
expect(createMemorySpy).not.toHaveBeenCalled()
|
|
129
|
+
createMemorySpy.mockRestore()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('purges matching logical keys across tenant scopes without a tenant database', async () => {
|
|
133
|
+
const root = fs.mkdtempSync(path.join(process.cwd(), '.cache-maintenance-'))
|
|
134
|
+
const jsonFilePath = path.join(root, 'cache.json')
|
|
135
|
+
const options = { strategy: 'jsonfile' as const, jsonFilePath }
|
|
136
|
+
try {
|
|
137
|
+
const cache = createCacheService(options)
|
|
138
|
+
await runWithCacheTenant(null, async () => {
|
|
139
|
+
await cache.set('nav:sidebar:global', 'global', { tags: ['nav'] })
|
|
140
|
+
})
|
|
141
|
+
await runWithCacheTenant('tenant-a', async () => {
|
|
142
|
+
await cache.set('nav:sidebar:tenant-a', 'tenant-a', { tags: ['nav'] })
|
|
143
|
+
await cache.set('crud|auth|GET|/api/auth/admin/nav|user-a', 'admin', { tags: ['auth'] })
|
|
144
|
+
await cache.set('crud|auth|GET|/api/auth/admin/naval|user-a', 'not-admin-nav')
|
|
145
|
+
await cache.set('unrelated:key', 'keep', { tags: ['unrelated'] })
|
|
146
|
+
})
|
|
147
|
+
await runWithCacheTenant('tenant-b', async () => {
|
|
148
|
+
await cache.set('crud|customer_accounts|GET|/api/customer_accounts/portal/nav|user-b', 'portal')
|
|
149
|
+
})
|
|
150
|
+
await cache.close?.()
|
|
151
|
+
|
|
152
|
+
const deleted = await purgeConfiguredCachePatternsAcrossTenantScopes(
|
|
153
|
+
['nav:*', 'crud|*|*|*/admin/nav|*', 'crud|*|*|*/portal/nav|*'],
|
|
154
|
+
options,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(deleted).toBe(4)
|
|
158
|
+
const verificationCache = createCacheService(options)
|
|
159
|
+
await runWithCacheTenant(null, async () => {
|
|
160
|
+
expect(await verificationCache.get('nav:sidebar:global')).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
await runWithCacheTenant('tenant-a', async () => {
|
|
163
|
+
expect(await verificationCache.get('nav:sidebar:tenant-a')).toBeNull()
|
|
164
|
+
expect(await verificationCache.get('crud|auth|GET|/api/auth/admin/nav|user-a')).toBeNull()
|
|
165
|
+
expect(await verificationCache.get('crud|auth|GET|/api/auth/admin/naval|user-a')).toBe('not-admin-nav')
|
|
166
|
+
expect(await verificationCache.get('unrelated:key')).toBe('keep')
|
|
167
|
+
})
|
|
168
|
+
await runWithCacheTenant('tenant-b', async () => {
|
|
169
|
+
expect(await verificationCache.get('crud|customer_accounts|GET|/api/customer_accounts/portal/nav|user-b')).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
await verificationCache.close?.()
|
|
172
|
+
|
|
173
|
+
const storage = JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')) as {
|
|
174
|
+
entries: Record<string, unknown>
|
|
175
|
+
tagIndex: Record<string, string[]>
|
|
176
|
+
}
|
|
177
|
+
expect(Object.keys(storage.entries)).toHaveLength(4)
|
|
178
|
+
expect(Object.values(storage.tagIndex).every((keys) => keys.every((key) => key in storage.entries))).toBe(true)
|
|
179
|
+
} finally {
|
|
180
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
118
184
|
it('should handle concurrent operations', async () => {
|
|
119
185
|
const cache = createCacheService()
|
|
120
186
|
|
package/src/service.ts
CHANGED
|
@@ -41,12 +41,25 @@ function isCacheMetadata(value: CacheValue | null): value is CacheMetadata {
|
|
|
41
41
|
|
|
42
42
|
type CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>
|
|
43
43
|
const KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']
|
|
44
|
+
const TENANT_META_KEY_PATTERN = 'tenant:*:key:meta:*'
|
|
45
|
+
const TENANT_META_KEY_MARKER = ':key:meta:'
|
|
46
|
+
|
|
47
|
+
type TenantAwareCacheStrategy = CacheStrategy & {
|
|
48
|
+
deleteByPatternsAcrossScopes(patterns: string[]): Promise<number>
|
|
49
|
+
}
|
|
44
50
|
|
|
45
51
|
function isCacheStrategyName(value: string | undefined): value is CacheStrategyName {
|
|
46
52
|
if (!value) return false
|
|
47
53
|
return KNOWN_STRATEGIES.includes(value as CacheStrategyName)
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
function resolveConfiguredStrategyType(options?: CacheServiceOptions): CacheStrategyName {
|
|
57
|
+
const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)
|
|
58
|
+
? process.env.CACHE_STRATEGY
|
|
59
|
+
: undefined
|
|
60
|
+
return options?.strategy ?? envStrategy ?? 'memory'
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
function resolveTenantPrefixes(): TenantPrefixes {
|
|
51
64
|
const tenant = normalizeTenantKey(getCurrentCacheTenant())
|
|
52
65
|
const base = `tenant:${tenant}:`
|
|
@@ -84,7 +97,7 @@ function buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, inclu
|
|
|
84
97
|
return Array.from(scoped)
|
|
85
98
|
}
|
|
86
99
|
|
|
87
|
-
function createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean):
|
|
100
|
+
function createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean): TenantAwareCacheStrategy {
|
|
88
101
|
function normalizeDeletionCount(raw: number): number {
|
|
89
102
|
if (!raw) return raw
|
|
90
103
|
if (!Number.isFinite(raw)) return raw
|
|
@@ -154,6 +167,32 @@ function createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: b
|
|
|
154
167
|
return originals
|
|
155
168
|
}
|
|
156
169
|
|
|
170
|
+
const deleteByPatternsAcrossScopes = async (patterns: string[]) => {
|
|
171
|
+
const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)))
|
|
172
|
+
if (normalizedPatterns.length === 0) return 0
|
|
173
|
+
|
|
174
|
+
const metaKeys = await base.keys(TENANT_META_KEY_PATTERN)
|
|
175
|
+
let deleted = 0
|
|
176
|
+
for (const metadataKey of metaKeys) {
|
|
177
|
+
const metadataValue = await base.get(metadataKey, { returnExpired: true })
|
|
178
|
+
if (!metadataValue) continue
|
|
179
|
+
const metadata = typeof metadataValue === 'string'
|
|
180
|
+
? null
|
|
181
|
+
: (isCacheMetadata(metadataValue) ? metadataValue : null)
|
|
182
|
+
const originalKey = typeof metadataValue === 'string' ? metadataValue : metadata?.key
|
|
183
|
+
if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER)
|
|
188
|
+
if (markerIndex < 0) continue
|
|
189
|
+
const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`
|
|
190
|
+
if (await base.delete(primaryKey)) deleted += 1
|
|
191
|
+
await base.delete(metadataKey)
|
|
192
|
+
}
|
|
193
|
+
return deleted
|
|
194
|
+
}
|
|
195
|
+
|
|
157
196
|
const stats = async () => {
|
|
158
197
|
const prefixes = resolveTenantPrefixes()
|
|
159
198
|
const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)
|
|
@@ -206,19 +245,38 @@ function createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: b
|
|
|
206
245
|
healthcheck,
|
|
207
246
|
cleanup,
|
|
208
247
|
close,
|
|
248
|
+
deleteByPatternsAcrossScopes,
|
|
209
249
|
}
|
|
210
250
|
}
|
|
211
251
|
|
|
252
|
+
function createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): TenantAwareCacheStrategy {
|
|
253
|
+
const strategyType = resolveConfiguredStrategyType(options)
|
|
254
|
+
|
|
255
|
+
const envTtl = process.env.CACHE_TTL
|
|
256
|
+
const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined
|
|
257
|
+
const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)
|
|
258
|
+
|
|
259
|
+
const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES
|
|
260
|
+
const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined
|
|
261
|
+
const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)
|
|
262
|
+
|
|
263
|
+
const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)
|
|
264
|
+
const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)
|
|
265
|
+
const reportsBoundedCounters = strategyType === 'memory'
|
|
266
|
+
|
|
267
|
+
return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters)
|
|
268
|
+
}
|
|
269
|
+
|
|
212
270
|
/**
|
|
213
271
|
* Cache service that provides a unified interface to different cache strategies
|
|
214
|
-
*
|
|
272
|
+
*
|
|
215
273
|
* Configuration via environment variables:
|
|
216
274
|
* - CACHE_STRATEGY: 'memory' | 'redis' | 'sqlite' | 'jsonfile' (default: 'memory')
|
|
217
275
|
* - CACHE_TTL: Default TTL in milliseconds (optional)
|
|
218
276
|
* - CACHE_REDIS_URL: Redis connection URL (for redis strategy)
|
|
219
277
|
* - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)
|
|
220
278
|
* - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)
|
|
221
|
-
*
|
|
279
|
+
*
|
|
222
280
|
* @example
|
|
223
281
|
* const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })
|
|
224
282
|
* await cache.set('user:123', { name: 'John' }, { tags: ['users', 'user:123'] })
|
|
@@ -226,24 +284,30 @@ function createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: b
|
|
|
226
284
|
* await cache.deleteByTags(['users']) // Invalidate all user-related cache
|
|
227
285
|
*/
|
|
228
286
|
export function createCacheService(options?: CacheServiceOptions): CacheStrategy {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
: undefined
|
|
232
|
-
const strategyType: CacheStrategyName = options?.strategy ?? envStrategy ?? 'memory'
|
|
233
|
-
|
|
234
|
-
const envTtl = process.env.CACHE_TTL
|
|
235
|
-
const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined
|
|
236
|
-
const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)
|
|
237
|
-
|
|
238
|
-
const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES
|
|
239
|
-
const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined
|
|
240
|
-
const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)
|
|
241
|
-
|
|
242
|
-
const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)
|
|
243
|
-
const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)
|
|
244
|
-
const reportsBoundedCounters = strategyType === 'memory'
|
|
287
|
+
return createConfiguredTenantAwareStrategy(options)
|
|
288
|
+
}
|
|
245
289
|
|
|
246
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Delete matching logical keys across every tenant scope in the configured
|
|
292
|
+
* stock cache backend without bootstrapping application DI or querying the
|
|
293
|
+
* tenant database. Intended for maintenance tasks that already operate on all
|
|
294
|
+
* scopes, such as post-generation structural invalidation.
|
|
295
|
+
*/
|
|
296
|
+
export async function purgeConfiguredCachePatternsAcrossTenantScopes(
|
|
297
|
+
patterns: string[],
|
|
298
|
+
options?: CacheServiceOptions,
|
|
299
|
+
): Promise<number> {
|
|
300
|
+
// A newly-created memory strategy cannot reach the running app's process-local
|
|
301
|
+
// cache, so constructing one here would only allocate and immediately discard
|
|
302
|
+
// an empty store. Persistent stock backends remain cross-process maintainable.
|
|
303
|
+
if (resolveConfiguredStrategyType(options) === 'memory') return 0
|
|
304
|
+
|
|
305
|
+
const cache = createConfiguredTenantAwareStrategy(options)
|
|
306
|
+
try {
|
|
307
|
+
return await cache.deleteByPatternsAcrossScopes(patterns)
|
|
308
|
+
} finally {
|
|
309
|
+
await cache.close?.()
|
|
310
|
+
}
|
|
247
311
|
}
|
|
248
312
|
|
|
249
313
|
/**
|