@open-mercato/cache 0.6.6-develop.5654.1.ca21e35f26 → 0.6.6-develop.5675.1.d585628349

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 CHANGED
@@ -10,6 +10,15 @@ Use `@open-mercato/cache` for all caching needs. MUST NOT use raw Redis, SQLite,
10
10
  | SQLite | Use for single-server production deployments; local persistent convenience cache, tuned with WAL/`synchronous=NORMAL` | `CACHE_STRATEGY=sqlite` |
11
11
  | Redis | Use for multi-server production or latency-sensitive request paths with frequent cache writes | `CACHE_STRATEGY=redis` |
12
12
 
13
+ ## Memory Strategy Bounds
14
+
15
+ The memory strategy is bounded so a process-shared instance (`OM_BOOTSTRAP_CACHE`, long-lived workers, memory-backed CRUD list cache) cannot grow without limit on user-controllable key cardinality.
16
+
17
+ - **LRU cap** — at most `maxEntries` entries are retained (default `50000`). Reads refresh recency (Map re-insertion); the least-recently-used entries are evicted on write once the cap is exceeded.
18
+ - **`CACHE_MEMORY_MAX_ENTRIES`** — env override for the cap, resolved in the cache service. `.env.example` carries a commented `#CACHE_MEMORY_MAX_ENTRIES=50000`. A non-positive value (or an unparseable one — which is ignored, falling back to the default) disables the cap (**unbounded** — only safe for short-lived per-request instances).
19
+ - **Amortized expired-entry sweep** — expired entries are reclaimed by a budgeted sweep that runs every 256 writes (no per-instance timer, so the per-request default stays leak-free). Each pass scans a bounded slice from the LRU head, so it stays `O(budget)` rather than scanning the whole store. Expired entries beyond the budget are still reclaimed on access, by LRU eviction, or via an explicit `cleanup()`.
20
+ - **Observability** — `stats()` on a memory-backed service surfaces `evictions`, `sweeps`, and `lastSweepReclaimed` (process-global counters) alongside the tenant-scoped `size`/`expired`, so operators can tell whether the bound is actively protecting the process. Other backends omit these fields.
21
+
13
22
  ## Always
14
23
 
15
24
  1. **MUST resolve via DI** — always use `container.resolve('cacheService')`, never instantiate cache directly
package/dist/service.js CHANGED
@@ -132,7 +132,8 @@ function createTenantAwareWrapper(base) {
132
132
  const expiresAt = metadata?.expiresAt ?? null;
133
133
  if (expiresAt !== null && expiresAt <= now) expired++;
134
134
  }
135
- return { size, expired };
135
+ const { evictions, sweeps, lastSweepReclaimed } = await base.stats();
136
+ return { size, expired, evictions, sweeps, lastSweepReclaimed };
136
137
  };
137
138
  const cleanup = base.cleanup ? async () => normalizeDeletionCount(await base.cleanup()) : void 0;
138
139
  const close = base.close ? async () => base.close() : void 0;
@@ -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): 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 return { size, expired }\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\n return createTenantAwareWrapper(resilientStrategy)\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<{ size: number; expired: number }> {\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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",
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): 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 // surface the underlying strategy's process-global bound counters too so\n // operators reading the DI cacheService can see LRU/sweep activity.\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\n return createTenantAwareWrapper(resilientStrategy)\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
  }
@@ -1,5 +1,6 @@
1
1
  import { matchCacheKeyPattern } from "../patterns.js";
2
2
  const EXPIRED_SWEEP_WRITE_INTERVAL = 256;
3
+ const EXPIRED_SWEEP_MAX_SCAN = 1e3;
3
4
  const DEFAULT_MEMORY_MAX_ENTRIES = 5e4;
