@open-mercato/cache 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +10 -0
- package/ENV.md +26 -0
- package/dist/codec.js +75 -0
- package/dist/codec.js.map +7 -0
- package/dist/service.js +9 -4
- package/dist/service.js.map +2 -2
- package/dist/strategies/redis.js +15 -13
- package/dist/strategies/redis.js.map +2 -2
- package/dist/strategies/sqlite.js +4 -2
- package/dist/strategies/sqlite.js.map +2 -2
- package/package.json +1 -1
- package/src/__tests__/codec.test.ts +162 -0
- package/src/__tests__/redis.compression.live.test.ts +41 -0
- package/src/__tests__/redis.strategy.test.ts +109 -9
- package/src/__tests__/service.test.ts +54 -0
- package/src/__tests__/sqlite.strategy.test.ts +53 -2
- package/src/codec.ts +94 -0
- package/src/service.ts +13 -4
- package/src/strategies/redis.ts +22 -18
- package/src/strategies/sqlite.ts +7 -4
- package/src/types.ts +11 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:cache] found
|
|
1
|
+
[build:cache] found 12 entry points
|
|
2
2
|
[build:cache] built successfully
|
package/AGENTS.md
CHANGED
|
@@ -21,6 +21,16 @@ The memory strategy is bounded so a process-shared instance (`OM_BOOTSTRAP_CACHE
|
|
|
21
21
|
- **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()`.
|
|
22
22
|
- **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.
|
|
23
23
|
|
|
24
|
+
## Value Compression
|
|
25
|
+
|
|
26
|
+
The redis and sqlite strategies can compress serialized entries (`packages/cache/src/codec.ts`); memory and jsonfile never do.
|
|
27
|
+
|
|
28
|
+
- **`CACHE_COMPRESSION`** — `off` (default) | `zstd` | `gzip`. zstd runs at level 1, gzip at level 6. A `zstd` setting falls back to `gzip` with one warning on a runtime without zstd (the `node:zlib` zstd API is marked experimental).
|
|
29
|
+
- **`CACHE_COMPRESSION_MIN_BYTES`** — entries smaller than this (default `1024`) stay plain JSON; a compressed form is kept only when it is smaller.
|
|
30
|
+
- **Reads detect the format by magic bytes**, whatever the current setting, so legacy JSON, zstd and gzip entries coexist and switching the setting never strands an entry. Decompression is capped at 64 MiB; a corrupt frame is dropped as a miss.
|
|
31
|
+
- **When to enable:** Redis on another host, or memory-bound Redis — fewer bytes over the network and in memory. Redis on localhost or SQLite saves memory/disk only, at a small read cost.
|
|
32
|
+
- **Rollout:** deploy with compression `off`, enable it only once every instance runs a version with the codec, and set it back to `off` before rolling back to an older version (older versions read compressed entries as misses and delete them).
|
|
33
|
+
|
|
24
34
|
## Always
|
|
25
35
|
|
|
26
36
|
1. **MUST resolve via DI** — always use `container.resolve('cache')`, never instantiate cache directly. The DI token is `cache` (registered in `packages/core/src/bootstrap.ts`); there is no `cacheService` registration.
|
package/ENV.md
CHANGED
|
@@ -75,6 +75,32 @@ CACHE_SQLITE_PATH=./.mercato/cache/cache.db
|
|
|
75
75
|
CACHE_JSON_FILE_PATH=./.mercato/cache/cache.json
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
### `CACHE_COMPRESSION`
|
|
79
|
+
|
|
80
|
+
**Description:** Compresses cache values stored by the `redis` and `sqlite` strategies. Reads always detect the stored format, so entries written under any setting stay readable. Enable it only once every instance runs a version that supports it, and switch it back to `off` before rolling back.
|
|
81
|
+
|
|
82
|
+
**Values:** `off` | `zstd` | `gzip`
|
|
83
|
+
|
|
84
|
+
**Default:** `off`
|
|
85
|
+
|
|
86
|
+
**Example:**
|
|
87
|
+
```bash
|
|
88
|
+
CACHE_COMPRESSION=zstd
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `CACHE_COMPRESSION_MIN_BYTES`
|
|
92
|
+
|
|
93
|
+
**Description:** Smallest serialized entry, in bytes, that gets compressed. Smaller entries stay plain JSON.
|
|
94
|
+
|
|
95
|
+
**Type:** Number (bytes)
|
|
96
|
+
|
|
97
|
+
**Default:** `1024`
|
|
98
|
+
|
|
99
|
+
**Example:**
|
|
100
|
+
```bash
|
|
101
|
+
CACHE_COMPRESSION_MIN_BYTES=1024
|
|
102
|
+
```
|
|
103
|
+
|
|
78
104
|
## Example Configurations
|
|
79
105
|
|
|
80
106
|
### Development (In-Memory)
|
package/dist/codec.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import zlib from "node:zlib";
|
|
2
|
+
const DEFAULT_COMPRESSION_MIN_BYTES = 1024;
|
|
3
|
+
const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
const ZSTD_LEVEL = 1;
|
|
5
|
+
const COMPRESSIONS = ["off", "zstd", "gzip"];
|
|
6
|
+
const warnedMessages = /* @__PURE__ */ new Set();
|
|
7
|
+
function warnOnce(message) {
|
|
8
|
+
if (warnedMessages.has(message)) return;
|
|
9
|
+
warnedMessages.add(message);
|
|
10
|
+
console.warn(message);
|
|
11
|
+
}
|
|
12
|
+
function isZstdAvailable() {
|
|
13
|
+
return typeof zlib.zstdCompressSync === "function" && typeof zlib.zstdDecompressSync === "function";
|
|
14
|
+
}
|
|
15
|
+
function parseCacheCompression(raw) {
|
|
16
|
+
const value = raw?.trim().toLowerCase();
|
|
17
|
+
if (!value) return "off";
|
|
18
|
+
if (COMPRESSIONS.includes(value)) return value;
|
|
19
|
+
warnOnce(`[cache] Unknown CACHE_COMPRESSION value "${raw}". Compression stays off.`);
|
|
20
|
+
return "off";
|
|
21
|
+
}
|
|
22
|
+
function parseCacheCompressionMinBytes(raw) {
|
|
23
|
+
if (!raw) return void 0;
|
|
24
|
+
const parsed = Number.parseInt(raw, 10);
|
|
25
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
26
|
+
}
|
|
27
|
+
function resolveCacheCodecConfig(options) {
|
|
28
|
+
const minBytes = options?.compressionMinBytes && options.compressionMinBytes > 0 ? options.compressionMinBytes : DEFAULT_COMPRESSION_MIN_BYTES;
|
|
29
|
+
let compression = options?.compression ?? "off";
|
|
30
|
+
if (compression === "zstd" && !isZstdAvailable()) {
|
|
31
|
+
warnOnce("[cache] zstd is not available in this Node.js runtime. Falling back to gzip cache compression.");
|
|
32
|
+
compression = "gzip";
|
|
33
|
+
}
|
|
34
|
+
return { compression, minBytes };
|
|
35
|
+
}
|
|
36
|
+
function compress(input, compression) {
|
|
37
|
+
if (compression === "zstd") {
|
|
38
|
+
return zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } });
|
|
39
|
+
}
|
|
40
|
+
return zlib.gzipSync(input);
|
|
41
|
+
}
|
|
42
|
+
function encodeCacheText(json, config) {
|
|
43
|
+
if (config.compression === "off") return json;
|
|
44
|
+
const size = Buffer.byteLength(json);
|
|
45
|
+
if (size < config.minBytes || size > MAX_DECOMPRESSED_BYTES) return json;
|
|
46
|
+
const compressed = compress(Buffer.from(json, "utf8"), config.compression);
|
|
47
|
+
return compressed.length < size ? compressed : json;
|
|
48
|
+
}
|
|
49
|
+
function isZstdFrame(raw) {
|
|
50
|
+
return raw.length >= 4 && raw[0] === 40 && raw[1] === 181 && raw[2] === 47 && raw[3] === 253;
|
|
51
|
+
}
|
|
52
|
+
function isGzipStream(raw) {
|
|
53
|
+
return raw.length >= 2 && raw[0] === 31 && raw[1] === 139;
|
|
54
|
+
}
|
|
55
|
+
function decodeCacheText(raw) {
|
|
56
|
+
if (typeof raw === "string") return raw;
|
|
57
|
+
if (isZstdFrame(raw)) {
|
|
58
|
+
return zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString("utf8");
|
|
59
|
+
}
|
|
60
|
+
if (isGzipStream(raw)) {
|
|
61
|
+
return zlib.gunzipSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString("utf8");
|
|
62
|
+
}
|
|
63
|
+
return raw.toString("utf8");
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
DEFAULT_COMPRESSION_MIN_BYTES,
|
|
67
|
+
MAX_DECOMPRESSED_BYTES,
|
|
68
|
+
decodeCacheText,
|
|
69
|
+
encodeCacheText,
|
|
70
|
+
isZstdAvailable,
|
|
71
|
+
parseCacheCompression,
|
|
72
|
+
parseCacheCompressionMinBytes,
|
|
73
|
+
resolveCacheCodecConfig
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=codec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/codec.ts"],
|
|
4
|
+
"sourcesContent": ["import zlib from 'node:zlib'\n\nexport type CacheCompression = 'off' | 'zstd' | 'gzip'\n\nexport type CacheCodecConfig = {\n compression: CacheCompression\n minBytes: number\n}\n\nexport type CacheCodecOptions = {\n compression?: CacheCompression\n compressionMinBytes?: number\n}\n\nexport const DEFAULT_COMPRESSION_MIN_BYTES = 1024\n\n/** Upper bound for one decompressed entry; larger JSON is never compressed, so no legitimate entry can hit it. */\nexport const MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024\n\nconst ZSTD_LEVEL = 1\nconst COMPRESSIONS: CacheCompression[] = ['off', 'zstd', 'gzip']\n\nconst warnedMessages = new Set<string>()\n\nfunction warnOnce(message: string): void {\n if (warnedMessages.has(message)) return\n warnedMessages.add(message)\n console.warn(message)\n}\n\nexport function isZstdAvailable(): boolean {\n return typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function'\n}\n\nexport function parseCacheCompression(raw: string | undefined): CacheCompression {\n const value = raw?.trim().toLowerCase()\n if (!value) return 'off'\n if (COMPRESSIONS.includes(value as CacheCompression)) return value as CacheCompression\n warnOnce(`[cache] Unknown CACHE_COMPRESSION value \"${raw}\". Compression stays off.`)\n return 'off'\n}\n\nexport function parseCacheCompressionMinBytes(raw: string | undefined): number | undefined {\n if (!raw) return undefined\n const parsed = Number.parseInt(raw, 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined\n}\n\nexport function resolveCacheCodecConfig(options?: CacheCodecOptions): CacheCodecConfig {\n const minBytes = options?.compressionMinBytes && options.compressionMinBytes > 0\n ? options.compressionMinBytes\n : DEFAULT_COMPRESSION_MIN_BYTES\n let compression = options?.compression ?? 'off'\n if (compression === 'zstd' && !isZstdAvailable()) {\n warnOnce('[cache] zstd is not available in this Node.js runtime. Falling back to gzip cache compression.')\n compression = 'gzip'\n }\n return { compression, minBytes }\n}\n\nfunction compress(input: Buffer, compression: Exclude<CacheCompression, 'off'>): Buffer {\n if (compression === 'zstd') {\n return zlib.zstdCompressSync(input, { params: { [zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL } })\n }\n return zlib.gzipSync(input)\n}\n\nexport function encodeCacheText(json: string, config: CacheCodecConfig): string | Buffer {\n if (config.compression === 'off') return json\n const size = Buffer.byteLength(json)\n if (size < config.minBytes || size > MAX_DECOMPRESSED_BYTES) return json\n const compressed = compress(Buffer.from(json, 'utf8'), config.compression)\n return compressed.length < size ? compressed : json\n}\n\nfunction isZstdFrame(raw: Buffer): boolean {\n return raw.length >= 4 && raw[0] === 0x28 && raw[1] === 0xb5 && raw[2] === 0x2f && raw[3] === 0xfd\n}\n\nfunction isGzipStream(raw: Buffer): boolean {\n return raw.length >= 2 && raw[0] === 0x1f && raw[1] === 0x8b\n}\n\n/** Detects the format from the stored bytes, so entries stay readable whatever CACHE_COMPRESSION is set to now. */\nexport function decodeCacheText(raw: string | Buffer): string {\n if (typeof raw === 'string') return raw\n if (isZstdFrame(raw)) {\n return zlib.zstdDecompressSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')\n }\n if (isGzipStream(raw)) {\n return zlib.gunzipSync(raw, { maxOutputLength: MAX_DECOMPRESSED_BYTES }).toString('utf8')\n }\n return raw.toString('utf8')\n}\n"],
|
|
5
|
+
"mappings": "AAAA,OAAO,UAAU;AAcV,MAAM,gCAAgC;AAGtC,MAAM,yBAAyB,KAAK,OAAO;AAElD,MAAM,aAAa;AACnB,MAAM,eAAmC,CAAC,OAAO,QAAQ,MAAM;AAE/D,MAAM,iBAAiB,oBAAI,IAAY;AAEvC,SAAS,SAAS,SAAuB;AACvC,MAAI,eAAe,IAAI,OAAO,EAAG;AACjC,iBAAe,IAAI,OAAO;AAC1B,UAAQ,KAAK,OAAO;AACtB;AAEO,SAAS,kBAA2B;AACzC,SAAO,OAAO,KAAK,qBAAqB,cAAc,OAAO,KAAK,uBAAuB;AAC3F;AAEO,SAAS,sBAAsB,KAA2C;AAC/E,QAAM,QAAQ,KAAK,KAAK,EAAE,YAAY;AACtC,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI,aAAa,SAAS,KAAyB,EAAG,QAAO;AAC7D,WAAS,4CAA4C,GAAG,2BAA2B;AACnF,SAAO;AACT;AAEO,SAAS,8BAA8B,KAA6C;AACzF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,SAAS,OAAO,SAAS,KAAK,EAAE;AACtC,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEO,SAAS,wBAAwB,SAA+C;AACrF,QAAM,WAAW,SAAS,uBAAuB,QAAQ,sBAAsB,IAC3E,QAAQ,sBACR;AACJ,MAAI,cAAc,SAAS,eAAe;AAC1C,MAAI,gBAAgB,UAAU,CAAC,gBAAgB,GAAG;AAChD,aAAS,gGAAgG;AACzG,kBAAc;AAAA,EAChB;AACA,SAAO,EAAE,aAAa,SAAS;AACjC;AAEA,SAAS,SAAS,OAAe,aAAuD;AACtF,MAAI,gBAAgB,QAAQ;AAC1B,WAAO,KAAK,iBAAiB,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,UAAU,uBAAuB,GAAG,WAAW,EAAE,CAAC;AAAA,EAC1G;AACA,SAAO,KAAK,SAAS,KAAK;AAC5B;AAEO,SAAS,gBAAgB,MAAc,QAA2C;AACvF,MAAI,OAAO,gBAAgB,MAAO,QAAO;AACzC,QAAM,OAAO,OAAO,WAAW,IAAI;AACnC,MAAI,OAAO,OAAO,YAAY,OAAO,uBAAwB,QAAO;AACpE,QAAM,aAAa,SAAS,OAAO,KAAK,MAAM,MAAM,GAAG,OAAO,WAAW;AACzE,SAAO,WAAW,SAAS,OAAO,aAAa;AACjD;AAEA,SAAS,YAAY,KAAsB;AACzC,SAAO,IAAI,UAAU,KAAK,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM,OAAQ,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM;AAChG;AAEA,SAAS,aAAa,KAAsB;AAC1C,SAAO,IAAI,UAAU,KAAK,IAAI,CAAC,MAAM,MAAQ,IAAI,CAAC,MAAM;AAC1D;AAGO,SAAS,gBAAgB,KAA8B;AAC5D,MAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,MAAI,YAAY,GAAG,GAAG;AACpB,WAAO,KAAK,mBAAmB,KAAK,EAAE,iBAAiB,uBAAuB,CAAC,EAAE,SAAS,MAAM;AAAA,EAClG;AACA,MAAI,aAAa,GAAG,GAAG;AACrB,WAAO,KAAK,WAAW,KAAK,EAAE,iBAAiB,uBAAuB,CAAC,EAAE,SAAS,MAAM;AAAA,EAC1F;AACA,SAAO,IAAI,SAAS,MAAM;AAC5B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getCurrentCacheTenant } from "./tenantContext.js";
|
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { CacheDependencyUnavailableError } from "./errors.js";
|
|
8
8
|
import { matchCacheKeyPattern } from "./patterns.js";
|
|
9
|
+
import { parseCacheCompression, parseCacheCompressionMinBytes } from "./codec.js";
|
|
9
10
|
function normalizeTenantKey(raw) {
|
|
10
11
|
const value = typeof raw === "string" ? raw.trim() : "";
|
|
11
12
|
if (!value) return "global";
|
|
@@ -191,7 +192,11 @@ function createConfiguredTenantAwareStrategy(options) {
|
|
|
191
192
|
const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES;
|
|
192
193
|
const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : void 0;
|
|
193
194
|
const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === "number" && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : void 0);
|
|
194
|
-
const
|
|
195
|
+
const codecOptions = {
|
|
196
|
+
compression: options?.compression ?? parseCacheCompression(process.env.CACHE_COMPRESSION),
|
|
197
|
+
compressionMinBytes: options?.compressionMinBytes ?? parseCacheCompressionMinBytes(process.env.CACHE_COMPRESSION_MIN_BYTES)
|
|
198
|
+
};
|
|
199
|
+
const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries, codecOptions);
|
|
195
200
|
const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries);
|
|
196
201
|
const reportsBoundedCounters = strategyType === "memory";
|
|
197
202
|
return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters);
|
|
@@ -248,12 +253,12 @@ class CacheService {
|
|
|
248
253
|
}
|
|
249
254
|
}
|
|
250
255
|
}
|
|
251
|
-
function createStrategyForType(strategyType, options, defaultTtl, maxEntries) {
|
|
256
|
+
function createStrategyForType(strategyType, options, defaultTtl, maxEntries, codecOptions) {
|
|
252
257
|
switch (strategyType) {
|
|
253
258
|
case "redis":
|
|
254
|
-
return createRedisStrategy(options?.redisUrl, { defaultTtl });
|
|
259
|
+
return createRedisStrategy(options?.redisUrl, { defaultTtl, ...codecOptions });
|
|
255
260
|
case "sqlite":
|
|
256
|
-
return createSqliteStrategy(options?.sqlitePath, { defaultTtl });
|
|
261
|
+
return createSqliteStrategy(options?.sqlitePath, { defaultTtl, ...codecOptions });
|
|
257
262
|
case "jsonfile":
|
|
258
263
|
return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl });
|
|
259
264
|
case "memory":
|
package/dist/service.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/service.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CacheStrategy, CacheServiceOptions, CacheGetOptions, CacheSetOptions, CacheValue } from './types'\nimport { createMemoryStrategy } from './strategies/memory'\nimport { createRedisStrategy } from './strategies/redis'\nimport { createSqliteStrategy } from './strategies/sqlite'\nimport { createJsonFileStrategy } from './strategies/jsonfile'\nimport { getCurrentCacheTenant } from './tenantContext'\nimport { createHash } from 'node:crypto'\nimport { CacheDependencyUnavailableError } from './errors'\nimport { matchCacheKeyPattern } from './patterns'\n\nfunction normalizeTenantKey(raw: string | null | undefined): string {\n const value = typeof raw === 'string' ? raw.trim() : ''\n if (!value) return 'global'\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\ntype TenantPrefixes = {\n keyPrefix: string\n tagPrefix: string\n scopeTag: string\n}\n\ntype CacheMetadata = {\n key: string\n expiresAt: number | null\n}\n\nfunction isCacheMetadata(value: CacheValue | null): value is CacheMetadata {\n if (typeof value !== 'object' || value === null) {\n return false\n }\n const record = value as Record<string, unknown>\n const hasValidKey = typeof record.key === 'string'\n const hasValidExpiresAt =\n !('expiresAt' in record)\n || record.expiresAt === null\n || typeof record.expiresAt === 'number'\n\n return hasValidKey && hasValidExpiresAt\n}\n\ntype CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>\nconst KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']\nconst TENANT_META_KEY_PATTERN = 'tenant:*:key:meta:*'\nconst TENANT_META_KEY_MARKER = ':key:meta:'\n\ntype TenantAwareCacheStrategy = CacheStrategy & {\n deleteByPatternsAcrossScopes(patterns: string[]): Promise<number>\n}\n\nfunction isCacheStrategyName(value: string | undefined): value is CacheStrategyName {\n if (!value) return false\n return KNOWN_STRATEGIES.includes(value as CacheStrategyName)\n}\n\nfunction resolveConfiguredStrategyType(options?: CacheServiceOptions): CacheStrategyName {\n const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)\n ? process.env.CACHE_STRATEGY\n : undefined\n return options?.strategy ?? envStrategy ?? 'memory'\n}\n\nfunction resolveTenantPrefixes(): TenantPrefixes {\n const tenant = normalizeTenantKey(getCurrentCacheTenant())\n const base = `tenant:${tenant}:`\n return {\n keyPrefix: `${base}key:`,\n tagPrefix: `${base}tag:`,\n scopeTag: `${base}tag:__scope__`,\n }\n}\n\nfunction hashIdentifier(input: string): string {\n return createHash('sha256').update(input).digest('hex')\n}\n\nfunction storageKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`\n}\n\nfunction metaKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}meta:${hashIdentifier(originalKey)}`\n}\n\nfunction hashedTag(tag: string, prefixes: TenantPrefixes): string {\n return `${prefixes.tagPrefix}t:${hashIdentifier(tag)}`\n}\n\nfunction buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, includeScope: boolean): string[] {\n const scoped = new Set<string>()\n if (includeScope) scoped.add(prefixes.scopeTag)\n if (Array.isArray(tags)) {\n for (const tag of tags) {\n if (typeof tag === 'string' && tag.length > 0) scoped.add(hashedTag(tag, prefixes))\n }\n }\n return Array.from(scoped)\n}\n\nfunction createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean): TenantAwareCacheStrategy {\n function normalizeDeletionCount(raw: number): number {\n if (!raw) return raw\n if (!Number.isFinite(raw)) return raw\n return Math.ceil(raw / 2)\n }\n\n const get = async (key: string, options?: CacheGetOptions) => {\n const prefixes = resolveTenantPrefixes()\n return base.get(storageKey(key, prefixes), options)\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions) => {\n const prefixes = resolveTenantPrefixes()\n const hashedTags = buildTagSet(options?.tags, prefixes, true)\n const ttl = options?.ttl ?? undefined\n const nextOptions: CacheSetOptions | undefined = options\n ? { ...options, tags: hashedTags }\n : { tags: hashedTags }\n await base.set(storageKey(key, prefixes), value, nextOptions)\n const metaPayload: CacheMetadata = { key, expiresAt: ttl ? Date.now() + ttl : null }\n await base.set(metaKey(key, prefixes), metaPayload, {\n ttl,\n tags: hashedTags,\n })\n }\n\n const has = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n return base.has(storageKey(key, prefixes))\n }\n\n const del = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n const primary = await base.delete(storageKey(key, prefixes))\n await base.delete(metaKey(key, prefixes))\n return primary\n }\n\n const deleteByTags = async (tags: string[]) => {\n const prefixes = resolveTenantPrefixes()\n const scopedTags = buildTagSet(tags, prefixes, false)\n if (!scopedTags.length) return 0\n const removed = await base.deleteByTags(scopedTags)\n return normalizeDeletionCount(removed)\n }\n\n const clear = async () => {\n const prefixes = resolveTenantPrefixes()\n const removed = await base.deleteByTags([prefixes.scopeTag])\n return normalizeDeletionCount(removed)\n }\n\n const keys = async (pattern?: string) => {\n const prefixes = resolveTenantPrefixes()\n const metaPattern = `${prefixes.keyPrefix}meta:*`\n const metaKeys = await base.keys(metaPattern)\n const originals: string[] = []\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n if (pattern && !matchCacheKeyPattern(original, pattern)) continue\n originals.push(original)\n }\n return originals\n }\n\n const deleteByPatternsAcrossScopes = async (patterns: string[]) => {\n const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)))\n if (normalizedPatterns.length === 0) return 0\n\n const metaKeys = await base.keys(TENANT_META_KEY_PATTERN)\n let deleted = 0\n for (const metadataKey of metaKeys) {\n const metadataValue = await base.get(metadataKey, { returnExpired: true })\n if (!metadataValue) continue\n const metadata = typeof metadataValue === 'string'\n ? null\n : (isCacheMetadata(metadataValue) ? metadataValue : null)\n const originalKey = typeof metadataValue === 'string' ? metadataValue : metadata?.key\n if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {\n continue\n }\n\n const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER)\n if (markerIndex < 0) continue\n const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`\n if (await base.delete(primaryKey)) deleted += 1\n await base.delete(metadataKey)\n }\n return deleted\n }\n\n const stats = async () => {\n const prefixes = resolveTenantPrefixes()\n const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)\n let size = 0\n let expired = 0\n const now = Date.now()\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n size++\n const expiresAt = metadata?.expiresAt ?? null\n if (expiresAt !== null && expiresAt <= now) expired++\n }\n // size/expired stay tenant-scoped (derived from this tenant's meta keys).\n // Only memory-backed strategies track the process-global bound counters, so\n // skip the extra base.stats() round-trip for redis/sqlite/jsonfile and omit\n // the optional fields (the stats() contract marks them optional). Keyed off\n // the configured strategy: a backend degraded to the memory fallback\n // intentionally does not surface these diagnostics.\n if (!reportsBoundedCounters) {\n return { size, expired }\n }\n const { evictions, sweeps, lastSweepReclaimed } = await base.stats()\n return { size, expired, evictions, sweeps, lastSweepReclaimed }\n }\n\n const cleanup = base.cleanup\n ? async () => normalizeDeletionCount(await base.cleanup!())\n : undefined\n\n const close = base.close\n ? async () => base.close!()\n : undefined\n const healthcheck = base.healthcheck\n ? async () => base.healthcheck!()\n : undefined\n\n return {\n get,\n set,\n has,\n delete: del,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n deleteByPatternsAcrossScopes,\n }\n}\n\nfunction createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): TenantAwareCacheStrategy {\n const strategyType = resolveConfiguredStrategyType(options)\n\n const envTtl = process.env.CACHE_TTL\n const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined\n const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)\n\n const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES\n const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined\n const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)\n\n const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries)\n const resilientStrategy = withDependencyFallback(baseStrategy, strategyType, defaultTtl, maxEntries)\n const reportsBoundedCounters = strategyType === 'memory'\n\n return createTenantAwareWrapper(resilientStrategy, reportsBoundedCounters)\n}\n\n/**\n * Cache service that provides a unified interface to different cache strategies\n *\n * Configuration via environment variables:\n * - CACHE_STRATEGY: 'memory' | 'redis' | 'sqlite' | 'jsonfile' (default: 'memory')\n * - CACHE_TTL: Default TTL in milliseconds (optional)\n * - CACHE_REDIS_URL: Redis connection URL (for redis strategy)\n * - CACHE_SQLITE_PATH: SQLite database file path (for sqlite strategy)\n * - CACHE_JSON_FILE_PATH: JSON file path (for jsonfile strategy)\n *\n * @example\n * const cache = createCacheService({ strategy: 'memory', defaultTtl: 60000 })\n * await cache.set('user:123', { name: 'John' }, { tags: ['users', 'user:123'] })\n * const user = await cache.get('user:123')\n * await cache.deleteByTags(['users']) // Invalidate all user-related cache\n */\nexport function createCacheService(options?: CacheServiceOptions): CacheStrategy {\n return createConfiguredTenantAwareStrategy(options)\n}\n\n/**\n * Delete matching logical keys across every tenant scope in the configured\n * stock cache backend without bootstrapping application DI or querying the\n * tenant database. Intended for maintenance tasks that already operate on all\n * scopes, such as post-generation structural invalidation.\n */\nexport async function purgeConfiguredCachePatternsAcrossTenantScopes(\n patterns: string[],\n options?: CacheServiceOptions,\n): Promise<number> {\n // A newly-created memory strategy cannot reach the running app's process-local\n // cache, so constructing one here would only allocate and immediately discard\n // an empty store. Persistent stock backends remain cross-process maintainable.\n if (resolveConfiguredStrategyType(options) === 'memory') return 0\n\n const cache = createConfiguredTenantAwareStrategy(options)\n try {\n return await cache.deleteByPatternsAcrossScopes(patterns)\n } finally {\n await cache.close?.()\n }\n}\n\n/**\n * CacheService class wrapper for DI integration\n * Provides the same interface as the functional API but as a class\n */\nexport class CacheService implements CacheStrategy {\n private strategy: CacheStrategy\n\n constructor(options?: CacheServiceOptions) {\n this.strategy = createCacheService(options)\n }\n\n async get(key: string, options?: CacheGetOptions): Promise<CacheValue | null> {\n return this.strategy.get(key, options)\n }\n\n async set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> {\n return this.strategy.set(key, value, options)\n }\n\n async has(key: string): Promise<boolean> {\n return this.strategy.has(key)\n }\n\n async delete(key: string): Promise<boolean> {\n return this.strategy.delete(key)\n }\n\n async deleteByTags(tags: string[]): Promise<number> {\n return this.strategy.deleteByTags(tags)\n }\n\n async clear(): Promise<number> {\n return this.strategy.clear()\n }\n\n async keys(pattern?: string): Promise<string[]> {\n return this.strategy.keys(pattern)\n }\n\n async stats(): Promise<{\n size: number\n expired: number\n evictions?: number\n sweeps?: number\n lastSweepReclaimed?: number\n }> {\n return this.strategy.stats()\n }\n\n async cleanup(): Promise<number> {\n if (this.strategy.cleanup) {\n return this.strategy.cleanup()\n }\n return 0\n }\n\n async close(): Promise<void> {\n if (this.strategy.close) {\n return this.strategy.close()\n }\n }\n}\n\nfunction createStrategyForType(strategyType: CacheStrategyName, options?: CacheServiceOptions, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n switch (strategyType) {\n case 'redis':\n return createRedisStrategy(options?.redisUrl, { defaultTtl })\n case 'sqlite':\n return createSqliteStrategy(options?.sqlitePath, { defaultTtl })\n case 'jsonfile':\n return createJsonFileStrategy(options?.jsonFilePath, { defaultTtl })\n case 'memory':\n default:\n return createMemoryStrategy({ defaultTtl, maxEntries })\n }\n}\n\nfunction describeDependencyFailure(error: CacheDependencyUnavailableError): string {\n const originalError = error.originalError\n if (!(originalError instanceof Error)) {\n return `missing dependency: ${error.dependency}`\n }\n\n const message = originalError.message.trim()\n if (message.length === 0) {\n return `missing dependency: ${error.dependency}`\n }\n\n if (message.includes('compiled against a different Node.js version') || message.includes('NODE_MODULE_VERSION')) {\n return `${error.dependency} native module needs rebuild for the current Node.js version`\n }\n\n if (message.includes('Cannot find module')) {\n return `missing dependency: ${error.dependency}`\n }\n\n return `${error.dependency} failed to load`\n}\n\nfunction withDependencyFallback(strategy: CacheStrategy, strategyType: CacheStrategyName, defaultTtl?: number, maxEntries?: number): CacheStrategy {\n if (strategyType === 'memory') return strategy\n\n let activeStrategy = strategy\n let fallbackStrategy: CacheStrategy | null = null\n let warned = false\n\n const ensureFallback = (error: CacheDependencyUnavailableError) => {\n if (!fallbackStrategy) {\n fallbackStrategy = createMemoryStrategy({ defaultTtl, maxEntries })\n }\n if (!warned) {\n const dependencyMessage = error.dependency\n ? ` (${describeDependencyFailure(error)})`\n : ''\n console.warn(`[cache] ${error.strategy} strategy unavailable${dependencyMessage}. Falling back to memory strategy.`)\n warned = true\n }\n activeStrategy = fallbackStrategy\n }\n\n const wrapMethod = <K extends keyof CacheStrategy>(method: K): CacheStrategy[K] => {\n const handler = async (...args: Parameters<NonNullable<CacheStrategy[K]>>) => {\n const fn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n\n try {\n return await fn(...args)\n } catch (error) {\n if (error instanceof CacheDependencyUnavailableError) {\n ensureFallback(error)\n const fallbackFn = activeStrategy[method] as ((...methodArgs: Parameters<NonNullable<CacheStrategy[K]>>) => ReturnType<NonNullable<CacheStrategy[K]>>) | undefined\n if (!fallbackFn) {\n return undefined as Awaited<ReturnType<NonNullable<CacheStrategy[K]>>>\n }\n return fallbackFn(...args)\n }\n throw error\n }\n }\n\n return handler as CacheStrategy[K]\n }\n\n return {\n get: wrapMethod('get'),\n set: wrapMethod('set'),\n has: wrapMethod('has'),\n delete: wrapMethod('delete'),\n deleteByTags: wrapMethod('deleteByTags'),\n clear: wrapMethod('clear'),\n keys: wrapMethod('keys'),\n stats: wrapMethod('stats'),\n healthcheck: typeof strategy.healthcheck === 'function'\n ? async () => strategy.healthcheck!()\n : undefined,\n cleanup: typeof strategy.cleanup === 'function' ? wrapMethod('cleanup') : undefined,\n close: typeof strategy.close === 'function' ? wrapMethod('close') : undefined,\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,4BAA4B;AACrC,SAAS,2BAA2B;AACpC,SAAS,4BAA4B;AACrC,SAAS,8BAA8B;AACvC,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAC3B,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;
|
|
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'\nimport { parseCacheCompression, parseCacheCompressionMinBytes } from './codec'\nimport type { CacheCodecOptions } from './codec'\n\nfunction normalizeTenantKey(raw: string | null | undefined): string {\n const value = typeof raw === 'string' ? raw.trim() : ''\n if (!value) return 'global'\n return value.replace(/[^a-zA-Z0-9._-]/g, '_')\n}\n\ntype TenantPrefixes = {\n keyPrefix: string\n tagPrefix: string\n scopeTag: string\n}\n\ntype CacheMetadata = {\n key: string\n expiresAt: number | null\n}\n\nfunction isCacheMetadata(value: CacheValue | null): value is CacheMetadata {\n if (typeof value !== 'object' || value === null) {\n return false\n }\n const record = value as Record<string, unknown>\n const hasValidKey = typeof record.key === 'string'\n const hasValidExpiresAt =\n !('expiresAt' in record)\n || record.expiresAt === null\n || typeof record.expiresAt === 'number'\n\n return hasValidKey && hasValidExpiresAt\n}\n\ntype CacheStrategyName = NonNullable<CacheServiceOptions['strategy']>\nconst KNOWN_STRATEGIES: CacheStrategyName[] = ['memory', 'redis', 'sqlite', 'jsonfile']\nconst TENANT_META_KEY_PATTERN = 'tenant:*:key:meta:*'\nconst TENANT_META_KEY_MARKER = ':key:meta:'\n\ntype TenantAwareCacheStrategy = CacheStrategy & {\n deleteByPatternsAcrossScopes(patterns: string[]): Promise<number>\n}\n\nfunction isCacheStrategyName(value: string | undefined): value is CacheStrategyName {\n if (!value) return false\n return KNOWN_STRATEGIES.includes(value as CacheStrategyName)\n}\n\nfunction resolveConfiguredStrategyType(options?: CacheServiceOptions): CacheStrategyName {\n const envStrategy = isCacheStrategyName(process.env.CACHE_STRATEGY)\n ? process.env.CACHE_STRATEGY\n : undefined\n return options?.strategy ?? envStrategy ?? 'memory'\n}\n\nfunction resolveTenantPrefixes(): TenantPrefixes {\n const tenant = normalizeTenantKey(getCurrentCacheTenant())\n const base = `tenant:${tenant}:`\n return {\n keyPrefix: `${base}key:`,\n tagPrefix: `${base}tag:`,\n scopeTag: `${base}tag:__scope__`,\n }\n}\n\nfunction hashIdentifier(input: string): string {\n return createHash('sha256').update(input).digest('hex')\n}\n\nfunction storageKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}k:${hashIdentifier(originalKey)}`\n}\n\nfunction metaKey(originalKey: string, prefixes: TenantPrefixes): string {\n return `${prefixes.keyPrefix}meta:${hashIdentifier(originalKey)}`\n}\n\nfunction hashedTag(tag: string, prefixes: TenantPrefixes): string {\n return `${prefixes.tagPrefix}t:${hashIdentifier(tag)}`\n}\n\nfunction buildTagSet(tags: string[] | undefined, prefixes: TenantPrefixes, includeScope: boolean): string[] {\n const scoped = new Set<string>()\n if (includeScope) scoped.add(prefixes.scopeTag)\n if (Array.isArray(tags)) {\n for (const tag of tags) {\n if (typeof tag === 'string' && tag.length > 0) scoped.add(hashedTag(tag, prefixes))\n }\n }\n return Array.from(scoped)\n}\n\nfunction createTenantAwareWrapper(base: CacheStrategy, reportsBoundedCounters: boolean): TenantAwareCacheStrategy {\n function normalizeDeletionCount(raw: number): number {\n if (!raw) return raw\n if (!Number.isFinite(raw)) return raw\n return Math.ceil(raw / 2)\n }\n\n const get = async (key: string, options?: CacheGetOptions) => {\n const prefixes = resolveTenantPrefixes()\n return base.get(storageKey(key, prefixes), options)\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions) => {\n const prefixes = resolveTenantPrefixes()\n const hashedTags = buildTagSet(options?.tags, prefixes, true)\n const ttl = options?.ttl ?? undefined\n const nextOptions: CacheSetOptions | undefined = options\n ? { ...options, tags: hashedTags }\n : { tags: hashedTags }\n await base.set(storageKey(key, prefixes), value, nextOptions)\n const metaPayload: CacheMetadata = { key, expiresAt: ttl ? Date.now() + ttl : null }\n await base.set(metaKey(key, prefixes), metaPayload, {\n ttl,\n tags: hashedTags,\n })\n }\n\n const has = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n return base.has(storageKey(key, prefixes))\n }\n\n const del = async (key: string) => {\n const prefixes = resolveTenantPrefixes()\n const primary = await base.delete(storageKey(key, prefixes))\n await base.delete(metaKey(key, prefixes))\n return primary\n }\n\n const deleteByTags = async (tags: string[]) => {\n const prefixes = resolveTenantPrefixes()\n const scopedTags = buildTagSet(tags, prefixes, false)\n if (!scopedTags.length) return 0\n const removed = await base.deleteByTags(scopedTags)\n return normalizeDeletionCount(removed)\n }\n\n const clear = async () => {\n const prefixes = resolveTenantPrefixes()\n const removed = await base.deleteByTags([prefixes.scopeTag])\n return normalizeDeletionCount(removed)\n }\n\n const keys = async (pattern?: string) => {\n const prefixes = resolveTenantPrefixes()\n const metaPattern = `${prefixes.keyPrefix}meta:*`\n const metaKeys = await base.keys(metaPattern)\n const originals: string[] = []\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n if (pattern && !matchCacheKeyPattern(original, pattern)) continue\n originals.push(original)\n }\n return originals\n }\n\n const deleteByPatternsAcrossScopes = async (patterns: string[]) => {\n const normalizedPatterns = Array.from(new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean)))\n if (normalizedPatterns.length === 0) return 0\n\n const metaKeys = await base.keys(TENANT_META_KEY_PATTERN)\n let deleted = 0\n for (const metadataKey of metaKeys) {\n const metadataValue = await base.get(metadataKey, { returnExpired: true })\n if (!metadataValue) continue\n const metadata = typeof metadataValue === 'string'\n ? null\n : (isCacheMetadata(metadataValue) ? metadataValue : null)\n const originalKey = typeof metadataValue === 'string' ? metadataValue : metadata?.key\n if (!originalKey || !normalizedPatterns.some((pattern) => matchCacheKeyPattern(originalKey, pattern))) {\n continue\n }\n\n const markerIndex = metadataKey.lastIndexOf(TENANT_META_KEY_MARKER)\n if (markerIndex < 0) continue\n const primaryKey = `${metadataKey.slice(0, markerIndex)}:key:k:${metadataKey.slice(markerIndex + TENANT_META_KEY_MARKER.length)}`\n if (await base.delete(primaryKey)) deleted += 1\n await base.delete(metadataKey)\n }\n return deleted\n }\n\n const stats = async () => {\n const prefixes = resolveTenantPrefixes()\n const metaKeys = await base.keys(`${prefixes.keyPrefix}meta:*`)\n let size = 0\n let expired = 0\n const now = Date.now()\n for (const metaKey of metaKeys) {\n const metaValue = await base.get(metaKey, { returnExpired: true })\n if (!metaValue) continue\n const metadata = typeof metaValue === 'string' ? null : (isCacheMetadata(metaValue) ? metaValue : null)\n const original = typeof metaValue === 'string' ? metaValue : metadata?.key\n if (!original) continue\n size++\n const expiresAt = metadata?.expiresAt ?? null\n if (expiresAt !== null && expiresAt <= now) expired++\n }\n // size/expired stay tenant-scoped (derived from this tenant's meta keys).\n // Only memory-backed strategies track the process-global bound counters, so\n // skip the extra base.stats() round-trip for redis/sqlite/jsonfile and omit\n // the optional fields (the stats() contract marks them optional). Keyed off\n // the configured strategy: a backend degraded to the memory fallback\n // intentionally does not surface these diagnostics.\n if (!reportsBoundedCounters) {\n return { size, expired }\n }\n const { evictions, sweeps, lastSweepReclaimed } = await base.stats()\n return { size, expired, evictions, sweeps, lastSweepReclaimed }\n }\n\n const cleanup = base.cleanup\n ? async () => normalizeDeletionCount(await base.cleanup!())\n : undefined\n\n const close = base.close\n ? async () => base.close!()\n : undefined\n const healthcheck = base.healthcheck\n ? async () => base.healthcheck!()\n : undefined\n\n return {\n get,\n set,\n has,\n delete: del,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n deleteByPatternsAcrossScopes,\n }\n}\n\nfunction createConfiguredTenantAwareStrategy(options?: CacheServiceOptions): TenantAwareCacheStrategy {\n const strategyType = resolveConfiguredStrategyType(options)\n\n const envTtl = process.env.CACHE_TTL\n const parsedEnvTtl = envTtl ? Number.parseInt(envTtl, 10) : undefined\n const defaultTtl = options?.defaultTtl ?? (typeof parsedEnvTtl === 'number' && Number.isFinite(parsedEnvTtl) ? parsedEnvTtl : undefined)\n\n const envMaxEntries = process.env.CACHE_MEMORY_MAX_ENTRIES\n const parsedEnvMaxEntries = envMaxEntries ? Number.parseInt(envMaxEntries, 10) : undefined\n const maxEntries = options?.maxEntries ?? (typeof parsedEnvMaxEntries === 'number' && Number.isFinite(parsedEnvMaxEntries) ? parsedEnvMaxEntries : undefined)\n\n const codecOptions: CacheCodecOptions = {\n compression: options?.compression ?? parseCacheCompression(process.env.CACHE_COMPRESSION),\n compressionMinBytes: options?.compressionMinBytes ?? parseCacheCompressionMinBytes(process.env.CACHE_COMPRESSION_MIN_BYTES),\n }\n\n const baseStrategy = createStrategyForType(strategyType, options, defaultTtl, maxEntries, codecOptions)\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 * - CACHE_COMPRESSION: 'off' | 'zstd' | 'gzip' value compression for redis/sqlite (default: 'off')\n * - CACHE_COMPRESSION_MIN_BYTES: smallest serialized entry that gets compressed (default: 1024)\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, codecOptions?: CacheCodecOptions): CacheStrategy {\n switch (strategyType) {\n case 'redis':\n return createRedisStrategy(options?.redisUrl, { defaultTtl, ...codecOptions })\n case 'sqlite':\n return createSqliteStrategy(options?.sqlitePath, { defaultTtl, ...codecOptions })\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/dist/strategies/redis.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CacheDependencyUnavailableError } from "../errors.js";
|
|
2
2
|
import { getRedisUrlOrThrow, REDIS_WIRE_PROTOCOL } from "@open-mercato/shared/lib/redis/connection";
|
|
3
3
|
import { matchCacheKeyPattern } from "../patterns.js";
|
|
4
|
+
import { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from "../codec.js";
|
|
4
5
|
const REAP_ORPHANED_TAG_MEMBERS = `
|
|
5
6
|
local removed = 0
|
|
6
7
|
for i = 2, #KEYS do
|
|
@@ -114,6 +115,7 @@ async function releaseRedisEntry(key, entry) {
|
|
|
114
115
|
}
|
|
115
116
|
function createRedisStrategy(redisUrl, options) {
|
|
116
117
|
const defaultTtl = options?.defaultTtl;
|
|
118
|
+
const codec = resolveCacheCodecConfig(options);
|
|
117
119
|
const keyPrefix = "cache:";
|
|
118
120
|
const tagPrefix = "tag:";
|
|
119
121
|
const connectionUrl = redisUrl || getRedisUrlOrThrow("CACHE");
|
|
@@ -137,10 +139,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
137
139
|
const get = async (key, options2) => {
|
|
138
140
|
const client = await getRedisClient();
|
|
139
141
|
const cacheKey = getCacheKey(key);
|
|
140
|
-
const data = await client.
|
|
142
|
+
const data = await client.getBuffer(cacheKey);
|
|
141
143
|
if (!data) return null;
|
|
142
144
|
try {
|
|
143
|
-
const entry = JSON.parse(data);
|
|
145
|
+
const entry = JSON.parse(decodeCacheText(data));
|
|
144
146
|
if (isExpired(entry)) {
|
|
145
147
|
if (options2?.returnExpired) {
|
|
146
148
|
return entry.value;
|
|
@@ -157,10 +159,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
157
159
|
const set = async (key, value, options2) => {
|
|
158
160
|
const client = await getRedisClient();
|
|
159
161
|
const cacheKey = getCacheKey(key);
|
|
160
|
-
const oldData = await client.
|
|
162
|
+
const oldData = await client.getBuffer(cacheKey);
|
|
161
163
|
if (oldData) {
|
|
162
164
|
try {
|
|
163
|
-
const oldEntry = JSON.parse(oldData);
|
|
165
|
+
const oldEntry = JSON.parse(decodeCacheText(oldData));
|
|
164
166
|
const pipeline2 = client.pipeline();
|
|
165
167
|
for (const tag of oldEntry.tags) {
|
|
166
168
|
pipeline2.srem(getTagKey(tag), key);
|
|
@@ -180,7 +182,7 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
180
182
|
createdAt: Date.now()
|
|
181
183
|
};
|
|
182
184
|
const pipeline = client.pipeline();
|
|
183
|
-
const serialized = JSON.stringify(entry);
|
|
185
|
+
const serialized = encodeCacheText(JSON.stringify(entry), codec);
|
|
184
186
|
if (ttl) {
|
|
185
187
|
pipeline.setex(cacheKey, Math.ceil(ttl / 1e3), serialized);
|
|
186
188
|
} else {
|
|
@@ -196,10 +198,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
196
198
|
const cacheKey = getCacheKey(key);
|
|
197
199
|
const exists = await client.exists(cacheKey);
|
|
198
200
|
if (!exists) return false;
|
|
199
|
-
const data = await client.
|
|
201
|
+
const data = await client.getBuffer(cacheKey);
|
|
200
202
|
if (!data) return false;
|
|
201
203
|
try {
|
|
202
|
-
const entry = JSON.parse(data);
|
|
204
|
+
const entry = JSON.parse(decodeCacheText(data));
|
|
203
205
|
if (isExpired(entry)) {
|
|
204
206
|
await deleteKey(key);
|
|
205
207
|
return false;
|
|
@@ -212,10 +214,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
212
214
|
const deleteKey = async (key) => {
|
|
213
215
|
const client = await getRedisClient();
|
|
214
216
|
const cacheKey = getCacheKey(key);
|
|
215
|
-
const data = await client.
|
|
217
|
+
const data = await client.getBuffer(cacheKey);
|
|
216
218
|
if (!data) return false;
|
|
217
219
|
try {
|
|
218
|
-
const entry = JSON.parse(data);
|
|
220
|
+
const entry = JSON.parse(decodeCacheText(data));
|
|
219
221
|
const pipeline = client.pipeline();
|
|
220
222
|
for (const tag of entry.tags) {
|
|
221
223
|
pipeline.srem(getTagKey(tag), key);
|
|
@@ -288,10 +290,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
288
290
|
const cacheKeys = await client.keys(`${keyPrefix}*`);
|
|
289
291
|
let expired = 0;
|
|
290
292
|
for (const cacheKey of cacheKeys) {
|
|
291
|
-
const data = await client.
|
|
293
|
+
const data = await client.getBuffer(cacheKey);
|
|
292
294
|
if (data) {
|
|
293
295
|
try {
|
|
294
|
-
const entry = JSON.parse(data);
|
|
296
|
+
const entry = JSON.parse(decodeCacheText(data));
|
|
295
297
|
if (isExpired(entry)) {
|
|
296
298
|
expired++;
|
|
297
299
|
}
|
|
@@ -306,10 +308,10 @@ function createRedisStrategy(redisUrl, options) {
|
|
|
306
308
|
const cacheKeys = await client.keys(`${keyPrefix}*`);
|
|
307
309
|
let removed = 0;
|
|
308
310
|
for (const cacheKey of cacheKeys) {
|
|
309
|
-
const data = await client.
|
|
311
|
+
const data = await client.getBuffer(cacheKey);
|
|
310
312
|
if (data) {
|
|
311
313
|
try {
|
|
312
|
-
const entry = JSON.parse(data);
|
|
314
|
+
const entry = JSON.parse(decodeCacheText(data));
|
|
313
315
|
if (isExpired(entry)) {
|
|
314
316
|
const key = cacheKey.substring(keyPrefix.length);
|
|
315
317
|
await deleteKey(key);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/strategies/redis.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { getRedisUrlOrThrow, REDIS_WIRE_PROTOCOL } from '@open-mercato/shared/lib/redis/connection'\nimport type { RedisProtocolVersion } from '@open-mercato/shared/lib/redis/connection'\nimport { matchCacheKeyPattern } from '../patterns'\n\ntype RedisPipeline = {\n set(key: string, value: string): RedisPipeline\n setex(key: string, ttlSeconds: number, value: string): RedisPipeline\n sadd(key: string, value: string): RedisPipeline\n srem(key: string, member: string): RedisPipeline\n del(key: string): RedisPipeline\n eval(script: string, numKeys: number, ...args: (string | number)[]): RedisPipeline\n exec(): Promise<unknown>\n}\n\ntype RedisClient = {\n ping(): Promise<string>\n get(key: string): Promise<string | null>\n set(key: string, value: string): Promise<unknown>\n setex(key: string, ttlSeconds: number, value: string): Promise<unknown>\n del(key: string): Promise<unknown>\n exists(key: string): Promise<number>\n keys(pattern: string): Promise<string[]>\n smembers(key: string): Promise<string[]>\n pipeline(): RedisPipeline\n quit(): Promise<void>\n once?(event: 'end', listener: () => void): void\n}\n\ntype RedisConstructor = new (url?: string, options?: { protocol?: RedisProtocolVersion }) => RedisClient\ntype PossibleRedisModule = unknown\n\ntype RequireFn = (id: string) => unknown\n\n/**\n * Reaps orphaned members from ONE tag set atomically.\n *\n * `KEYS[1]` is the tag key and `KEYS[2..]` are the candidate value keys, with\n * `ARGV[1..]` the member names aligned to them. Every key the script touches is\n * declared in `KEYS` so it stays valid against a slot-aware deployment.\n *\n * The `EXISTS` check and the `SREM` run in one atomic step, which is the whole\n * point: a member may only be dropped while its value key is genuinely absent.\n * A concurrent `set()` either lands first (`EXISTS` is 1, so the member is left\n * alone) or lands after (its own `sadd` re-adds the member). Either ordering\n * leaves a live key indexed.\n *\n * Deliberately single-tag. Pairing every orphan with every requested tag would\n * make the work quadratic in the tag count \u2014 and because a script blocks the\n * server for its whole run, that lands as a stall rather than as throughput.\n * Callers reap per tag using the membership each `smembers` actually returned.\n */\nconst REAP_ORPHANED_TAG_MEMBERS = `\nlocal removed = 0\nfor i = 2, #KEYS do\n if redis.call('EXISTS', KEYS[i]) == 0 then\n removed = removed + redis.call('SREM', KEYS[1], ARGV[i - 1])\n end\nend\nreturn removed\n`\n\n/** Bounds a single script's blocking time on the server, and the request size. */\nconst REAP_CHUNK_SIZE = 256\n\n/**\n * Redis cache strategy with tag support\n * Persistent across process restarts, can be shared across multiple instances\n * \n * Uses Redis data structures:\n * - Hash for storing cache entries: cache:{key} -> {value, tags, expiresAt, createdAt}\n * - Sets for tag index: tag:{tag} -> Set of keys\n */\nlet redisModulePromise: Promise<PossibleRedisModule> | null = null\ntype RedisRegistryEntry = { client?: RedisClient; creating?: Promise<RedisClient>; refs: number }\nconst redisRegistry = new Map<string, RedisRegistryEntry>()\n\nfunction resolveRequire(): RequireFn | null {\n const nonWebpack = (globalThis as { __non_webpack_require__?: unknown }).__non_webpack_require__\n if (typeof nonWebpack === 'function') return nonWebpack as RequireFn\n if (typeof require === 'function') return require as RequireFn\n if (typeof module !== 'undefined' && typeof module.require === 'function') {\n return module.require.bind(module)\n }\n try {\n const maybeRequire = Function('return typeof require !== \"undefined\" ? require : undefined')()\n if (typeof maybeRequire === 'function') return maybeRequire as RequireFn\n } catch {\n // ignore\n }\n return null\n}\n\nfunction loadRedisModuleViaRequire(): PossibleRedisModule | null {\n const resolver = resolveRequire()\n if (!resolver) return null\n try {\n return resolver('ioredis') as PossibleRedisModule\n } catch {\n return null\n }\n}\n\nfunction pickRedisConstructor(mod: PossibleRedisModule): RedisConstructor | null {\n const queue: unknown[] = [mod]\n const seen = new Set<unknown>()\n while (queue.length) {\n const current = queue.shift()\n if (!current || seen.has(current)) continue\n seen.add(current)\n if (typeof current === 'function') return current as RedisConstructor\n if (typeof current === 'object') {\n queue.push((current as { default?: unknown }).default)\n queue.push((current as { Redis?: unknown }).Redis)\n queue.push((current as { module?: { exports?: unknown } }).module?.exports)\n queue.push((current as { exports?: unknown }).exports)\n }\n }\n return null\n}\n\nasync function loadRedisModule(): Promise<PossibleRedisModule> {\n if (!redisModulePromise) {\n redisModulePromise = (async () => {\n const required = loadRedisModuleViaRequire() ?? (await import('ioredis'))\n return required as PossibleRedisModule\n })().catch((error) => {\n redisModulePromise = null\n throw new CacheDependencyUnavailableError('redis', 'ioredis', error)\n })\n }\n return redisModulePromise\n}\n\nfunction retainRedisEntry(key: string): RedisRegistryEntry {\n let entry = redisRegistry.get(key)\n if (!entry) {\n entry = { refs: 0 }\n redisRegistry.set(key, entry)\n }\n entry.refs += 1\n return entry\n}\n\nasync function acquireRedisClient(key: string, entry: RedisRegistryEntry): Promise<RedisClient> {\n if (entry.client) return entry.client\n if (entry.creating) return entry.creating\n entry.creating = loadRedisModule()\n .then((mod) => {\n const ctor = pickRedisConstructor(mod)\n if (!ctor) {\n throw new CacheDependencyUnavailableError('redis', 'ioredis', new Error('No usable Redis constructor'))\n }\n const client = new ctor(key, { protocol: REDIS_WIRE_PROTOCOL })\n entry.client = client\n entry.creating = undefined\n client.once?.('end', () => {\n if (redisRegistry.get(key) === entry && entry.refs === 0) {\n redisRegistry.delete(key)\n } else if (redisRegistry.get(key) === entry) {\n entry.client = undefined\n }\n })\n return client\n })\n .catch((error) => {\n entry.creating = undefined\n throw error\n })\n return entry.creating\n}\n\nasync function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promise<void> {\n entry.refs = Math.max(0, entry.refs - 1)\n if (entry.refs > 0) return\n redisRegistry.delete(key)\n if (entry.client) {\n try {\n await entry.client.quit()\n } catch {\n // ignore shutdown errors\n } finally {\n entry.client = undefined\n }\n }\n}\n\nexport function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number }): CacheStrategy {\n const defaultTtl = options?.defaultTtl\n const keyPrefix = 'cache:'\n const tagPrefix = 'tag:'\n const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')\n const registryEntry = retainRedisEntry(connectionUrl)\n let redis: RedisClient | null = registryEntry.client ?? null\n\n async function getRedisClient(): Promise<RedisClient> {\n if (redis) return redis\n\n redis = await acquireRedisClient(connectionUrl, registryEntry)\n return redis\n }\n\n function getCacheKey(key: string): string {\n return `${keyPrefix}${key}`\n }\n\n function getTagKey(tag: string): string {\n return `${tagPrefix}${tag}`\n }\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const data = await client.get(cacheKey)\n\n if (!data) return null\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return entry.value\n } catch {\n // Invalid JSON, remove it\n await client.del(cacheKey)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Remove old entry from tag index if it exists\n const oldData = await client.get(cacheKey)\n if (oldData) {\n try {\n const oldEntry: CacheEntry = JSON.parse(oldData)\n // Remove from old tags\n const pipeline = client.pipeline()\n for (const tag of oldEntry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n await pipeline.exec()\n } catch {\n // Ignore parse errors\n }\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n const pipeline = client.pipeline()\n\n // Store the entry\n const serialized = JSON.stringify(entry)\n if (ttl) {\n pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)\n } else {\n pipeline.set(cacheKey, serialized)\n }\n\n // Add to tag index\n for (const tag of tags) {\n pipeline.sadd(getTagKey(tag), key)\n }\n\n await pipeline.exec()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const exists = await client.exists(cacheKey)\n\n if (!exists) return false\n\n // Check if expired\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n await deleteKey(key)\n return false\n }\n return true\n } catch {\n return false\n }\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Get entry to remove from tag index\n const data = await client.get(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(data)\n const pipeline = client.pipeline()\n\n // Remove from tag index\n for (const tag of entry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n\n // Delete the cache entry\n pipeline.del(cacheKey)\n\n await pipeline.exec()\n return true\n } catch {\n // Just delete the key if we can't parse it\n await client.del(cacheKey)\n return true\n }\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const client = await getRedisClient()\n const keysToDelete = new Set<string>()\n // Keep which tag each key came from. Reaping needs it to stay linear in the\n // memberships that actually exist rather than in tags x keys.\n const membersByTagKey = new Map<string, string[]>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const tagKey = getTagKey(tag)\n const keys = await client.smembers(tagKey)\n membersByTagKey.set(tagKey, keys)\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n const orphans = new Set<string>()\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n else orphans.add(key)\n }\n\n // A key whose value has already expired leaves its name behind in every tag\n // set it was added to: `set()` only prunes tags when it overwrites a live\n // key, `deleteKey` only prunes when it finds one, and `cleanup()` scans\n // value keys rather than tag sets. Nothing else reaps them, so the sets grow\n // without bound whenever TTL'd entries also rotate their key names \u2014 and\n // every later invalidation pays a GET per orphan. Reap what we just walked.\n //\n // `deleteKey` reported these missing some time ago \u2014 the walk above is one\n // round trip per member \u2014 so a concurrent request may have rebuilt any of\n // them since. Re-check each value key atomically with the removal rather\n // than trusting that stale read: dropping a live key from the index would\n // silently exempt it from every future invalidation of this tag.\n //\n // Reap per tag, over that tag's own members, so the work stays linear in the\n // memberships `smembers` actually returned. Each script is capped at\n // REAP_CHUNK_SIZE members, so no single atomic call can stall the server for\n // longer than a fixed bound however many tags were requested. The calls are\n // pipelined into one round trip, but stay separate scripts so Redis can\n // serve other clients between them.\n if (orphans.size > 0) {\n const pipeline = client.pipeline()\n let queued = 0\n for (const [tagKey, members] of membersByTagKey) {\n const tagOrphans = members.filter((member) => orphans.has(member))\n for (let offset = 0; offset < tagOrphans.length; offset += REAP_CHUNK_SIZE) {\n const chunk = tagOrphans.slice(offset, offset + REAP_CHUNK_SIZE)\n const keys = [tagKey, ...chunk.map(getCacheKey)]\n pipeline.eval(REAP_ORPHANED_TAG_MEMBERS, keys.length, ...keys, ...chunk)\n queued++\n }\n }\n if (queued > 0) await pipeline.exec()\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const client = await getRedisClient()\n \n // Get all cache keys\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n const tagKeys = await client.keys(`${tagPrefix}*`)\n\n if (cacheKeys.length === 0 && tagKeys.length === 0) return 0\n\n const pipeline = client.pipeline()\n for (const key of [...cacheKeys, ...tagKeys]) {\n pipeline.del(key)\n }\n\n await pipeline.exec()\n return cacheKeys.length\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const client = await getRedisClient()\n const searchPattern = pattern \n ? `${keyPrefix}${pattern}` \n : `${keyPrefix}*`\n \n const cacheKeys = await client.keys(searchPattern)\n \n // Remove prefix from keys\n const result = cacheKeys.map((key: string) => key.substring(keyPrefix.length))\n \n if (!pattern) return result\n \n // Apply pattern matching (Redis KEYS command uses glob pattern, but we want our pattern)\n return result.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let expired = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n expired++\n }\n } catch {\n // Ignore parse errors\n }\n }\n }\n\n return { size: cacheKeys.length, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let removed = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.get(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(data)\n if (isExpired(entry)) {\n const key = cacheKey.substring(keyPrefix.length)\n await deleteKey(key)\n removed++\n }\n } catch {\n // Remove invalid entries\n await client.del(cacheKey)\n removed++\n }\n }\n }\n\n return removed\n }\n\n const close = async (): Promise<void> => {\n await releaseRedisEntry(connectionUrl, registryEntry)\n redis = null\n }\n\n const healthcheck = async (): Promise<void> => {\n const client = await getRedisClient()\n await client.ping()\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,uCAAuC;AAChD,SAAS,oBAAoB,2BAA2B;AAExD,SAAS,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import type { CacheStrategy, CacheEntry, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'\nimport { CacheDependencyUnavailableError } from '../errors'\nimport { getRedisUrlOrThrow, REDIS_WIRE_PROTOCOL } from '@open-mercato/shared/lib/redis/connection'\nimport type { RedisProtocolVersion } from '@open-mercato/shared/lib/redis/connection'\nimport { matchCacheKeyPattern } from '../patterns'\nimport { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from '../codec'\nimport type { CacheCodecOptions } from '../codec'\n\ntype RedisPipeline = {\n set(key: string, value: string | Buffer): RedisPipeline\n setex(key: string, ttlSeconds: number, value: string | Buffer): RedisPipeline\n sadd(key: string, value: string): RedisPipeline\n srem(key: string, member: string): RedisPipeline\n del(key: string): RedisPipeline\n eval(script: string, numKeys: number, ...args: (string | number)[]): RedisPipeline\n exec(): Promise<unknown>\n}\n\ntype RedisClient = {\n ping(): Promise<string>\n get(key: string): Promise<string | null>\n getBuffer(key: string): Promise<Buffer | null>\n set(key: string, value: string | Buffer): Promise<unknown>\n setex(key: string, ttlSeconds: number, value: string | Buffer): Promise<unknown>\n del(key: string): Promise<unknown>\n exists(key: string): Promise<number>\n keys(pattern: string): Promise<string[]>\n smembers(key: string): Promise<string[]>\n pipeline(): RedisPipeline\n quit(): Promise<void>\n once?(event: 'end', listener: () => void): void\n}\n\ntype RedisConstructor = new (url?: string, options?: { protocol?: RedisProtocolVersion }) => RedisClient\ntype PossibleRedisModule = unknown\n\ntype RequireFn = (id: string) => unknown\n\n/**\n * Reaps orphaned members from ONE tag set atomically.\n *\n * `KEYS[1]` is the tag key and `KEYS[2..]` are the candidate value keys, with\n * `ARGV[1..]` the member names aligned to them. Every key the script touches is\n * declared in `KEYS` so it stays valid against a slot-aware deployment.\n *\n * The `EXISTS` check and the `SREM` run in one atomic step, which is the whole\n * point: a member may only be dropped while its value key is genuinely absent.\n * A concurrent `set()` either lands first (`EXISTS` is 1, so the member is left\n * alone) or lands after (its own `sadd` re-adds the member). Either ordering\n * leaves a live key indexed.\n *\n * Deliberately single-tag. Pairing every orphan with every requested tag would\n * make the work quadratic in the tag count \u2014 and because a script blocks the\n * server for its whole run, that lands as a stall rather than as throughput.\n * Callers reap per tag using the membership each `smembers` actually returned.\n */\nconst REAP_ORPHANED_TAG_MEMBERS = `\nlocal removed = 0\nfor i = 2, #KEYS do\n if redis.call('EXISTS', KEYS[i]) == 0 then\n removed = removed + redis.call('SREM', KEYS[1], ARGV[i - 1])\n end\nend\nreturn removed\n`\n\n/** Bounds a single script's blocking time on the server, and the request size. */\nconst REAP_CHUNK_SIZE = 256\n\n/**\n * Redis cache strategy with tag support\n * Persistent across process restarts, can be shared across multiple instances\n * \n * Uses Redis data structures:\n * - Hash for storing cache entries: cache:{key} -> {value, tags, expiresAt, createdAt}\n * - Sets for tag index: tag:{tag} -> Set of keys\n */\nlet redisModulePromise: Promise<PossibleRedisModule> | null = null\ntype RedisRegistryEntry = { client?: RedisClient; creating?: Promise<RedisClient>; refs: number }\nconst redisRegistry = new Map<string, RedisRegistryEntry>()\n\nfunction resolveRequire(): RequireFn | null {\n const nonWebpack = (globalThis as { __non_webpack_require__?: unknown }).__non_webpack_require__\n if (typeof nonWebpack === 'function') return nonWebpack as RequireFn\n if (typeof require === 'function') return require as RequireFn\n if (typeof module !== 'undefined' && typeof module.require === 'function') {\n return module.require.bind(module)\n }\n try {\n const maybeRequire = Function('return typeof require !== \"undefined\" ? require : undefined')()\n if (typeof maybeRequire === 'function') return maybeRequire as RequireFn\n } catch {\n // ignore\n }\n return null\n}\n\nfunction loadRedisModuleViaRequire(): PossibleRedisModule | null {\n const resolver = resolveRequire()\n if (!resolver) return null\n try {\n return resolver('ioredis') as PossibleRedisModule\n } catch {\n return null\n }\n}\n\nfunction pickRedisConstructor(mod: PossibleRedisModule): RedisConstructor | null {\n const queue: unknown[] = [mod]\n const seen = new Set<unknown>()\n while (queue.length) {\n const current = queue.shift()\n if (!current || seen.has(current)) continue\n seen.add(current)\n if (typeof current === 'function') return current as RedisConstructor\n if (typeof current === 'object') {\n queue.push((current as { default?: unknown }).default)\n queue.push((current as { Redis?: unknown }).Redis)\n queue.push((current as { module?: { exports?: unknown } }).module?.exports)\n queue.push((current as { exports?: unknown }).exports)\n }\n }\n return null\n}\n\nasync function loadRedisModule(): Promise<PossibleRedisModule> {\n if (!redisModulePromise) {\n redisModulePromise = (async () => {\n const required = loadRedisModuleViaRequire() ?? (await import('ioredis'))\n return required as PossibleRedisModule\n })().catch((error) => {\n redisModulePromise = null\n throw new CacheDependencyUnavailableError('redis', 'ioredis', error)\n })\n }\n return redisModulePromise\n}\n\nfunction retainRedisEntry(key: string): RedisRegistryEntry {\n let entry = redisRegistry.get(key)\n if (!entry) {\n entry = { refs: 0 }\n redisRegistry.set(key, entry)\n }\n entry.refs += 1\n return entry\n}\n\nasync function acquireRedisClient(key: string, entry: RedisRegistryEntry): Promise<RedisClient> {\n if (entry.client) return entry.client\n if (entry.creating) return entry.creating\n entry.creating = loadRedisModule()\n .then((mod) => {\n const ctor = pickRedisConstructor(mod)\n if (!ctor) {\n throw new CacheDependencyUnavailableError('redis', 'ioredis', new Error('No usable Redis constructor'))\n }\n const client = new ctor(key, { protocol: REDIS_WIRE_PROTOCOL })\n entry.client = client\n entry.creating = undefined\n client.once?.('end', () => {\n if (redisRegistry.get(key) === entry && entry.refs === 0) {\n redisRegistry.delete(key)\n } else if (redisRegistry.get(key) === entry) {\n entry.client = undefined\n }\n })\n return client\n })\n .catch((error) => {\n entry.creating = undefined\n throw error\n })\n return entry.creating\n}\n\nasync function releaseRedisEntry(key: string, entry: RedisRegistryEntry): Promise<void> {\n entry.refs = Math.max(0, entry.refs - 1)\n if (entry.refs > 0) return\n redisRegistry.delete(key)\n if (entry.client) {\n try {\n await entry.client.quit()\n } catch {\n // ignore shutdown errors\n } finally {\n entry.client = undefined\n }\n }\n}\n\nexport function createRedisStrategy(redisUrl?: string, options?: { defaultTtl?: number } & CacheCodecOptions): CacheStrategy {\n const defaultTtl = options?.defaultTtl\n const codec = resolveCacheCodecConfig(options)\n const keyPrefix = 'cache:'\n const tagPrefix = 'tag:'\n const connectionUrl = redisUrl || getRedisUrlOrThrow('CACHE')\n const registryEntry = retainRedisEntry(connectionUrl)\n let redis: RedisClient | null = registryEntry.client ?? null\n\n async function getRedisClient(): Promise<RedisClient> {\n if (redis) return redis\n\n redis = await acquireRedisClient(connectionUrl, registryEntry)\n return redis\n }\n\n function getCacheKey(key: string): string {\n return `${keyPrefix}${key}`\n }\n\n function getTagKey(tag: string): string {\n return `${tagPrefix}${tag}`\n }\n\n function isExpired(entry: CacheEntry): boolean {\n if (entry.expiresAt === null) return false\n return Date.now() > entry.expiresAt\n }\n\n const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const data = await client.getBuffer(cacheKey)\n\n if (!data) return null\n\n try {\n const entry: CacheEntry = JSON.parse(decodeCacheText(data))\n\n if (isExpired(entry)) {\n if (options?.returnExpired) {\n return entry.value\n }\n // Clean up expired entry\n await deleteKey(key)\n return null\n }\n\n return entry.value\n } catch {\n // Invalid JSON, remove it\n await client.del(cacheKey)\n return null\n }\n }\n\n const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Remove old entry from tag index if it exists\n const oldData = await client.getBuffer(cacheKey)\n if (oldData) {\n try {\n const oldEntry: CacheEntry = JSON.parse(decodeCacheText(oldData))\n // Remove from old tags\n const pipeline = client.pipeline()\n for (const tag of oldEntry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n await pipeline.exec()\n } catch {\n // Ignore parse errors\n }\n }\n\n const ttl = options?.ttl ?? defaultTtl\n const tags = options?.tags || []\n const expiresAt = ttl ? Date.now() + ttl : null\n\n const entry: CacheEntry = {\n key,\n value,\n tags,\n expiresAt,\n createdAt: Date.now(),\n }\n\n const pipeline = client.pipeline()\n\n // Store the entry\n const serialized = encodeCacheText(JSON.stringify(entry), codec)\n if (ttl) {\n pipeline.setex(cacheKey, Math.ceil(ttl / 1000), serialized)\n } else {\n pipeline.set(cacheKey, serialized)\n }\n\n // Add to tag index\n for (const tag of tags) {\n pipeline.sadd(getTagKey(tag), key)\n }\n\n await pipeline.exec()\n }\n\n const has = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n const exists = await client.exists(cacheKey)\n\n if (!exists) return false\n\n // Check if expired\n const data = await client.getBuffer(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(decodeCacheText(data))\n if (isExpired(entry)) {\n await deleteKey(key)\n return false\n }\n return true\n } catch {\n return false\n }\n }\n\n const deleteKey = async (key: string): Promise<boolean> => {\n const client = await getRedisClient()\n const cacheKey = getCacheKey(key)\n\n // Get entry to remove from tag index\n const data = await client.getBuffer(cacheKey)\n if (!data) return false\n\n try {\n const entry: CacheEntry = JSON.parse(decodeCacheText(data))\n const pipeline = client.pipeline()\n\n // Remove from tag index\n for (const tag of entry.tags) {\n pipeline.srem(getTagKey(tag), key)\n }\n\n // Delete the cache entry\n pipeline.del(cacheKey)\n\n await pipeline.exec()\n return true\n } catch {\n // Just delete the key if we can't parse it\n await client.del(cacheKey)\n return true\n }\n }\n\n const deleteByTags = async (tags: string[]): Promise<number> => {\n const client = await getRedisClient()\n const keysToDelete = new Set<string>()\n // Keep which tag each key came from. Reaping needs it to stay linear in the\n // memberships that actually exist rather than in tags x keys.\n const membersByTagKey = new Map<string, string[]>()\n\n // Collect all keys that have any of the specified tags\n for (const tag of tags) {\n const tagKey = getTagKey(tag)\n const keys = await client.smembers(tagKey)\n membersByTagKey.set(tagKey, keys)\n for (const key of keys) {\n keysToDelete.add(key)\n }\n }\n\n // Delete all collected keys\n let deleted = 0\n const orphans = new Set<string>()\n for (const key of keysToDelete) {\n const success = await deleteKey(key)\n if (success) deleted++\n else orphans.add(key)\n }\n\n // A key whose value has already expired leaves its name behind in every tag\n // set it was added to: `set()` only prunes tags when it overwrites a live\n // key, `deleteKey` only prunes when it finds one, and `cleanup()` scans\n // value keys rather than tag sets. Nothing else reaps them, so the sets grow\n // without bound whenever TTL'd entries also rotate their key names \u2014 and\n // every later invalidation pays a GET per orphan. Reap what we just walked.\n //\n // `deleteKey` reported these missing some time ago \u2014 the walk above is one\n // round trip per member \u2014 so a concurrent request may have rebuilt any of\n // them since. Re-check each value key atomically with the removal rather\n // than trusting that stale read: dropping a live key from the index would\n // silently exempt it from every future invalidation of this tag.\n //\n // Reap per tag, over that tag's own members, so the work stays linear in the\n // memberships `smembers` actually returned. Each script is capped at\n // REAP_CHUNK_SIZE members, so no single atomic call can stall the server for\n // longer than a fixed bound however many tags were requested. The calls are\n // pipelined into one round trip, but stay separate scripts so Redis can\n // serve other clients between them.\n if (orphans.size > 0) {\n const pipeline = client.pipeline()\n let queued = 0\n for (const [tagKey, members] of membersByTagKey) {\n const tagOrphans = members.filter((member) => orphans.has(member))\n for (let offset = 0; offset < tagOrphans.length; offset += REAP_CHUNK_SIZE) {\n const chunk = tagOrphans.slice(offset, offset + REAP_CHUNK_SIZE)\n const keys = [tagKey, ...chunk.map(getCacheKey)]\n pipeline.eval(REAP_ORPHANED_TAG_MEMBERS, keys.length, ...keys, ...chunk)\n queued++\n }\n }\n if (queued > 0) await pipeline.exec()\n }\n\n return deleted\n }\n\n const clear = async (): Promise<number> => {\n const client = await getRedisClient()\n \n // Get all cache keys\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n const tagKeys = await client.keys(`${tagPrefix}*`)\n\n if (cacheKeys.length === 0 && tagKeys.length === 0) return 0\n\n const pipeline = client.pipeline()\n for (const key of [...cacheKeys, ...tagKeys]) {\n pipeline.del(key)\n }\n\n await pipeline.exec()\n return cacheKeys.length\n }\n\n const keys = async (pattern?: string): Promise<string[]> => {\n const client = await getRedisClient()\n const searchPattern = pattern \n ? `${keyPrefix}${pattern}` \n : `${keyPrefix}*`\n \n const cacheKeys = await client.keys(searchPattern)\n \n // Remove prefix from keys\n const result = cacheKeys.map((key: string) => key.substring(keyPrefix.length))\n \n if (!pattern) return result\n \n // Apply pattern matching (Redis KEYS command uses glob pattern, but we want our pattern)\n return result.filter((key: string) => matchCacheKeyPattern(key, pattern))\n }\n\n const stats = async (): Promise<{ size: number; expired: number }> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let expired = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.getBuffer(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(decodeCacheText(data))\n if (isExpired(entry)) {\n expired++\n }\n } catch {\n // Ignore parse errors\n }\n }\n }\n\n return { size: cacheKeys.length, expired }\n }\n\n const cleanup = async (): Promise<number> => {\n const client = await getRedisClient()\n const cacheKeys = await client.keys(`${keyPrefix}*`)\n \n let removed = 0\n for (const cacheKey of cacheKeys) {\n const data = await client.getBuffer(cacheKey)\n if (data) {\n try {\n const entry: CacheEntry = JSON.parse(decodeCacheText(data))\n if (isExpired(entry)) {\n const key = cacheKey.substring(keyPrefix.length)\n await deleteKey(key)\n removed++\n }\n } catch {\n // Remove invalid entries\n await client.del(cacheKey)\n removed++\n }\n }\n }\n\n return removed\n }\n\n const close = async (): Promise<void> => {\n await releaseRedisEntry(connectionUrl, registryEntry)\n redis = null\n }\n\n const healthcheck = async (): Promise<void> => {\n const client = await getRedisClient()\n await client.ping()\n }\n\n return {\n get,\n set,\n has,\n delete: deleteKey,\n deleteByTags,\n clear,\n keys,\n stats,\n healthcheck,\n cleanup,\n close,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["options", "pipeline", "keys"]
|
|
7
7
|
}
|
|
@@ -4,10 +4,12 @@ import path from "node:path";
|
|
|
4
4
|
import { CacheDependencyUnavailableError } from "../errors.js";
|
|
5
5
|
import { DEFAULT_SQLITE_CACHE_PATH } from "../defaults.js";
|
|
6
6
|
import { matchCacheKeyPattern } from "../patterns.js";
|
|
7
|
+
import { decodeCacheText, encodeCacheText, resolveCacheCodecConfig } from "../codec.js";
|
|
7
8
|
const sqliteRequire = createRequire(path.join(process.cwd(), "package.json"));
|
|
8
9
|
function createSqliteStrategy(dbPath, options) {
|
|
9
10
|
let db = null;
|
|
10
11
|
const defaultTtl = options?.defaultTtl;
|
|
12
|
+
const codec = resolveCacheCodecConfig(options);
|
|
11
13
|
const filePath = dbPath || process.env.CACHE_SQLITE_PATH || DEFAULT_SQLITE_CACHE_PATH;
|
|
12
14
|
async function getDb() {
|
|
13
15
|
if (db) return db;
|
|
@@ -58,7 +60,7 @@ function createSqliteStrategy(dbPath, options) {
|
|
|
58
60
|
const row = stmt.get(key);
|
|
59
61
|
if (!row) return null;
|
|
60
62
|
try {
|
|
61
|
-
const value = JSON.parse(row.value);
|
|
63
|
+
const value = JSON.parse(decodeCacheText(row.value));
|
|
62
64
|
const expiresAt = row.expires_at;
|
|
63
65
|
if (isExpired(expiresAt)) {
|
|
64
66
|
if (options2?.returnExpired) {
|
|
@@ -79,7 +81,7 @@ function createSqliteStrategy(dbPath, options) {
|
|
|
79
81
|
const tags = options2?.tags || [];
|
|
80
82
|
const expiresAt = ttl ? Date.now() + ttl : null;
|
|
81
83
|
const createdAt = Date.now();
|
|
82
|
-
const serialized = JSON.stringify(value);
|
|
84
|
+
const serialized = encodeCacheText(JSON.stringify(value), codec);
|
|
83
85
|
database.transaction(() => {
|
|
84
86
|
database.prepare("DELETE FROM cache_tags WHERE key = ?").run(key);
|
|
85
87
|
database.prepare(`
|