4
5
  function normalizeMaxEntries(raw) {
5
6
  if (raw === void 0) return DEFAULT_MEMORY_MAX_ENTRIES;
@@ -12,6 +13,9 @@ function createMemoryStrategy(options) {
12
13
  const defaultTtl = options?.defaultTtl;
13
14
  const maxEntries = normalizeMaxEntries(options?.maxEntries);
14
15
  let writesSinceSweep = 0;
16
+ let evictions = 0;
17
+ let sweeps = 0;
18
+ let lastSweepReclaimed = 0;
15
19
  function isExpired(entry) {
16
20
  if (entry.expiresAt === null) return false;
17
21
  return Date.now() > entry.expiresAt;
@@ -29,6 +33,7 @@ function createMemoryStrategy(options) {
29
33
  const entry = store.get(oldest);
30
34
  store.delete(oldest);
31
35
  if (entry) removeFromTagIndex(oldest, entry.tags);
36
+ evictions++;
32
37
  }
33
38
  }
34
39
  function cleanupExpiredEntry(key, entry) {
@@ -65,11 +70,18 @@ function createMemoryStrategy(options) {
65
70
  function sweepExpiredIfDue() {
66
71
  if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return;
67
72
  writesSinceSweep = 0;
73
+ sweeps++;
74
+ let scanned = 0;
75
+ let reclaimed = 0;
68
76
  for (const [key, entry] of store.entries()) {
77
+ if (scanned >= EXPIRED_SWEEP_MAX_SCAN) break;
78
+ scanned++;
69
79
  if (isExpired(entry)) {
70
80
  cleanupExpiredEntry(key, entry);
81
+ reclaimed++;
71
82
  }
72
83
  }
84
+ lastSweepReclaimed = reclaimed;
73
85
  }
74
86
  const get = async (key, options2) => {
75
87
  const entry = store.get(key);
@@ -154,7 +166,7 @@ function createMemoryStrategy(options) {
154
166
  expired++;
155
167
  }
156
168
  }
157
- return { size: store.size, expired };
169
+ return { size: store.size, expired, evictions, sweeps, lastSweepReclaimed };
158
170
  };
159
171
  const cleanup = async () => {
160
172
  let removed = 0;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/strategies/memory.ts"],
4
- "sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { matchCacheKeyPattern } from '../patterns'\n\nconst EXPIRED_SWEEP_WRITE_INTERVAL = 256\n\n/**\n * Default upper bound on the number of entries a memory cache retains.\n * Bounds memory for long-lived (process-wide) instances; LRU eviction drops\n * the least-recently-used entries once the cap is exceeded. Override via the\n * `maxEntries` option (or `CACHE_MEMORY_MAX_ENTRIES`); a non-positive value\n * disables the cap (unbounded \u2014 only safe for short-lived instances).\n */\nexport const DEFAULT_MEMORY_MAX_ENTRIES = 50_000\n\nfunction normalizeMaxEntries(raw?: number): number {\n if (raw === undefined) return DEFAULT_MEMORY_MAX_ENTRIES\n if (!Number.isFinite(raw) || raw <= 0) return Number.POSITIVE_INFINITY\n return Math.floor(raw)\n}\n\n/**\n * In-memory cache strategy with tag support.\n * Fast but data is lost when process restarts.\n *\n * Bounded by an LRU cap (`maxEntries`, default {@link DEFAULT_MEMORY_MAX_ENTRIES},\n * env-tunable via `CACHE_MEMORY_MAX_ENTRIES` resolved in the cache service) so a\n * process-shared instance (OM_BOOTSTRAP_CACHE, long-lived workers, memory-backed\n * CRUD list cache) cannot grow without limit on user-controllable key\n * cardinality. Recency is refreshed on read (Map re-insertion), the oldest\n * entries are evicted on write, and expired entries are reclaimed by an\n * amortized sweep every N writes \u2014 no per-instance timer, so the per-request\n * default stays leak-free (a per-instance setInterval would pin every request's\n * Maps for the process lifetime).\n */\nexport function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries?: number }): CacheStrategy {\n const store = new Map<string, CacheEntry>()\n const tagIndex = new Map<string, Set<string>>() // tag -> Set of keys\n const defaultTtl = options?.defaultTtl\n const maxEntries = normalizeMaxEntries(options?.maxEntries)\n let writesSinceSweep = 0\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n // LRU bookkeeping: re-insert on read so the most-recently-used entry moves\n // to the tail (Map preserves insertion order), mirroring the rbacDefaultCache\n // precedent; evictIfNeeded then drops from the head (least-recently-used).\n function touchKey(key: string, entry: CacheEntry): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n store.delete(key)\n store.set(key, entry)\n }\n\n function evictIfNeeded(): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n while (store.size > maxEntries) {\n const oldest = store.keys().next().value\n if (typeof oldest !== 'string') break\n const entry = store.get(oldest)\n store.delete(oldest)\n if (entry) removeFromTagIndex(oldest, entry.tags)\n }\n }\n\n function cleanupExpiredEntry(key: string, entry: CacheEntry): void {\n store.delete(key)\n // Remove from tag index\n for (const tag of entry.tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n function addToTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n if (!tagIndex.has(tag)) {\n tagIndex.set(tag, new Set())\n }\n tagIndex.get(tag)!.add(key)\n }\n }\n\n function removeFromTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n // Amortized reclamation of already-expired entries. Runs every N writes\n // instead of on a timer, keeping the no-shared-state property that makes the\n // per-request default safe. Independent of the LRU cap so expired-but-cold\n // entries are reclaimed even when the store stays under `maxEntries`.\n function sweepExpiredIfDue(): void {\n if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return\n writesSinceSweep = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n }\n }\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const entry = store.get(key)\n if (!entry) return null\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n cleanupExpiredEntry(key, entry)\n return null\n }\n\n touchKey(key, entry)\n return entry.value\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n // Remove old entry from tag index if it exists\n const oldEntry = store.get(key)\n if (oldEntry) {\n removeFromTagIndex(key, oldEntry.tags)\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 store.set(key, entry)\n addToTagIndex(key, tags)\n sweepExpiredIfDue()\n evictIfNeeded()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n return false\n }\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n\n removeFromTagIndex(key, entry.tags)\n return store.delete(key)\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\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 keys = tagIndex.get(tag)\n if (keys) {\n for (const key of keys) {\n keysToDelete.add(key)\n }\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 size = store.size\n store.clear()\n tagIndex.clear()\n return size\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const allKeys = Array.from(store.keys())\n if (!pattern) return allKeys\n return allKeys.filter((key) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n let expired = 0\n for (const entry of store.values()) {\n if (isExpired(entry)) {\n expired++\n }\n }\n return { size: store.size, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n let removed = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n removed++\n }\n }\n return removed\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { matchCacheKeyPattern } from '../patterns'\n\nconst EXPIRED_SWEEP_WRITE_INTERVAL = 256\n\n/**\n * Upper bound on entries scanned by a single amortized sweep pass. Keeps the\n * sweep O(budget) instead of O(store size) so a large, hot store never pays a\n * full-store scan latency spike on the `set()` that crosses the sweep interval.\n * The scan walks from the LRU head, where expired-and-cold entries accumulate;\n * anything beyond the budget is reclaimed on a later sweep, on access, by LRU\n * eviction once the cap is exceeded, or by an explicit `cleanup()`.\n */\nconst EXPIRED_SWEEP_MAX_SCAN = 1_000\n\n/**\n * Default upper bound on the number of entries a memory cache retains.\n * Bounds memory for long-lived (process-wide) instances; LRU eviction drops\n * the least-recently-used entries once the cap is exceeded. Override via the\n * `maxEntries` option (or `CACHE_MEMORY_MAX_ENTRIES`); a non-positive value\n * disables the cap (unbounded \u2014 only safe for short-lived instances).\n */\nexport const DEFAULT_MEMORY_MAX_ENTRIES = 50_000\n\nfunction normalizeMaxEntries(raw?: number): number {\n if (raw === undefined) return DEFAULT_MEMORY_MAX_ENTRIES\n if (!Number.isFinite(raw) || raw <= 0) return Number.POSITIVE_INFINITY\n return Math.floor(raw)\n}\n\n/**\n * In-memory cache strategy with tag support.\n * Fast but data is lost when process restarts.\n *\n * Bounded by an LRU cap (`maxEntries`, default {@link DEFAULT_MEMORY_MAX_ENTRIES},\n * env-tunable via `CACHE_MEMORY_MAX_ENTRIES` resolved in the cache service) so a\n * process-shared instance (OM_BOOTSTRAP_CACHE, long-lived workers, memory-backed\n * CRUD list cache) cannot grow without limit on user-controllable key\n * cardinality. Recency is refreshed on read (Map re-insertion), the oldest\n * entries are evicted on write, and expired entries are reclaimed by an\n * amortized sweep every N writes \u2014 no per-instance timer, so the per-request\n * default stays leak-free (a per-instance setInterval would pin every request's\n * Maps for the process lifetime).\n */\nexport function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries?: number }): CacheStrategy {\n const store = new Map<string, CacheEntry>()\n const tagIndex = new Map<string, Set<string>>() // tag -> Set of keys\n const defaultTtl = options?.defaultTtl\n const maxEntries = normalizeMaxEntries(options?.maxEntries)\n let writesSinceSweep = 0\n // Lightweight observability counters so operators can tell whether the bound\n // is actively protecting the process. Process-global for this instance, not\n // tenant-scoped; surfaced via stats().\n let evictions = 0\n let sweeps = 0\n let lastSweepReclaimed = 0\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n // LRU bookkeeping: re-insert on read so the most-recently-used entry moves\n // to the tail (Map preserves insertion order), mirroring the rbacDefaultCache\n // precedent; evictIfNeeded then drops from the head (least-recently-used).\n function touchKey(key: string, entry: CacheEntry): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n store.delete(key)\n store.set(key, entry)\n }\n\n function evictIfNeeded(): void {\n if (maxEntries === Number.POSITIVE_INFINITY) return\n while (store.size > maxEntries) {\n const oldest = store.keys().next().value\n if (typeof oldest !== 'string') break\n const entry = store.get(oldest)\n store.delete(oldest)\n if (entry) removeFromTagIndex(oldest, entry.tags)\n evictions++\n }\n }\n\n function cleanupExpiredEntry(key: string, entry: CacheEntry): void {\n store.delete(key)\n // Remove from tag index\n for (const tag of entry.tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n function addToTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n if (!tagIndex.has(tag)) {\n tagIndex.set(tag, new Set())\n }\n tagIndex.get(tag)!.add(key)\n }\n }\n\n function removeFromTagIndex(key: string, tags: string[]): void {\n for (const tag of tags) {\n const keys = tagIndex.get(tag)\n if (keys) {\n keys.delete(key)\n if (keys.size === 0) {\n tagIndex.delete(tag)\n }\n }\n }\n }\n\n // Amortized reclamation of already-expired entries. Runs every N writes\n // instead of on a timer, keeping the no-shared-state property that makes the\n // per-request default safe. Independent of the LRU cap so expired-but-cold\n // entries are reclaimed even when the store stays under `maxEntries`. The\n // scan is budgeted (EXPIRED_SWEEP_MAX_SCAN entries from the LRU head per pass)\n // so it stays O(budget) on large stores instead of scanning every entry.\n function sweepExpiredIfDue(): void {\n if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return\n writesSinceSweep = 0\n sweeps++\n let scanned = 0\n let reclaimed = 0\n for (const [key, entry] of store.entries()) {\n if (scanned >= EXPIRED_SWEEP_MAX_SCAN) break\n scanned++\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n reclaimed++\n }\n }\n lastSweepReclaimed = reclaimed\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const entry = store.get(key)\n if (!entry) return null\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n cleanupExpiredEntry(key, entry)\n return null\n }\n\n touchKey(key, entry)\n return entry.value\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n // Remove old entry from tag index if it exists\n const oldEntry = store.get(key)\n if (oldEntry) {\n removeFromTagIndex(key, oldEntry.tags)\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 store.set(key, entry)\n addToTagIndex(key, tags)\n sweepExpiredIfDue()\n evictIfNeeded()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n return false\n }\n return true\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const entry = store.get(key)\n if (!entry) return false\n\n removeFromTagIndex(key, entry.tags)\n return store.delete(key)\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\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 keys = tagIndex.get(tag)\n if (keys) {\n for (const key of keys) {\n keysToDelete.add(key)\n }\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 size = store.size\n store.clear()\n tagIndex.clear()\n return size\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const allKeys = Array.from(store.keys())\n if (!pattern) return allKeys\n return allKeys.filter((key) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{\n size: number\n expired: number\n evictions: number\n sweeps: number\n lastSweepReclaimed: number\n }> => {\n let expired = 0\n for (const entry of store.values()) {\n if (isExpired(entry)) {\n expired++\n }\n }\n return { size: store.size, expired, evictions, sweeps, lastSweepReclaimed }\n }\n\n const cleanup = async (): Promise<number> => {\n let removed = 0\n for (const [key, entry] of store.entries()) {\n if (isExpired(entry)) {\n cleanupExpiredEntry(key, entry)\n removed++\n }\n }\n return removed\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n cleanup,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["keys", "options"]
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cache",
3
- "version": "0.6.6-develop.5654.1.ca21e35f26",
3
+ "version": "0.6.6-develop.5675.1.d585628349",
4
4
  "description": "Multi-strategy cache service with tag-based invalidation support",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -251,11 +251,68 @@ describe('Memory Cache Strategy', () => {
251
251
  await bounded.set(`fresh:${i}`, i)
252
252
  }
253
253
  // The expired cohort was reclaimed; only the 60 fresh entries remain.
254
- expect((await bounded.stats()).size).toBe(60)
254
+ const stats = await bounded.stats()
255
+ expect(stats.size).toBe(60)
256
+ // The firing pass reclaimed the whole 200-entry cohort (under budget).
257
+ expect(stats.sweeps).toBeGreaterThanOrEqual(1)
258
+ expect(stats.lastSweepReclaimed).toBe(200)
255
259
  } finally {
256
260
  jest.useRealTimers()
257
261
  }
258
262
  })
263
+
264
+ it('bounds a single sweep pass instead of scanning the whole store', async () => {
265
+ jest.useFakeTimers()
266
+ jest.setSystemTime(new Date('2025-01-01T00:00:00.000Z'))
267
+ try {
268
+ // A cohort larger than the per-pass scan budget so one sweep cannot
269
+ // reclaim all of it. maxEntries default (50k) keeps LRU eviction out
270
+ // of the picture for this many entries.
271
+ const cohort = 1_200
272
+ const bounded = createMemoryStrategy()
273
+ for (let i = 0; i < cohort; i++) {
274
+ await bounded.set(`expiring:${i}`, i, { ttl: 50 })
275
+ }
276
+ await jest.advanceTimersByTimeAsync(100)
277
+
278
+ // Cross the sweep interval again with fresh (non-expiring) entries.
279
+ for (let i = 0; i < 256; i++) {
280
+ await bounded.set(`fresh:${i}`, i)
281
+ }
282
+
283
+ const stats = await bounded.stats()
284
+ // The budget caps the firing pass, so not every expired entry is gone
285
+ // and the reclaimed count is strictly below the full cohort.
286
+ expect(stats.lastSweepReclaimed).toBeGreaterThan(0)
287
+ expect(stats.lastSweepReclaimed).toBeLessThan(cohort)
288
+ expect(stats.size).toBeGreaterThan(256)
289
+ } finally {
290
+ jest.useRealTimers()
291
+ }
292
+ })
293
+ })
294
+
295
+ describe('Observability counters', () => {
296
+ it('counts LRU evictions via stats()', async () => {
297
+ const bounded = createMemoryStrategy({ maxEntries: 3 })
298
+ await bounded.set('a', 1)
299
+ await bounded.set('b', 2)
300
+ await bounded.set('c', 3)
301
+ expect((await bounded.stats()).evictions).toBe(0)
302
+
303
+ await bounded.set('d', 4) // evicts 'a'
304
+ await bounded.set('e', 5) // evicts 'b'
305
+ expect((await bounded.stats()).evictions).toBe(2)
306
+ })
307
+
308
+ it('reports zero counters for an idle bounded strategy', async () => {
309
+ const bounded = createMemoryStrategy({ maxEntries: 10 })
310
+ await bounded.set('a', 1)
311
+ const stats = await bounded.stats()
312
+ expect(stats.evictions).toBe(0)
313
+ expect(stats.sweeps).toBe(0)
314
+ expect(stats.lastSweepReclaimed).toBe(0)
315
+ })
259
316
  })
260
317
 
261
318
  describe('Statistics', () => {
@@ -199,4 +199,40 @@ describe('Cache Service', () => {
199
199
  expect(stats.expired).toBe(0)
200
200
  })
201
201
  })
202
+
203
+ describe('Memory bound env resolution (CACHE_MEMORY_MAX_ENTRIES)', () => {
204
+ afterEach(() => {
205
+ delete process.env.CACHE_MEMORY_MAX_ENTRIES
206
+ })
207
+
208
+ it('bounds a memory-backed service when the env var is set', async () => {
209
+ process.env.CACHE_MEMORY_MAX_ENTRIES = '10'
210
+ const cache = createCacheService({ strategy: 'memory' })
211
+
212
+ for (let index = 0; index < 30; index += 1) {
213
+ await cache.set(`key:${index}`, index)
214
+ }
215
+
216
+ const stats = await cache.stats()
217
+ // The LRU cap kept the store far below the 30 logical writes and the
218
+ // surfaced eviction counter proves the bound was actively enforced.
219
+ expect(stats.size).toBeLessThan(30)
220
+ expect(stats.evictions).toBeGreaterThan(0)
221
+ })
222
+
223
+ it('ignores an invalid env value and stays unbounded by default', async () => {
224
+ process.env.CACHE_MEMORY_MAX_ENTRIES = 'not-a-number'
225
+ const cache = createCacheService({ strategy: 'memory' })
226
+
227
+ for (let index = 0; index < 30; index += 1) {
228
+ await cache.set(`key:${index}`, index)
229
+ }
230
+
231
+ const stats = await cache.stats()
232
+ // Invalid value parses to NaN, is discarded, and the default cap (50k)
233
+ // leaves all 30 entries resident with no evictions.
234
+ expect(stats.size).toBe(30)
235
+ expect(stats.evictions).toBe(0)
236
+ })
237
+ })
202
238
  })
package/src/service.ts CHANGED
@@ -170,7 +170,11 @@ function createTenantAwareWrapper(base: CacheStrategy): CacheStrategy {
170
170
  const expiresAt = metadata?.expiresAt ?? null
171
171
  if (expiresAt !== null && expiresAt <= now) expired++
172
172
  }
173
- return { size, expired }
173
+ // size/expired stay tenant-scoped (derived from this tenant's meta keys);
174
+ // surface the underlying strategy's process-global bound counters too so
175
+ // operators reading the DI cacheService can see LRU/sweep activity.
176
+ const { evictions, sweeps, lastSweepReclaimed } = await base.stats()
177
+ return { size, expired, evictions, sweeps, lastSweepReclaimed }
174
178
  }
175
179
 
176
180
  const cleanup = base.cleanup
@@ -274,7 +278,13 @@ export class CacheService implements CacheStrategy {
274
278
  return this.strategy.keys(pattern)
275
279
  }
276
280
 
277
- async stats(): Promise<{ size: number; expired: number }> {
281
+ async stats(): Promise<{
282
+ size: number
283
+ expired: number
284
+ evictions?: number
285
+ sweeps?: number
286
+ lastSweepReclaimed?: number
287
+ }> {
278
288
  return this.strategy.stats()
279
289
  }
280
290
 
@@ -3,6 +3,16 @@ import { matchCacheKeyPattern } from '../patterns'
3
3
 
4
4
  const EXPIRED_SWEEP_WRITE_INTERVAL = 256
5
5
 
6
+ /**
7
+ * Upper bound on entries scanned by a single amortized sweep pass. Keeps the
8
+ * sweep O(budget) instead of O(store size) so a large, hot store never pays a
9
+ * full-store scan latency spike on the `set()` that crosses the sweep interval.
10
+ * The scan walks from the LRU head, where expired-and-cold entries accumulate;
11
+ * anything beyond the budget is reclaimed on a later sweep, on access, by LRU
12
+ * eviction once the cap is exceeded, or by an explicit `cleanup()`.
13
+ */
14
+ const EXPIRED_SWEEP_MAX_SCAN = 1_000
15
+
6
16
  /**
7
17
  * Default upper bound on the number of entries a memory cache retains.
8
18
  * Bounds memory for long-lived (process-wide) instances; LRU eviction drops
@@ -38,6 +48,12 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
38
48
  const defaultTtl = options?.defaultTtl
39
49
  const maxEntries = normalizeMaxEntries(options?.maxEntries)
40
50
  let writesSinceSweep = 0
51
+ // Lightweight observability counters so operators can tell whether the bound
52
+ // is actively protecting the process. Process-global for this instance, not
53
+ // tenant-scoped; surfaced via stats().
54
+ let evictions = 0
55
+ let sweeps = 0
56
+ let lastSweepReclaimed = 0
41
57
 
42
58
  function isExpired(entry: CacheEntry): boolean {
43
59
  if (entry.expiresAt === null) return false
@@ -61,6 +77,7 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
61
77
  const entry = store.get(oldest)
62
78
  store.delete(oldest)
63
79
  if (entry) removeFromTagIndex(oldest, entry.tags)
80
+ evictions++
64
81
  }
65
82
  }
66
83
 
@@ -102,15 +119,24 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
102
119
  // Amortized reclamation of already-expired entries. Runs every N writes
103
120
  // instead of on a timer, keeping the no-shared-state property that makes the
104
121
  // per-request default safe. Independent of the LRU cap so expired-but-cold
105
- // entries are reclaimed even when the store stays under `maxEntries`.
122
+ // entries are reclaimed even when the store stays under `maxEntries`. The
123
+ // scan is budgeted (EXPIRED_SWEEP_MAX_SCAN entries from the LRU head per pass)
124
+ // so it stays O(budget) on large stores instead of scanning every entry.
106
125
  function sweepExpiredIfDue(): void {
107
126
  if (++writesSinceSweep < EXPIRED_SWEEP_WRITE_INTERVAL) return
108
127
  writesSinceSweep = 0
128
+ sweeps++
129
+ let scanned = 0
130
+ let reclaimed = 0
109
131
  for (const [key, entry] of store.entries()) {
132
+ if (scanned >= EXPIRED_SWEEP_MAX_SCAN) break
133
+ scanned++
110
134
  if (isExpired(entry)) {
111
135
  cleanupExpiredEntry(key, entry)
136
+ reclaimed++
112
137
  }
113
138
  }
139
+ lastSweepReclaimed = reclaimed
114
140
  }
115
141
 
116
142
  const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
@@ -208,14 +234,20 @@ export function createMemoryStrategy(options?: { defaultTtl?: number; maxEntries
208
234
  return allKeys.filter((key) => matchCacheKeyPattern(key, pattern))
209
235
  }
210
236
 
211
- const stats = async (): Promise<{ size: number; expired: number }> => {
237
+ const stats = async (): Promise<{
238
+ size: number
239
+ expired: number
240
+ evictions: number
241
+ sweeps: number
242
+ lastSweepReclaimed: number
243
+ }> => {
212
244
  let expired = 0
213
245
  for (const entry of store.values()) {
214
246
  if (isExpired(entry)) {
215
247
  expired++
216
248
  }
217
249
  }
218
- return { size: store.size, expired }
250
+ return { size: store.size, expired, evictions, sweeps, lastSweepReclaimed }
219
251
  }
220
252
 
221
253
  const cleanup = async (): Promise<number> => {
package/src/types.ts CHANGED
@@ -5,6 +5,10 @@ export type CacheEntry = {
5
5
  value: CacheValue
6
6
  tags: string[]
7
7
  expiresAt: number | null
8
+ /**
9
+ * Wall-clock creation time. Informational/diagnostic only — LRU recency and
10
+ * eviction rely purely on Map insertion order, not on this field.
11
+ */
8
12
  createdAt: number
9
13
  }
10
14
 
@@ -77,6 +81,12 @@ export type CacheStrategy = {
77
81
  stats(): Promise<{
78
82
  size: number // Total number of entries
79
83
  expired: number // Number of expired entries
84
+ // Optional process-global counters surfaced by bounded strategies (memory)
85
+ // so operators can tell whether the LRU cap / sweep is actively protecting
86
+ // the process. Strategies that do not track them omit these fields.
87
+ evictions?: number // Entries dropped by LRU eviction since process start
88
+ sweeps?: number // Amortized expired-entry sweep passes run since process start
89
+ lastSweepReclaimed?: number // Entries reclaimed by the most recent sweep pass
80
90
  }>
81
91
 
82
92
  /**