@blamejs/core 0.7.18 → 0.7.20

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.
Files changed (169) hide show
  1. package/CHANGELOG.md +427 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +350 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +399 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/fetch-metadata.js +129 -0
  86. package/lib/middleware/health.js +392 -392
  87. package/lib/middleware/index.js +85 -79
  88. package/lib/middleware/rate-limit.js +358 -358
  89. package/lib/middleware/request-id.js +61 -61
  90. package/lib/middleware/request-log.js +168 -168
  91. package/lib/middleware/require-auth.js +104 -104
  92. package/lib/middleware/security-headers.js +121 -116
  93. package/lib/middleware/sse.js +166 -166
  94. package/lib/migrations.js +383 -383
  95. package/lib/mtls-ca.js +518 -518
  96. package/lib/mtls-engine-default.js +481 -481
  97. package/lib/network-dns.js +632 -632
  98. package/lib/network-heartbeat.js +290 -290
  99. package/lib/network-nts.js +574 -574
  100. package/lib/network-proxy.js +265 -265
  101. package/lib/network-tls.js +328 -328
  102. package/lib/network.js +233 -233
  103. package/lib/notify.js +612 -612
  104. package/lib/ntp-check.js +229 -229
  105. package/lib/numeric-bounds.js +111 -111
  106. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  107. package/lib/object-store/azure-blob.js +488 -488
  108. package/lib/object-store/gcs-bucket-ops.js +351 -351
  109. package/lib/object-store/gcs.js +519 -519
  110. package/lib/object-store/http-put.js +153 -153
  111. package/lib/object-store/index.js +197 -197
  112. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  113. package/lib/object-store/sigv4.js +903 -903
  114. package/lib/observability.js +151 -151
  115. package/lib/otel-export.js +269 -269
  116. package/lib/pagination.js +464 -464
  117. package/lib/parsers/index.js +80 -80
  118. package/lib/parsers/safe-env.js +642 -642
  119. package/lib/parsers/safe-ini.js +292 -292
  120. package/lib/parsers/safe-toml.js +784 -784
  121. package/lib/parsers/safe-xml.js +390 -390
  122. package/lib/parsers/safe-yaml.js +1015 -1015
  123. package/lib/permissions.js +708 -708
  124. package/lib/pqc-agent.js +87 -87
  125. package/lib/pqc-gate.js +279 -279
  126. package/lib/protobuf-encoder.js +190 -190
  127. package/lib/protocol-dispatcher.js +161 -161
  128. package/lib/pubsub-redis.js +167 -167
  129. package/lib/pubsub.js +429 -429
  130. package/lib/queue-local.js +476 -476
  131. package/lib/queue-redis.js +745 -745
  132. package/lib/queue-sqs.js +319 -319
  133. package/lib/queue.js +695 -695
  134. package/lib/redis-client.js +519 -519
  135. package/lib/request-helpers.js +340 -340
  136. package/lib/restore-bundle.js +237 -237
  137. package/lib/restore-rollback.js +259 -259
  138. package/lib/restore.js +409 -409
  139. package/lib/retry.js +376 -376
  140. package/lib/router.js +748 -748
  141. package/lib/safe-async.js +735 -735
  142. package/lib/safe-buffer.js +237 -237
  143. package/lib/safe-json.js +541 -541
  144. package/lib/safe-schema.js +1266 -1266
  145. package/lib/safe-url.js +159 -159
  146. package/lib/scheduler.js +706 -706
  147. package/lib/security-assert.js +373 -373
  148. package/lib/seeders.js +618 -618
  149. package/lib/session.js +535 -478
  150. package/lib/slug.js +269 -269
  151. package/lib/ssrf-guard.js +401 -401
  152. package/lib/static.js +7 -5
  153. package/lib/storage.js +471 -471
  154. package/lib/subject.js +281 -281
  155. package/lib/template.js +791 -791
  156. package/lib/testing.js +798 -798
  157. package/lib/time.js +310 -310
  158. package/lib/totp.js +302 -302
  159. package/lib/tracing.js +494 -494
  160. package/lib/uuid.js +132 -132
  161. package/lib/validate-opts.js +340 -340
  162. package/lib/vault/index.js +308 -308
  163. package/lib/vault/rotate.js +784 -784
  164. package/lib/vault/wrap.js +296 -296
  165. package/lib/vendor/noble-ciphers.cjs +9 -9
  166. package/lib/webhook.js +595 -595
  167. package/lib/websocket.js +1048 -1048
  168. package/package.json +77 -77
  169. package/sbom.cyclonedx.json +7 -7
package/lib/cache.js CHANGED
@@ -1,1206 +1,1206 @@
1
- "use strict";
2
- /**
3
- * b.cache — operator-facing cache primitive.
4
- *
5
- * var cache = b.cache.create({
6
- * namespace: "session.user",
7
- * backend: "memory",
8
- * ttlMs: C.TIME.minutes(5),
9
- * maxEntries: 10000,
10
- * maxBytes: C.BYTES.mib(100), // memory backend only
11
- * sizeOf: function (v) { return v.byteLength; }, // optional override
12
- * slidingTtl: true, // bump expiresAt on hit
13
- * audit: b.audit, // optional
14
- * });
15
- *
16
- * await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
17
- * var hit = await cache.get("u-42");
18
- *
19
- * // Memoize / read-through:
20
- * var profile = await cache.wrap("u-42", function () {
21
- * return db.users.findOne({ _id: "u-42" });
22
- * });
23
- *
24
- * // Bulk invalidate (memory backend):
25
- * await cache.invalidateTag("user:42"); // purges every entry tagged user:42
26
- *
27
- * Surface (returned by create):
28
- *
29
- * get(key) → value | undefined
30
- * set(key, value, opts?) → void (opts: { ttlMs, tags })
31
- * del(key) → boolean (existed)
32
- * has(key) → boolean (does NOT bump LRU recency)
33
- * clear(opts?) → number (purged) (opts: { req, context })
34
- * size() → number
35
- * bytes() → number (memory backend only — total stored bytes)
36
- * wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
37
- * invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
38
- * getTags(key) → string[] | null
39
- * close() → void
40
- *
41
- * Backends:
42
- *
43
- * "memory" (default) — Map + LRU eviction (maxEntries) + periodic
44
- * sweep timer (sweepIntervalMs). Single-process accuracy only.
45
- *
46
- * "cluster" — _blamejs_cache table via cluster-storage. PRIMARY KEY
47
- * is "<namespace>:<key>" so one table serves every CacheInstance.
48
- * UPSERT via ON CONFLICT for atomic set; DELETE WHERE expiresAt
49
- * for sweep. JSON-only value serialization.
50
- *
51
- * { get, set, del, clear, size, close } — operator-supplied custom
52
- * backend (Redis, Memcached, …). All methods async.
53
- *
54
- * Validation policy:
55
- *
56
- * - create() opts → throw at boot
57
- * - get/set/del/has/wrap key arg type → throw at call site (programming bug)
58
- * - set value type → tolerant (operator decides what to store)
59
- * - per-call ttlMs override → throw at call site (bad ttl is silent footgun)
60
- * - audit / observability emit failures → drop silent (hot-path sink)
61
- * - method called after close() → throw BAD_STATE at call site
62
- *
63
- * Security defaults:
64
- *
65
- * - auditClear: true — mass purge is operator-action shaped (can hide forensics)
66
- * - auditFailures: true — backend errors are signal
67
- * - hot-path get/set/hit/miss/eviction → observability only (audit chain
68
- * would drown at any reasonable QPS)
69
- *
70
- * The cache supports single-flight wrap (concurrent calls collapse),
71
- * stale-while-revalidate, LRU + bytes eviction on the memory backend,
72
- * sliding TTL on hit, tag-based bulk invalidation (memory backend), a
73
- * shared cluster backend, and a custom-backend escape hatch.
74
- *
75
- * What is NOT in the box:
76
- *
77
- * - maxBytes on the cluster backend — per-row size accounting against
78
- * a shared table would mean an aggregate query on every set. The
79
- * operator controls cluster-table size with their own pruning if
80
- * bytes pressure surfaces.
81
- * - Per-entry exact slidingTtl on the cluster backend — sliding works
82
- * on cluster but extends by the cache's defaultTtlMs (we don't
83
- * store per-row ttl). Operators with mixed-TTL writes wanting
84
- * strict per-entry sliding use the memory backend or extend at
85
- * the application layer.
86
- */
87
-
88
- var cacheRedis = require("./cache-redis");
89
- var redisClient = require("./redis-client");
90
- var clusterStorage = require("./cluster-storage");
91
- var C = require("./constants");
92
- var lazyRequire = require("./lazy-require");
93
- var { boot } = require("./log");
94
- var numericChecks = require("./numeric-checks");
95
- var requestHelpers = require("./request-helpers");
96
- var safeAsync = require("./safe-async");
97
- var safeJson = require("./safe-json");
98
- var validateOpts = require("./validate-opts");
99
- var { CacheError } = require("./framework-error");
100
-
101
- var log = boot("cache");
102
- var observability = lazyRequire(function () { return require("./observability"); });
103
-
104
- var _err = CacheError.factory;
105
-
106
- var DEFAULTS = Object.freeze({
107
- backend: "memory",
108
- ttlMs: C.TIME.minutes(5),
109
- maxEntries: C.BYTES.bytes(10000),
110
- maxBytes: Infinity,
111
- sweepIntervalMs: C.TIME.minutes(1),
112
- staleWhileRevalidate: false,
113
- slidingTtl: false,
114
- auditFailures: true,
115
- auditClear: true,
116
- });
117
-
118
- // ---- Config-time validation helpers (throw on bad input) ----
119
-
120
- var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
121
- var _isPositiveInt = numericChecks.isPositiveInt;
122
-
123
- // ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
124
- // or non-number is rejected.
125
- function _validateTtl(name, value) {
126
- if (value === Infinity) return;
127
- if (typeof value !== "number" || isNaN(value) || !isFinite(value) || value < 0) {
128
- throw _err("BAD_OPT", name + " must be a non-negative finite number or Infinity, got " +
129
- (typeof value) + " " + JSON.stringify(value));
130
- }
131
- }
132
-
133
- function _validateMaxEntries(value) {
134
- if (value === Infinity) return;
135
- if (!_isPositiveInt(value)) {
136
- throw _err("BAD_OPT", "cache.create: maxEntries must be a positive integer or Infinity, got " +
137
- JSON.stringify(value));
138
- }
139
- }
140
-
141
- function _validateMaxBytes(value) {
142
- if (value === Infinity) return;
143
- if (!_isFiniteNonNegative(value) || value < 1) {
144
- throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
145
- JSON.stringify(value));
146
- }
147
- }
148
-
149
- // Default sizeOf — best-effort byte estimate. Operators with structured
150
- // values (large objects, custom classes) should pass their own sizeOf
151
- // for accuracy.
152
- function _defaultSizeOf(value) {
153
- if (value === null || value === undefined) return 0;
154
- if (Buffer.isBuffer(value)) return value.length;
155
- if (typeof value === "string") return Buffer.byteLength(value, "utf8");
156
- if (typeof value === "number" || typeof value === "boolean") return C.BYTES.bytes(8);
157
- // Fallback: round-trip through JSON. Cost is real; documented in the
158
- // DEFAULTS docstring so operators with hot-path size accounting know
159
- // to supply their own sizeOf.
160
- try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
161
- catch (_e) { return 0; }
162
- }
163
-
164
- function _validateBackendObject(backend) {
165
- var required = ["get", "set", "del", "clear", "size", "close"];
166
- if (typeof backend !== "object" || backend === null) {
167
- throw _err("BAD_OPT", "cache.create: custom backend must be an object");
168
- }
169
- for (var i = 0; i < required.length; i++) {
170
- if (typeof backend[required[i]] !== "function") {
171
- throw _err("BAD_OPT", "cache.create: custom backend missing method '" + required[i] +
172
- "' (required: " + required.join(", ") + ")");
173
- }
174
- }
175
- }
176
-
177
- function _validateCreateOpts(opts) {
178
- validateOpts.requireObject(opts, "cache.create", CacheError);
179
- validateOpts.requireNonEmptyString(opts.namespace, "cache.create: namespace", CacheError, "BAD_OPT");
180
- // Composite cluster-key separator is ":" — namespace must not contain it
181
- // or two namespaces could collide ("a:b" + "c" = "a:b:c" = "a" + "b:c").
182
- if (opts.namespace.indexOf(":") !== -1) {
183
- throw _err("BAD_OPT", "cache.create: namespace must not contain ':' (used as cluster-key separator), got " +
184
- JSON.stringify(opts.namespace));
185
- }
186
- if (opts.backend !== undefined) {
187
- if (typeof opts.backend === "string") {
188
- if (opts.backend !== "memory" && opts.backend !== "cluster" && opts.backend !== "redis") {
189
- throw _err("BAD_OPT", "cache.create: backend string must be 'memory' | 'cluster' | 'redis', got " +
190
- JSON.stringify(opts.backend));
191
- }
192
- if (opts.backend === "redis") {
193
- if (typeof opts.redisUrl !== "string" || opts.redisUrl.length === 0) {
194
- throw _err("BAD_OPT", "cache.create: backend='redis' requires opts.redisUrl (e.g. redis://localhost:6379/0)");
195
- }
196
- }
197
- } else {
198
- _validateBackendObject(opts.backend);
199
- }
200
- }
201
- if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
202
- if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
203
- if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
204
- validateOpts.optionalFunction(opts.sizeOf, "cache.create: sizeOf", CacheError);
205
- validateOpts.optionalBoolean(opts.slidingTtl, "cache.create: slidingTtl", CacheError);
206
- if (opts.sweepIntervalMs !== undefined) {
207
- validateOpts.optionalFiniteNonNegative(opts.sweepIntervalMs, "cache.create: sweepIntervalMs", CacheError);
208
- if (opts.sweepIntervalMs < C.TIME.seconds(1)) {
209
- throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be >= 1000ms, got " +
210
- JSON.stringify(opts.sweepIntervalMs));
211
- }
212
- }
213
- validateOpts.optionalBoolean(opts.staleWhileRevalidate, "cache.create: staleWhileRevalidate", CacheError);
214
- validateOpts.optionalBoolean(opts.auditFailures, "cache.create: auditFailures", CacheError);
215
- validateOpts.optionalBoolean(opts.auditClear, "cache.create: auditClear", CacheError);
216
- validateOpts.auditShape(opts.audit, "cache.create", CacheError);
217
- validateOpts.observabilityShape(opts.observability, "cache.create", CacheError);
218
- validateOpts.optionalFunction(opts.clock, "cache.create: clock", CacheError);
219
- }
220
-
221
- function _validateKey(key, ctx) {
222
- if (typeof key !== "string" || key.length === 0) {
223
- throw _err("BAD_KEY", ctx + ": key must be a non-empty string, got " +
224
- (typeof key) + " " + JSON.stringify(key));
225
- }
226
- }
227
-
228
- // ---- Memory backend ----
229
- // LRU realized by Map insertion order (Node Map iterates in insertion order;
230
- // re-inserting a key on hit moves it to the most-recent position).
231
-
232
- function _memoryBackend(cfg) {
233
- var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
234
- var maxEntries = cfg.maxEntries;
235
- var maxBytes = cfg.maxBytes;
236
- var sizeOf = cfg.sizeOf;
237
- var slidingTtl = cfg.slidingTtl;
238
- var clock = cfg.clock;
239
- var emitObs = cfg.emitObs;
240
- var namespace = cfg.namespace;
241
- var sweepTimer = null;
242
- var totalBytes = 0;
243
-
244
- // tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
245
- var tagIndex = new Map();
246
-
247
- function _isExpired(entry, now) {
248
- return entry.expiresAt !== Infinity && entry.expiresAt <= now;
249
- }
250
-
251
- function _untrack(key, entry) {
252
- if (!entry) return;
253
- totalBytes -= entry.bytes || 0;
254
- if (totalBytes < 0) totalBytes = 0;
255
- if (entry.tags && entry.tags.length > 0) {
256
- for (var i = 0; i < entry.tags.length; i++) {
257
- var s = tagIndex.get(entry.tags[i]);
258
- if (s) {
259
- s.delete(key);
260
- if (s.size === 0) tagIndex.delete(entry.tags[i]);
261
- }
262
- }
263
- }
264
- }
265
-
266
- function _evictByCounts() {
267
- while (maxEntries !== Infinity && entries.size > maxEntries) {
268
- var oldest = entries.keys().next().value;
269
- var e = entries.get(oldest);
270
- _untrack(oldest, e);
271
- entries.delete(oldest);
272
- emitObs("cache.eviction.size", { namespace: namespace });
273
- }
274
- while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
275
- var oldestB = entries.keys().next().value;
276
- var eb = entries.get(oldestB);
277
- _untrack(oldestB, eb);
278
- entries.delete(oldestB);
279
- emitObs("cache.eviction.bytes", { namespace: namespace });
280
- }
281
- }
282
-
283
- async function get(key) {
284
- var now = clock();
285
- var entry = entries.get(key);
286
- if (!entry) return undefined;
287
- if (_isExpired(entry, now)) {
288
- _untrack(key, entry);
289
- entries.delete(key);
290
- emitObs("cache.eviction.expired", { namespace: namespace });
291
- return undefined;
292
- }
293
- // Sliding TTL: extend lifetime on each successful read by the
294
- // entry's original ttlMs. Infinity stays Infinity.
295
- if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
296
- entry.expiresAt = now + entry.ttlMs;
297
- }
298
- // LRU recency bump: re-insert moves to the most-recent slot.
299
- entries.delete(key);
300
- entries.set(key, entry);
301
- return entry.value;
302
- }
303
-
304
- async function set(key, value, expiresAt, meta) {
305
- // Existing key replacement: untrack first to rebalance bytes + tags.
306
- var prior = entries.get(key);
307
- if (prior) {
308
- _untrack(key, prior);
309
- entries.delete(key);
310
- }
311
- var bytes = sizeOf(value) || 0;
312
- var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
313
- var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
314
- entries.set(key, {
315
- value: value,
316
- expiresAt: expiresAt,
317
- ttlMs: ttlMs,
318
- bytes: bytes,
319
- tags: tags,
320
- });
321
- totalBytes += bytes;
322
- if (tags && tags.length > 0) {
323
- for (var i = 0; i < tags.length; i++) {
324
- var s = tagIndex.get(tags[i]);
325
- if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
326
- s.add(key);
327
- }
328
- }
329
- _evictByCounts();
330
- }
331
-
332
- async function del(key) {
333
- var entry = entries.get(key);
334
- if (!entry) return false;
335
- _untrack(key, entry);
336
- entries.delete(key);
337
- return true;
338
- }
339
-
340
- async function has(key) {
341
- var entry = entries.get(key);
342
- if (!entry) return false;
343
- if (_isExpired(entry, clock())) {
344
- _untrack(key, entry);
345
- entries.delete(key);
346
- emitObs("cache.eviction.expired", { namespace: namespace });
347
- return false;
348
- }
349
- return true;
350
- }
351
-
352
- async function clear() {
353
- var n = entries.size;
354
- entries.clear();
355
- tagIndex.clear();
356
- totalBytes = 0;
357
- return n;
358
- }
359
-
360
- async function size() {
361
- // Lazy purge: count only non-expired so size() reflects "live" entries.
362
- var now = clock();
363
- var live = 0;
364
- for (var entry of entries.values()) {
365
- if (!_isExpired(entry, now)) live++;
366
- }
367
- return live;
368
- }
369
-
370
- async function invalidateTag(tag) {
371
- var keys = tagIndex.get(tag);
372
- if (!keys || keys.size === 0) return 0;
373
- var purged = 0;
374
- var toDelete = Array.from(keys);
375
- for (var i = 0; i < toDelete.length; i++) {
376
- var k = toDelete[i];
377
- var entry = entries.get(k);
378
- if (entry) {
379
- _untrack(k, entry);
380
- entries.delete(k);
381
- purged++;
382
- }
383
- }
384
- return purged;
385
- }
386
-
387
- async function getTags(key) {
388
- var entry = entries.get(key);
389
- if (!entry) return null;
390
- return entry.tags ? entry.tags.slice() : [];
391
- }
392
-
393
- async function bytes() {
394
- return totalBytes;
395
- }
396
-
397
- function _sweep() {
398
- var now = clock();
399
- var purged = 0;
400
- for (var k of Array.from(entries.keys())) {
401
- var e = entries.get(k);
402
- if (_isExpired(e, now)) {
403
- _untrack(k, e);
404
- entries.delete(k);
405
- purged++;
406
- }
407
- }
408
- if (purged > 0) {
409
- for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
410
- }
411
- }
412
-
413
- function _startSweep(intervalMs) {
414
- if (sweepTimer) return;
415
- sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep" });
416
- }
417
-
418
- async function close() {
419
- if (sweepTimer) { sweepTimer.stop(); sweepTimer = null; }
420
- entries.clear();
421
- tagIndex.clear();
422
- totalBytes = 0;
423
- }
424
-
425
- return {
426
- name: "memory",
427
- get: get,
428
- set: set,
429
- del: del,
430
- has: has,
431
- clear: clear,
432
- size: size,
433
- bytes: bytes,
434
- invalidateTag: invalidateTag,
435
- getTags: getTags,
436
- close: close,
437
- _startSweep: _startSweep,
438
- // Test hook: raw entries map for state inspection
439
- _entries: entries,
440
- };
441
- }
442
-
443
- // ---- Cluster backend ----
444
- // Single _blamejs_cache table; cacheKey = "<namespace>:<key>". JSON-only
445
- // value serialization. UPSERT via ON CONFLICT for atomic set.
446
-
447
- function _clusterBackend(cfg) {
448
- var namespace = cfg.namespace;
449
- var clock = cfg.clock;
450
- var emitObs = cfg.emitObs;
451
- var slidingTtl = cfg.slidingTtl;
452
- var defaultTtlMs = cfg.defaultTtlMs;
453
-
454
- // Composite cluster key. Namespace was validated to not contain ":"
455
- // at create time, so the split is unambiguous.
456
- function _composedKey(key) { return namespace + ":" + key; }
457
-
458
- async function get(key) {
459
- var now = clock();
460
- var result = await clusterStorage.execute(
461
- "SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?",
462
- [_composedKey(key)]
463
- );
464
- if (!result || !result.rows || result.rows.length === 0) return undefined;
465
- var row = result.rows[0];
466
- if (row.expiresAt <= now) {
467
- // Lazy purge: opportunistic delete on stale read.
468
- try {
469
- await clusterStorage.execute(
470
- "DELETE FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt <= ?",
471
- [_composedKey(key), now]
472
- );
473
- } catch (_e) { /* sweeper will catch it next pass */ }
474
- emitObs("cache.eviction.expired", { namespace: namespace });
475
- return undefined;
476
- }
477
- // Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
478
- // store per-row ttl). Operators with mixed-TTL writes wanting strict
479
- // per-entry sliding use the memory backend or extend at app layer.
480
- // Fire-and-forget — best-effort lifetime extension.
481
- if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
482
- var newExpires = now + defaultTtlMs;
483
- clusterStorage.execute(
484
- "UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
485
- "WHERE cacheKey = ? AND expiresAt > ?",
486
- [newExpires, now, _composedKey(key), now]
487
- ).catch(function () { /* best-effort */ });
488
- }
489
- try { return safeJson.parse(row.valueJson, { maxBytes: C.BYTES.mib(64) }); }
490
- catch (_e) { return undefined; }
491
- }
492
-
493
- async function set(key, value, expiresAt, meta) {
494
- var json = JSON.stringify(value);
495
- var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
496
- var now = clock();
497
- var ck = _composedKey(key);
498
- // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
499
- await clusterStorage.execute(
500
- "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
501
- "VALUES (?, ?, ?, ?) " +
502
- "ON CONFLICT (cacheKey) DO UPDATE SET " +
503
- "valueJson = ?, expiresAt = ?, updatedAt = ?",
504
- [ck, json, storedExpires, now, json, storedExpires, now]
505
- );
506
- // Tag handling: drop any prior tags for this key (tags can change
507
- // across sets), then INSERT the new ones. The PRIMARY KEY on
508
- // (cacheKey, tag) makes the INSERT idempotent if duplicate tags
509
- // sneak in.
510
- var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
511
- await clusterStorage.execute(
512
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
513
- [ck]
514
- );
515
- if (tags && tags.length > 0) {
516
- for (var i = 0; i < tags.length; i++) {
517
- await clusterStorage.execute(
518
- "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
519
- "ON CONFLICT (cacheKey, tag) DO NOTHING",
520
- [ck, tags[i]]
521
- );
522
- }
523
- }
524
- }
525
-
526
- async function del(key) {
527
- var ck = _composedKey(key);
528
- var result = await clusterStorage.execute(
529
- "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
530
- [ck]
531
- );
532
- // Drop any matching tag rows. Best-effort: a stale tag row pointing
533
- // at a non-existent cacheKey is dropped on the next invalidateTag
534
- // sweep (by the JOIN-shape DELETE) anyway.
535
- await clusterStorage.execute(
536
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
537
- [ck]
538
- ).catch(function () { /* best-effort */ });
539
- return !!(result && result.rowCount && result.rowCount > 0);
540
- }
541
-
542
- async function invalidateTag(tag) {
543
- // Find every cacheKey carrying the tag (namespace-scoped via the LIKE
544
- // on the composed key), delete from the cache table + the junction.
545
- var like = namespace + ":%";
546
- var keysResult = await clusterStorage.execute(
547
- "SELECT cacheKey FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
548
- [tag, like]
549
- );
550
- var keys = (keysResult && keysResult.rows) || [];
551
- if (keys.length === 0) {
552
- // Nothing to invalidate; still drop any orphan tag rows for
553
- // this tag scoped to our namespace.
554
- await clusterStorage.execute(
555
- "DELETE FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
556
- [tag, like]
557
- );
558
- return 0;
559
- }
560
- var purged = 0;
561
- for (var i = 0; i < keys.length; i++) {
562
- var ck = keys[i].cacheKey;
563
- var r = await clusterStorage.execute(
564
- "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
565
- [ck]
566
- );
567
- if (r && r.rowCount > 0) purged += r.rowCount;
568
- await clusterStorage.execute(
569
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
570
- [ck]
571
- );
572
- }
573
- return purged;
574
- }
575
-
576
- async function getTags(key) {
577
- var result = await clusterStorage.execute(
578
- "SELECT tag FROM _blamejs_cache_tags WHERE cacheKey = ?",
579
- [_composedKey(key)]
580
- );
581
- if (!result || !result.rows) return [];
582
- return result.rows.map(function (r) { return r.tag; });
583
- }
584
-
585
- async function has(key) {
586
- // Existence check without recency bump — cluster backend doesn't
587
- // track LRU at all, so "without bumping" is automatic. Honors
588
- // expiresAt the same as get().
589
- var now = clock();
590
- var result = await clusterStorage.execute(
591
- "SELECT expiresAt FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt > ?",
592
- [_composedKey(key), now]
593
- );
594
- return !!(result && result.rows && result.rows.length > 0);
595
- }
596
-
597
- async function clear() {
598
- // Namespace-scoped wipe so two CacheInstance instances sharing the
599
- // table don't cross-purge each other.
600
- var like = namespace + ":%";
601
- var result = await clusterStorage.execute(
602
- "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
603
- [like]
604
- );
605
- // Drop matching tag rows in the same namespace.
606
- await clusterStorage.execute(
607
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey LIKE ?",
608
- [like]
609
- ).catch(function () { /* best-effort */ });
610
- return (result && result.rowCount) || 0;
611
- }
612
-
613
- async function size() {
614
- var now = clock();
615
- var like = namespace + ":%";
616
- var result = await clusterStorage.execute(
617
- "SELECT COUNT(*) AS n FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt > ?",
618
- [like, now]
619
- );
620
- if (!result || !result.rows || result.rows.length === 0) return 0;
621
- return result.rows[0].n || 0;
622
- }
623
-
624
- async function _sweep() {
625
- var now = clock();
626
- var like = namespace + ":%";
627
- // Capture the to-be-purged keys first so we can drop matching tag
628
- // rows in the same sweep — keeps the junction table free of orphans
629
- // pointing at expired cacheKeys.
630
- var expiredResult = await clusterStorage.execute(
631
- "SELECT cacheKey FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
632
- [like, now]
633
- );
634
- var expiredKeys = (expiredResult && expiredResult.rows) || [];
635
- await clusterStorage.execute(
636
- "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
637
- [like, now]
638
- );
639
- for (var i = 0; i < expiredKeys.length; i++) {
640
- await clusterStorage.execute(
641
- "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
642
- [expiredKeys[i].cacheKey]
643
- ).catch(function () { /* best-effort */ });
644
- }
645
- }
646
-
647
- function _startSweep(intervalMs) {
648
- cfg._sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep-cluster" });
649
- }
650
-
651
- async function close() {
652
- if (cfg._sweepTimer) { cfg._sweepTimer.stop(); cfg._sweepTimer = null; }
653
- }
654
-
655
- return {
656
- name: "cluster",
657
- get: get,
658
- set: set,
659
- del: del,
660
- has: has,
661
- clear: clear,
662
- size: size,
663
- close: close,
664
- invalidateTag: invalidateTag,
665
- getTags: getTags,
666
- _startSweep: _startSweep,
667
- };
668
- }
669
-
670
- // ---- Custom backend wrapper ----
671
- // Operator-supplied { get, set, del, clear, size, close } — wrap to
672
- // uniform-shape (no _startSweep, _entries). The operator is responsible
673
- // for their own expiration; we pass expiresAt to set().
674
-
675
- function _customBackend(operatorBackend, cfg) {
676
- return {
677
- name: "custom",
678
- get: function (key) { return operatorBackend.get(key); },
679
- set: function (key, value, expiresAt, meta) {
680
- // Older 3-arg backends remain compatible — meta is opt-in.
681
- return operatorBackend.set(key, value, expiresAt, meta);
682
- },
683
- del: function (key) { return operatorBackend.del(key); },
684
- has: function (key) {
685
- // Optional has() — fall back to get-and-coerce if operator didn't
686
- // implement it.
687
- if (typeof operatorBackend.has === "function") return operatorBackend.has(key);
688
- return Promise.resolve(operatorBackend.get(key)).then(function (v) { return v !== undefined; });
689
- },
690
- clear: function () { return operatorBackend.clear(); },
691
- size: function () { return operatorBackend.size(); },
692
- bytes: function () {
693
- if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
694
- return Promise.resolve(0);
695
- },
696
- invalidateTag: function (tag) {
697
- if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
698
- return Promise.resolve(0);
699
- },
700
- getTags: function (key) {
701
- if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
702
- return Promise.resolve(null);
703
- },
704
- close: function () { return operatorBackend.close(); },
705
- _startSweep: function () { /* operator backend manages its own sweep */ },
706
- };
707
- }
708
-
709
- // ---- Public create ----
710
-
711
- function create(opts) {
712
- opts = opts || {};
713
- validateOpts(opts, [
714
- "namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
715
- "sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
716
- "auditFailures", "auditClear",
717
- "audit", "observability", "clock",
718
- // backend === "redis" connection options. Ignored for memory /
719
- // cluster / custom-backend modes.
720
- "redisUrl", "redisPassword", "redisUsername", "redisTls", "redisCa",
721
- "redisServername", "redisConnectTimeoutMs", "redisCommandTimeoutMs",
722
- "redisMaxReconnectAttempts",
723
- // Cross-node invalidation: when set, every successful
724
- // del/clear/invalidateTag publishes an event on the supplied
725
- // pubsub instance. Other cache instances on other nodes (or in
726
- // other processes sharing the pubsub backend) react locally —
727
- // mostly useful for the memory backend so stale per-node entries
728
- // don't survive a global tag wipe. The cluster + redis backends
729
- // are coherent by virtue of their shared store, but a hot
730
- // memory-tier on top of either still benefits.
731
- "invalidationPubsub",
732
- ], "cache");
733
- _validateCreateOpts(opts);
734
- var cfg0 = validateOpts.applyDefaults(opts, DEFAULTS);
735
-
736
- var namespace = opts.namespace;
737
- var backendKind = cfg0.backend;
738
- var defaultTtlMs = cfg0.ttlMs;
739
- var maxEntries = cfg0.maxEntries;
740
- var maxBytes = cfg0.maxBytes;
741
- var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
742
- var sweepIntervalMs = cfg0.sweepIntervalMs;
743
- var staleRevalidate = cfg0.staleWhileRevalidate;
744
- var slidingTtl = cfg0.slidingTtl;
745
- var auditFailures = cfg0.auditFailures;
746
- var auditClear = cfg0.auditClear;
747
- var audit = opts.audit || null;
748
- var operatorObs = opts.observability || null;
749
- var clock = opts.clock || function () { return Date.now(); };
750
- var invalidationPubsub = opts.invalidationPubsub || null;
751
- if (invalidationPubsub && (
752
- typeof invalidationPubsub.publish !== "function" ||
753
- typeof invalidationPubsub.subscribe !== "function" ||
754
- typeof invalidationPubsub.unsubscribe !== "function")) {
755
- throw _err("BAD_OPT",
756
- "cache.create: invalidationPubsub must implement { publish, subscribe, unsubscribe } (b.pubsub.create instance)");
757
- }
758
- var invalidationChannel = "cache:" + namespace + ":invalidate";
759
- var invalidationToken = null;
760
- // Re-entrancy guard — when we receive an invalidation event from
761
- // another node we MUST NOT re-publish it (infinite fan-out loop).
762
- var inboundInvalidation = false;
763
-
764
- function emitObs(name, labels) {
765
- try {
766
- if (operatorObs) operatorObs.event(name, 1, labels || {});
767
- else observability().event(name, 1, labels || {});
768
- } catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
769
- }
770
-
771
- var emitAudit = validateOpts.makeAuditEmitter(audit);
772
-
773
- function _actor(callerOpts) {
774
- return requestHelpers.resolveActorWithOverride(callerOpts);
775
- }
776
-
777
- function _backendFailedAudit(op, err) {
778
- if (!auditFailures) return;
779
- emitAudit("cache.backend.failed", {
780
- actor: requestHelpers.extractActorContext(null),
781
- resource: { kind: "cache", id: namespace },
782
- outcome: "failure",
783
- reason: "backend-error",
784
- metadata: { op: op, code: (err && err.code) || null, message: (err && err.message) || String(err) },
785
- });
786
- }
787
-
788
- // Resolve backend
789
- var cfg = {
790
- namespace: namespace,
791
- maxEntries: maxEntries,
792
- maxBytes: maxBytes,
793
- sizeOf: sizeOf,
794
- slidingTtl: slidingTtl,
795
- defaultTtlMs: defaultTtlMs,
796
- clock: clock,
797
- emitObs: emitObs,
798
- _sweepTimer: null,
799
- };
800
- var backend;
801
- if (backendKind === "memory") {
802
- backend = _memoryBackend(cfg);
803
- } else if (backendKind === "cluster") {
804
- backend = _clusterBackend(cfg);
805
- } else if (backendKind === "redis") {
806
- backend = _customBackend(cacheRedis.create(Object.assign(
807
- redisClient.pickClientOpts(opts, "redis"),
808
- {
809
- namespace: namespace,
810
- slidingTtl: slidingTtl,
811
- defaultTtlMs: defaultTtlMs,
812
- clock: clock,
813
- emitObs: emitObs,
814
- }
815
- )), cfg);
816
- } else {
817
- backend = _customBackend(opts.backend, cfg);
818
- }
819
-
820
- backend._startSweep(sweepIntervalMs);
821
-
822
- var closed = false;
823
- function _ensureOpen(method) {
824
- if (closed) {
825
- throw _err("BAD_STATE", "cache." + method + ": cache instance has been closed");
826
- }
827
- }
828
-
829
- // Single-flight inflight map for wrap()
830
- var inflight = new Map();
831
-
832
- // Stale-while-revalidate tracking (per-instance, in-memory). When SWR
833
- // is on, wrap() stores entries with a HARD TTL of 2× ttlMs and tracks
834
- // the SOFT expiration here. Reads after soft but before hard return
835
- // the cached value AND kick off a background refresh; reads after
836
- // hard fall through to a normal miss + compute. The soft-TTL map is
837
- // memory-only even when the backend is cluster — refreshes are a
838
- // best-effort optimization, not a correctness invariant, so a cache
839
- // miss after restart (no soft data) just means we serve fresh once.
840
- var softExpiry = new Map(); // key → softExpiresAt
841
- var swrInflight = new Map(); // key → background-refresh promise
842
- var SWR_HARD_MULTIPLIER = 2;
843
-
844
- // ---- Public methods ----
845
-
846
- function _resolveTtl(callerOpts, methodName) {
847
- if (callerOpts && callerOpts.ttlMs !== undefined) {
848
- _validateTtl("cache." + methodName + ": ttlMs", callerOpts.ttlMs);
849
- return callerOpts.ttlMs;
850
- }
851
- return defaultTtlMs;
852
- }
853
-
854
- async function get(key) {
855
- _ensureOpen("get");
856
- _validateKey(key, "cache.get");
857
- var v;
858
- try { v = await backend.get(key); }
859
- catch (e) {
860
- emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
861
- _backendFailedAudit("get", e);
862
- throw e;
863
- }
864
- if (v === undefined) emitObs("cache.miss", { namespace: namespace });
865
- else emitObs("cache.hit", { namespace: namespace });
866
- return v;
867
- }
868
-
869
- async function set(key, value, callerOpts) {
870
- _ensureOpen("set");
871
- _validateKey(key, "cache.set");
872
- var ttlMs = _resolveTtl(callerOpts, "set");
873
- if (ttlMs === 0) return; // 0 means "do not cache"
874
- var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
875
- var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
876
- if (tags) {
877
- for (var i = 0; i < tags.length; i++) {
878
- if (typeof tags[i] !== "string" || tags[i].length === 0) {
879
- throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
880
- }
881
- }
882
- }
883
- try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
884
- catch (e) {
885
- emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
886
- _backendFailedAudit("set", e);
887
- throw e;
888
- }
889
- emitObs("cache.set", { namespace: namespace });
890
- }
891
-
892
- async function del(key) {
893
- _ensureOpen("del");
894
- _validateKey(key, "cache.del");
895
- var existed;
896
- try { existed = await backend.del(key); }
897
- catch (e) {
898
- emitObs("cache.backend.failed", { namespace: namespace, op: "del" });
899
- _backendFailedAudit("del", e);
900
- throw e;
901
- }
902
- if (existed) emitObs("cache.del", { namespace: namespace });
903
- softExpiry.delete(key);
904
- _publishInvalidation({ kind: "del", key: key });
905
- return existed;
906
- }
907
-
908
- async function has(key) {
909
- _ensureOpen("has");
910
- _validateKey(key, "cache.has");
911
- try { return await backend.has(key); }
912
- catch (e) {
913
- emitObs("cache.backend.failed", { namespace: namespace, op: "has" });
914
- _backendFailedAudit("has", e);
915
- throw e;
916
- }
917
- }
918
-
919
- async function clear(callerOpts) {
920
- _ensureOpen("clear");
921
- var purged;
922
- try { purged = await backend.clear(); }
923
- catch (e) {
924
- emitObs("cache.backend.failed", { namespace: namespace, op: "clear" });
925
- _backendFailedAudit("clear", e);
926
- throw e;
927
- }
928
- emitObs("cache.clear", { namespace: namespace });
929
- if (auditClear) {
930
- emitAudit("cache.cleared", {
931
- actor: _actor(callerOpts),
932
- resource: { kind: "cache", id: namespace },
933
- outcome: "success",
934
- metadata: { itemCount: purged },
935
- });
936
- }
937
- // Drop any in-flight wrap promises — operator clear means "consumers
938
- // should re-fetch", and in-flight resolves would seed stale entries
939
- // post-clear.
940
- inflight.clear();
941
- swrInflight.clear();
942
- softExpiry.clear();
943
- _publishInvalidation({ kind: "clear" });
944
- return purged;
945
- }
946
-
947
- async function size() {
948
- _ensureOpen("size");
949
- try { return await backend.size(); }
950
- catch (e) {
951
- emitObs("cache.backend.failed", { namespace: namespace, op: "size" });
952
- _backendFailedAudit("size", e);
953
- throw e;
954
- }
955
- }
956
-
957
- async function bytes() {
958
- _ensureOpen("bytes");
959
- try {
960
- if (typeof backend.bytes !== "function") return 0;
961
- return await backend.bytes();
962
- } catch (e) {
963
- emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
964
- _backendFailedAudit("bytes", e);
965
- throw e;
966
- }
967
- }
968
-
969
- async function invalidateTag(tag, callerOpts) {
970
- _ensureOpen("invalidateTag");
971
- if (typeof tag !== "string" || tag.length === 0) {
972
- throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
973
- }
974
- if (typeof backend.invalidateTag !== "function") {
975
- throw _err("NOT_SUPPORTED",
976
- "cache.invalidateTag: backend '" + (backend.name || "custom") +
977
- "' does not implement invalidateTag. Operator-supplied custom backends " +
978
- "must export invalidateTag(tag) → number to participate in tag-based wipes.");
979
- }
980
- var purged;
981
- try { purged = await backend.invalidateTag(tag); }
982
- catch (e) {
983
- emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
984
- _backendFailedAudit("invalidateTag", e);
985
- throw e;
986
- }
987
- emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
988
- if (auditClear && purged > 0) {
989
- emitAudit("cache.tag.invalidated", {
990
- actor: _actor(callerOpts),
991
- resource: { kind: "cache.tag", id: namespace + ":" + tag },
992
- outcome: "success",
993
- metadata: { tag: tag, itemCount: purged },
994
- });
995
- }
996
- // Drop in-flight wrap promises whose key WOULD have just been
997
- // invalidated. We don't track per-key tags inflight, so a coarse
998
- // drop matches clear()'s safer-than-stale posture.
999
- inflight.clear();
1000
- swrInflight.clear();
1001
- _publishInvalidation({ kind: "tag", tag: tag });
1002
- return purged;
1003
- }
1004
-
1005
- async function getTags(key) {
1006
- _ensureOpen("getTags");
1007
- _validateKey(key, "cache.getTags");
1008
- if (typeof backend.getTags !== "function") return null;
1009
- try { return await backend.getTags(key); }
1010
- catch (e) {
1011
- emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
1012
- _backendFailedAudit("getTags", e);
1013
- throw e;
1014
- }
1015
- }
1016
-
1017
- function _backgroundRefresh(key, fn, ttlMs) {
1018
- if (swrInflight.has(key)) return; // already refreshing
1019
- var p = (async function () {
1020
- var startedAt = clock();
1021
- var computed;
1022
- try { computed = await fn(); }
1023
- finally {
1024
- emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
1025
- }
1026
- var expiresAt = _writeWithSwr(key, computed, ttlMs);
1027
- void expiresAt;
1028
- return computed;
1029
- })();
1030
- swrInflight.set(key, p);
1031
- p.then(
1032
- function () { swrInflight.delete(key); },
1033
- function (_e) {
1034
- swrInflight.delete(key);
1035
- // Background refresh failed; stale value already served. Surface
1036
- // via observability so operators see it without breaking the
1037
- // request that triggered the refresh.
1038
- emitObs("cache.refresh.failed", { namespace: namespace });
1039
- }
1040
- );
1041
- }
1042
-
1043
- function _writeWithSwr(key, value, ttlMs) {
1044
- if (ttlMs === 0) return null; // 0 means "do not cache"
1045
- var now = clock();
1046
- var hardTtlMs = (ttlMs === Infinity)
1047
- ? Infinity
1048
- : (staleRevalidate ? ttlMs * SWR_HARD_MULTIPLIER : ttlMs);
1049
- var expiresAt = (hardTtlMs === Infinity) ? Infinity : (now + hardTtlMs);
1050
- if (staleRevalidate && ttlMs !== Infinity) {
1051
- softExpiry.set(key, now + ttlMs);
1052
- } else {
1053
- softExpiry.delete(key);
1054
- }
1055
- // Backend write — failure surfaces via observability + audit but
1056
- // doesn't bubble (caller already has the computed value).
1057
- backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
1058
- emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
1059
- _backendFailedAudit("set", e);
1060
- });
1061
- return expiresAt;
1062
- }
1063
-
1064
- async function wrap(key, fn, callerOpts) {
1065
- _ensureOpen("wrap");
1066
- _validateKey(key, "cache.wrap");
1067
- if (typeof fn !== "function") {
1068
- throw _err("BAD_OPT", "cache.wrap: fn must be a function, got " + typeof fn);
1069
- }
1070
- var ttlMs = _resolveTtl(callerOpts, "wrap");
1071
- var singleFlight = !(callerOpts && callerOpts.singleFlight === false);
1072
-
1073
- var existing;
1074
- try { existing = await backend.get(key); }
1075
- catch (e) {
1076
- emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
1077
- _backendFailedAudit("get", e);
1078
- throw e;
1079
- }
1080
-
1081
- if (existing !== undefined) {
1082
- // SWR: served from backend, but might be stale (past soft TTL).
1083
- var soft = softExpiry.get(key);
1084
- var now = clock();
1085
- if (staleRevalidate && soft !== undefined && soft <= now) {
1086
- emitObs("cache.hit", { namespace: namespace });
1087
- _backgroundRefresh(key, fn, ttlMs);
1088
- return existing;
1089
- }
1090
- emitObs("cache.hit", { namespace: namespace });
1091
- return existing;
1092
- }
1093
- emitObs("cache.miss", { namespace: namespace });
1094
-
1095
- if (singleFlight && inflight.has(key)) {
1096
- emitObs("cache.wrap.singleflight.collapsed", { namespace: namespace });
1097
- return inflight.get(key);
1098
- }
1099
-
1100
- var promise = (async function () {
1101
- var startedAt = clock();
1102
- var computed;
1103
- try { computed = await fn(); }
1104
- finally {
1105
- emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
1106
- }
1107
- if (ttlMs !== 0) {
1108
- if (staleRevalidate) {
1109
- _writeWithSwr(key, computed, ttlMs);
1110
- } else {
1111
- var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
1112
- try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
1113
- catch (e) {
1114
- emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
1115
- _backendFailedAudit("set", e);
1116
- // Failed write doesn't fail the wrap — caller still gets the
1117
- // computed value; cache just didn't persist.
1118
- }
1119
- }
1120
- }
1121
- return computed;
1122
- })();
1123
- if (singleFlight) {
1124
- inflight.set(key, promise);
1125
- promise.then(
1126
- function () { inflight.delete(key); },
1127
- function () { inflight.delete(key); }
1128
- );
1129
- }
1130
- return promise;
1131
- }
1132
-
1133
- function _publishInvalidation(ev) {
1134
- if (!invalidationPubsub || inboundInvalidation) return;
1135
- try { invalidationPubsub.publish(invalidationChannel, ev); }
1136
- catch (_e) { /* publish best-effort — local invalidation already happened */ }
1137
- }
1138
-
1139
- async function _onInboundInvalidation(ev /*, meta */) {
1140
- if (!ev || closed) return;
1141
- inboundInvalidation = true;
1142
- try {
1143
- if (ev.kind === "tag" && typeof ev.tag === "string" &&
1144
- typeof backend.invalidateTag === "function") {
1145
- try { await backend.invalidateTag(ev.tag); }
1146
- catch (e) { log.debug("invalidation-apply-failed", { op: "invalidateTag", tag: ev.tag, error: e.message }); }
1147
- } else if (ev.kind === "del" && typeof ev.key === "string") {
1148
- try { await backend.del(ev.key); }
1149
- catch (e) { log.debug("invalidation-apply-failed", { op: "del", key: ev.key, error: e.message }); }
1150
- } else if (ev.kind === "clear") {
1151
- try { await backend.clear(); }
1152
- catch (e) { log.debug("invalidation-apply-failed", { op: "clear", error: e.message }); }
1153
- }
1154
- // Wipe local in-flight memoization so a freshly-invalidated key
1155
- // can't resolve from a still-pending fetch on this node.
1156
- inflight.clear();
1157
- swrInflight.clear();
1158
- softExpiry.clear();
1159
- } finally {
1160
- inboundInvalidation = false;
1161
- }
1162
- }
1163
-
1164
- if (invalidationPubsub) {
1165
- invalidationToken = invalidationPubsub.subscribe(invalidationChannel, _onInboundInvalidation);
1166
- }
1167
-
1168
- async function close() {
1169
- if (closed) return;
1170
- closed = true;
1171
- if (invalidationPubsub && invalidationToken) {
1172
- try { invalidationPubsub.unsubscribe(invalidationToken); }
1173
- catch (e) { log.debug("close-cleanup-failed", { op: "unsubscribe", error: e.message }); }
1174
- invalidationToken = null;
1175
- }
1176
- inflight.clear();
1177
- swrInflight.clear();
1178
- softExpiry.clear();
1179
- try { await backend.close(); }
1180
- catch (_e) { /* close best-effort */ }
1181
- }
1182
-
1183
- return {
1184
- get: get,
1185
- set: set,
1186
- del: del,
1187
- has: has,
1188
- clear: clear,
1189
- size: size,
1190
- bytes: bytes,
1191
- wrap: wrap,
1192
- invalidateTag: invalidateTag,
1193
- getTags: getTags,
1194
- close: close,
1195
- namespace: namespace,
1196
- // Test hooks
1197
- _backend: backend,
1198
- _inflight: inflight,
1199
- };
1200
- }
1201
-
1202
- module.exports = {
1203
- create: create,
1204
- CacheError: CacheError,
1205
- DEFAULTS: DEFAULTS,
1206
- };
1
+ "use strict";
2
+ /**
3
+ * b.cache — operator-facing cache primitive.
4
+ *
5
+ * var cache = b.cache.create({
6
+ * namespace: "session.user",
7
+ * backend: "memory",
8
+ * ttlMs: C.TIME.minutes(5),
9
+ * maxEntries: 10000,
10
+ * maxBytes: C.BYTES.mib(100), // memory backend only
11
+ * sizeOf: function (v) { return v.byteLength; }, // optional override
12
+ * slidingTtl: true, // bump expiresAt on hit
13
+ * audit: b.audit, // optional
14
+ * });
15
+ *
16
+ * await cache.set("u-42", record, { ttlMs: C.TIME.minutes(10), tags: ["user:42", "session"] });
17
+ * var hit = await cache.get("u-42");
18
+ *
19
+ * // Memoize / read-through:
20
+ * var profile = await cache.wrap("u-42", function () {
21
+ * return db.users.findOne({ _id: "u-42" });
22
+ * });
23
+ *
24
+ * // Bulk invalidate (memory backend):
25
+ * await cache.invalidateTag("user:42"); // purges every entry tagged user:42
26
+ *
27
+ * Surface (returned by create):
28
+ *
29
+ * get(key) → value | undefined
30
+ * set(key, value, opts?) → void (opts: { ttlMs, tags })
31
+ * del(key) → boolean (existed)
32
+ * has(key) → boolean (does NOT bump LRU recency)
33
+ * clear(opts?) → number (purged) (opts: { req, context })
34
+ * size() → number
35
+ * bytes() → number (memory backend only — total stored bytes)
36
+ * wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
37
+ * invalidateTag(tag, opts?) → number (purged) (opts: { req, context })
38
+ * getTags(key) → string[] | null
39
+ * close() → void
40
+ *
41
+ * Backends:
42
+ *
43
+ * "memory" (default) — Map + LRU eviction (maxEntries) + periodic
44
+ * sweep timer (sweepIntervalMs). Single-process accuracy only.
45
+ *
46
+ * "cluster" — _blamejs_cache table via cluster-storage. PRIMARY KEY
47
+ * is "<namespace>:<key>" so one table serves every CacheInstance.
48
+ * UPSERT via ON CONFLICT for atomic set; DELETE WHERE expiresAt
49
+ * for sweep. JSON-only value serialization.
50
+ *
51
+ * { get, set, del, clear, size, close } — operator-supplied custom
52
+ * backend (Redis, Memcached, …). All methods async.
53
+ *
54
+ * Validation policy:
55
+ *
56
+ * - create() opts → throw at boot
57
+ * - get/set/del/has/wrap key arg type → throw at call site (programming bug)
58
+ * - set value type → tolerant (operator decides what to store)
59
+ * - per-call ttlMs override → throw at call site (bad ttl is silent footgun)
60
+ * - audit / observability emit failures → drop silent (hot-path sink)
61
+ * - method called after close() → throw BAD_STATE at call site
62
+ *
63
+ * Security defaults:
64
+ *
65
+ * - auditClear: true — mass purge is operator-action shaped (can hide forensics)
66
+ * - auditFailures: true — backend errors are signal
67
+ * - hot-path get/set/hit/miss/eviction → observability only (audit chain
68
+ * would drown at any reasonable QPS)
69
+ *
70
+ * The cache supports single-flight wrap (concurrent calls collapse),
71
+ * stale-while-revalidate, LRU + bytes eviction on the memory backend,
72
+ * sliding TTL on hit, tag-based bulk invalidation (memory backend), a
73
+ * shared cluster backend, and a custom-backend escape hatch.
74
+ *
75
+ * What is NOT in the box:
76
+ *
77
+ * - maxBytes on the cluster backend — per-row size accounting against
78
+ * a shared table would mean an aggregate query on every set. The
79
+ * operator controls cluster-table size with their own pruning if
80
+ * bytes pressure surfaces.
81
+ * - Per-entry exact slidingTtl on the cluster backend — sliding works
82
+ * on cluster but extends by the cache's defaultTtlMs (we don't
83
+ * store per-row ttl). Operators with mixed-TTL writes wanting
84
+ * strict per-entry sliding use the memory backend or extend at
85
+ * the application layer.
86
+ */
87
+
88
+ var cacheRedis = require("./cache-redis");
89
+ var redisClient = require("./redis-client");
90
+ var clusterStorage = require("./cluster-storage");
91
+ var C = require("./constants");
92
+ var lazyRequire = require("./lazy-require");
93
+ var { boot } = require("./log");
94
+ var numericChecks = require("./numeric-checks");
95
+ var requestHelpers = require("./request-helpers");
96
+ var safeAsync = require("./safe-async");
97
+ var safeJson = require("./safe-json");
98
+ var validateOpts = require("./validate-opts");
99
+ var { CacheError } = require("./framework-error");
100
+
101
+ var log = boot("cache");
102
+ var observability = lazyRequire(function () { return require("./observability"); });
103
+
104
+ var _err = CacheError.factory;
105
+
106
+ var DEFAULTS = Object.freeze({
107
+ backend: "memory",
108
+ ttlMs: C.TIME.minutes(5),
109
+ maxEntries: C.BYTES.bytes(10000),
110
+ maxBytes: Infinity,
111
+ sweepIntervalMs: C.TIME.minutes(1),
112
+ staleWhileRevalidate: false,
113
+ slidingTtl: false,
114
+ auditFailures: true,
115
+ auditClear: true,
116
+ });
117
+
118
+ // ---- Config-time validation helpers (throw on bad input) ----
119
+
120
+ var _isFiniteNonNegative = numericChecks.isFiniteNonNegative;
121
+ var _isPositiveInt = numericChecks.isPositiveInt;
122
+
123
+ // ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
124
+ // or non-number is rejected.
125
+ function _validateTtl(name, value) {
126
+ if (value === Infinity) return;
127
+ if (typeof value !== "number" || isNaN(value) || !isFinite(value) || value < 0) {
128
+ throw _err("BAD_OPT", name + " must be a non-negative finite number or Infinity, got " +
129
+ (typeof value) + " " + JSON.stringify(value));
130
+ }
131
+ }
132
+
133
+ function _validateMaxEntries(value) {
134
+ if (value === Infinity) return;
135
+ if (!_isPositiveInt(value)) {
136
+ throw _err("BAD_OPT", "cache.create: maxEntries must be a positive integer or Infinity, got " +
137
+ JSON.stringify(value));
138
+ }
139
+ }
140
+
141
+ function _validateMaxBytes(value) {
142
+ if (value === Infinity) return;
143
+ if (!_isFiniteNonNegative(value) || value < 1) {
144
+ throw _err("BAD_OPT", "cache.create: maxBytes must be a positive finite number or Infinity, got " +
145
+ JSON.stringify(value));
146
+ }
147
+ }
148
+
149
+ // Default sizeOf — best-effort byte estimate. Operators with structured
150
+ // values (large objects, custom classes) should pass their own sizeOf
151
+ // for accuracy.
152
+ function _defaultSizeOf(value) {
153
+ if (value === null || value === undefined) return 0;
154
+ if (Buffer.isBuffer(value)) return value.length;
155
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
156
+ if (typeof value === "number" || typeof value === "boolean") return C.BYTES.bytes(8);
157
+ // Fallback: round-trip through JSON. Cost is real; documented in the
158
+ // DEFAULTS docstring so operators with hot-path size accounting know
159
+ // to supply their own sizeOf.
160
+ try { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
161
+ catch (_e) { return 0; }
162
+ }
163
+
164
+ function _validateBackendObject(backend) {
165
+ var required = ["get", "set", "del", "clear", "size", "close"];
166
+ if (typeof backend !== "object" || backend === null) {
167
+ throw _err("BAD_OPT", "cache.create: custom backend must be an object");
168
+ }
169
+ for (var i = 0; i < required.length; i++) {
170
+ if (typeof backend[required[i]] !== "function") {
171
+ throw _err("BAD_OPT", "cache.create: custom backend missing method '" + required[i] +
172
+ "' (required: " + required.join(", ") + ")");
173
+ }
174
+ }
175
+ }
176
+
177
+ function _validateCreateOpts(opts) {
178
+ validateOpts.requireObject(opts, "cache.create", CacheError);
179
+ validateOpts.requireNonEmptyString(opts.namespace, "cache.create: namespace", CacheError, "BAD_OPT");
180
+ // Composite cluster-key separator is ":" — namespace must not contain it
181
+ // or two namespaces could collide ("a:b" + "c" = "a:b:c" = "a" + "b:c").
182
+ if (opts.namespace.indexOf(":") !== -1) {
183
+ throw _err("BAD_OPT", "cache.create: namespace must not contain ':' (used as cluster-key separator), got " +
184
+ JSON.stringify(opts.namespace));
185
+ }
186
+ if (opts.backend !== undefined) {
187
+ if (typeof opts.backend === "string") {
188
+ if (opts.backend !== "memory" && opts.backend !== "cluster" && opts.backend !== "redis") {
189
+ throw _err("BAD_OPT", "cache.create: backend string must be 'memory' | 'cluster' | 'redis', got " +
190
+ JSON.stringify(opts.backend));
191
+ }
192
+ if (opts.backend === "redis") {
193
+ if (typeof opts.redisUrl !== "string" || opts.redisUrl.length === 0) {
194
+ throw _err("BAD_OPT", "cache.create: backend='redis' requires opts.redisUrl (e.g. redis://localhost:6379/0)");
195
+ }
196
+ }
197
+ } else {
198
+ _validateBackendObject(opts.backend);
199
+ }
200
+ }
201
+ if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
202
+ if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
203
+ if (opts.maxBytes !== undefined) _validateMaxBytes(opts.maxBytes);
204
+ validateOpts.optionalFunction(opts.sizeOf, "cache.create: sizeOf", CacheError);
205
+ validateOpts.optionalBoolean(opts.slidingTtl, "cache.create: slidingTtl", CacheError);
206
+ if (opts.sweepIntervalMs !== undefined) {
207
+ validateOpts.optionalFiniteNonNegative(opts.sweepIntervalMs, "cache.create: sweepIntervalMs", CacheError);
208
+ if (opts.sweepIntervalMs < C.TIME.seconds(1)) {
209
+ throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be >= 1000ms, got " +
210
+ JSON.stringify(opts.sweepIntervalMs));
211
+ }
212
+ }
213
+ validateOpts.optionalBoolean(opts.staleWhileRevalidate, "cache.create: staleWhileRevalidate", CacheError);
214
+ validateOpts.optionalBoolean(opts.auditFailures, "cache.create: auditFailures", CacheError);
215
+ validateOpts.optionalBoolean(opts.auditClear, "cache.create: auditClear", CacheError);
216
+ validateOpts.auditShape(opts.audit, "cache.create", CacheError);
217
+ validateOpts.observabilityShape(opts.observability, "cache.create", CacheError);
218
+ validateOpts.optionalFunction(opts.clock, "cache.create: clock", CacheError);
219
+ }
220
+
221
+ function _validateKey(key, ctx) {
222
+ if (typeof key !== "string" || key.length === 0) {
223
+ throw _err("BAD_KEY", ctx + ": key must be a non-empty string, got " +
224
+ (typeof key) + " " + JSON.stringify(key));
225
+ }
226
+ }
227
+
228
+ // ---- Memory backend ----
229
+ // LRU realized by Map insertion order (Node Map iterates in insertion order;
230
+ // re-inserting a key on hit moves it to the most-recent position).
231
+
232
+ function _memoryBackend(cfg) {
233
+ var entries = new Map(); // key → { value, expiresAt, ttlMs, bytes, tags }
234
+ var maxEntries = cfg.maxEntries;
235
+ var maxBytes = cfg.maxBytes;
236
+ var sizeOf = cfg.sizeOf;
237
+ var slidingTtl = cfg.slidingTtl;
238
+ var clock = cfg.clock;
239
+ var emitObs = cfg.emitObs;
240
+ var namespace = cfg.namespace;
241
+ var sweepTimer = null;
242
+ var totalBytes = 0;
243
+
244
+ // tag → Set<key>. Bidirectional with entry.tags for fast invalidate.
245
+ var tagIndex = new Map();
246
+
247
+ function _isExpired(entry, now) {
248
+ return entry.expiresAt !== Infinity && entry.expiresAt <= now;
249
+ }
250
+
251
+ function _untrack(key, entry) {
252
+ if (!entry) return;
253
+ totalBytes -= entry.bytes || 0;
254
+ if (totalBytes < 0) totalBytes = 0;
255
+ if (entry.tags && entry.tags.length > 0) {
256
+ for (var i = 0; i < entry.tags.length; i++) {
257
+ var s = tagIndex.get(entry.tags[i]);
258
+ if (s) {
259
+ s.delete(key);
260
+ if (s.size === 0) tagIndex.delete(entry.tags[i]);
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ function _evictByCounts() {
267
+ while (maxEntries !== Infinity && entries.size > maxEntries) {
268
+ var oldest = entries.keys().next().value;
269
+ var e = entries.get(oldest);
270
+ _untrack(oldest, e);
271
+ entries.delete(oldest);
272
+ emitObs("cache.eviction.size", { namespace: namespace });
273
+ }
274
+ while (maxBytes !== Infinity && totalBytes > maxBytes && entries.size > 0) {
275
+ var oldestB = entries.keys().next().value;
276
+ var eb = entries.get(oldestB);
277
+ _untrack(oldestB, eb);
278
+ entries.delete(oldestB);
279
+ emitObs("cache.eviction.bytes", { namespace: namespace });
280
+ }
281
+ }
282
+
283
+ async function get(key) {
284
+ var now = clock();
285
+ var entry = entries.get(key);
286
+ if (!entry) return undefined;
287
+ if (_isExpired(entry, now)) {
288
+ _untrack(key, entry);
289
+ entries.delete(key);
290
+ emitObs("cache.eviction.expired", { namespace: namespace });
291
+ return undefined;
292
+ }
293
+ // Sliding TTL: extend lifetime on each successful read by the
294
+ // entry's original ttlMs. Infinity stays Infinity.
295
+ if (slidingTtl && entry.ttlMs !== Infinity && typeof entry.ttlMs === "number") {
296
+ entry.expiresAt = now + entry.ttlMs;
297
+ }
298
+ // LRU recency bump: re-insert moves to the most-recent slot.
299
+ entries.delete(key);
300
+ entries.set(key, entry);
301
+ return entry.value;
302
+ }
303
+
304
+ async function set(key, value, expiresAt, meta) {
305
+ // Existing key replacement: untrack first to rebalance bytes + tags.
306
+ var prior = entries.get(key);
307
+ if (prior) {
308
+ _untrack(key, prior);
309
+ entries.delete(key);
310
+ }
311
+ var bytes = sizeOf(value) || 0;
312
+ var ttlMs = meta && typeof meta.ttlMs === "number" ? meta.ttlMs : null;
313
+ var tags = (meta && Array.isArray(meta.tags)) ? meta.tags.slice() : null;
314
+ entries.set(key, {
315
+ value: value,
316
+ expiresAt: expiresAt,
317
+ ttlMs: ttlMs,
318
+ bytes: bytes,
319
+ tags: tags,
320
+ });
321
+ totalBytes += bytes;
322
+ if (tags && tags.length > 0) {
323
+ for (var i = 0; i < tags.length; i++) {
324
+ var s = tagIndex.get(tags[i]);
325
+ if (!s) { s = new Set(); tagIndex.set(tags[i], s); }
326
+ s.add(key);
327
+ }
328
+ }
329
+ _evictByCounts();
330
+ }
331
+
332
+ async function del(key) {
333
+ var entry = entries.get(key);
334
+ if (!entry) return false;
335
+ _untrack(key, entry);
336
+ entries.delete(key);
337
+ return true;
338
+ }
339
+
340
+ async function has(key) {
341
+ var entry = entries.get(key);
342
+ if (!entry) return false;
343
+ if (_isExpired(entry, clock())) {
344
+ _untrack(key, entry);
345
+ entries.delete(key);
346
+ emitObs("cache.eviction.expired", { namespace: namespace });
347
+ return false;
348
+ }
349
+ return true;
350
+ }
351
+
352
+ async function clear() {
353
+ var n = entries.size;
354
+ entries.clear();
355
+ tagIndex.clear();
356
+ totalBytes = 0;
357
+ return n;
358
+ }
359
+
360
+ async function size() {
361
+ // Lazy purge: count only non-expired so size() reflects "live" entries.
362
+ var now = clock();
363
+ var live = 0;
364
+ for (var entry of entries.values()) {
365
+ if (!_isExpired(entry, now)) live++;
366
+ }
367
+ return live;
368
+ }
369
+
370
+ async function invalidateTag(tag) {
371
+ var keys = tagIndex.get(tag);
372
+ if (!keys || keys.size === 0) return 0;
373
+ var purged = 0;
374
+ var toDelete = Array.from(keys);
375
+ for (var i = 0; i < toDelete.length; i++) {
376
+ var k = toDelete[i];
377
+ var entry = entries.get(k);
378
+ if (entry) {
379
+ _untrack(k, entry);
380
+ entries.delete(k);
381
+ purged++;
382
+ }
383
+ }
384
+ return purged;
385
+ }
386
+
387
+ async function getTags(key) {
388
+ var entry = entries.get(key);
389
+ if (!entry) return null;
390
+ return entry.tags ? entry.tags.slice() : [];
391
+ }
392
+
393
+ async function bytes() {
394
+ return totalBytes;
395
+ }
396
+
397
+ function _sweep() {
398
+ var now = clock();
399
+ var purged = 0;
400
+ for (var k of Array.from(entries.keys())) {
401
+ var e = entries.get(k);
402
+ if (_isExpired(e, now)) {
403
+ _untrack(k, e);
404
+ entries.delete(k);
405
+ purged++;
406
+ }
407
+ }
408
+ if (purged > 0) {
409
+ for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
410
+ }
411
+ }
412
+
413
+ function _startSweep(intervalMs) {
414
+ if (sweepTimer) return;
415
+ sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep" });
416
+ }
417
+
418
+ async function close() {
419
+ if (sweepTimer) { sweepTimer.stop(); sweepTimer = null; }
420
+ entries.clear();
421
+ tagIndex.clear();
422
+ totalBytes = 0;
423
+ }
424
+
425
+ return {
426
+ name: "memory",
427
+ get: get,
428
+ set: set,
429
+ del: del,
430
+ has: has,
431
+ clear: clear,
432
+ size: size,
433
+ bytes: bytes,
434
+ invalidateTag: invalidateTag,
435
+ getTags: getTags,
436
+ close: close,
437
+ _startSweep: _startSweep,
438
+ // Test hook: raw entries map for state inspection
439
+ _entries: entries,
440
+ };
441
+ }
442
+
443
+ // ---- Cluster backend ----
444
+ // Single _blamejs_cache table; cacheKey = "<namespace>:<key>". JSON-only
445
+ // value serialization. UPSERT via ON CONFLICT for atomic set.
446
+
447
+ function _clusterBackend(cfg) {
448
+ var namespace = cfg.namespace;
449
+ var clock = cfg.clock;
450
+ var emitObs = cfg.emitObs;
451
+ var slidingTtl = cfg.slidingTtl;
452
+ var defaultTtlMs = cfg.defaultTtlMs;
453
+
454
+ // Composite cluster key. Namespace was validated to not contain ":"
455
+ // at create time, so the split is unambiguous.
456
+ function _composedKey(key) { return namespace + ":" + key; }
457
+
458
+ async function get(key) {
459
+ var now = clock();
460
+ var result = await clusterStorage.execute(
461
+ "SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?",
462
+ [_composedKey(key)]
463
+ );
464
+ if (!result || !result.rows || result.rows.length === 0) return undefined;
465
+ var row = result.rows[0];
466
+ if (row.expiresAt <= now) {
467
+ // Lazy purge: opportunistic delete on stale read.
468
+ try {
469
+ await clusterStorage.execute(
470
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt <= ?",
471
+ [_composedKey(key), now]
472
+ );
473
+ } catch (_e) { /* sweeper will catch it next pass */ }
474
+ emitObs("cache.eviction.expired", { namespace: namespace });
475
+ return undefined;
476
+ }
477
+ // Sliding TTL on cluster: extend by the cache's defaultTtlMs (we don't
478
+ // store per-row ttl). Operators with mixed-TTL writes wanting strict
479
+ // per-entry sliding use the memory backend or extend at app layer.
480
+ // Fire-and-forget — best-effort lifetime extension.
481
+ if (slidingTtl && defaultTtlMs !== Infinity && typeof defaultTtlMs === "number") {
482
+ var newExpires = now + defaultTtlMs;
483
+ clusterStorage.execute(
484
+ "UPDATE _blamejs_cache SET expiresAt = ?, updatedAt = ? " +
485
+ "WHERE cacheKey = ? AND expiresAt > ?",
486
+ [newExpires, now, _composedKey(key), now]
487
+ ).catch(function () { /* best-effort */ });
488
+ }
489
+ try { return safeJson.parse(row.valueJson, { maxBytes: C.BYTES.mib(64) }); }
490
+ catch (_e) { return undefined; }
491
+ }
492
+
493
+ async function set(key, value, expiresAt, meta) {
494
+ var json = JSON.stringify(value);
495
+ var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
496
+ var now = clock();
497
+ var ck = _composedKey(key);
498
+ // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
499
+ await clusterStorage.execute(
500
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
501
+ "VALUES (?, ?, ?, ?) " +
502
+ "ON CONFLICT (cacheKey) DO UPDATE SET " +
503
+ "valueJson = ?, expiresAt = ?, updatedAt = ?",
504
+ [ck, json, storedExpires, now, json, storedExpires, now]
505
+ );
506
+ // Tag handling: drop any prior tags for this key (tags can change
507
+ // across sets), then INSERT the new ones. The PRIMARY KEY on
508
+ // (cacheKey, tag) makes the INSERT idempotent if duplicate tags
509
+ // sneak in.
510
+ var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
511
+ await clusterStorage.execute(
512
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
513
+ [ck]
514
+ );
515
+ if (tags && tags.length > 0) {
516
+ for (var i = 0; i < tags.length; i++) {
517
+ await clusterStorage.execute(
518
+ "INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
519
+ "ON CONFLICT (cacheKey, tag) DO NOTHING",
520
+ [ck, tags[i]]
521
+ );
522
+ }
523
+ }
524
+ }
525
+
526
+ async function del(key) {
527
+ var ck = _composedKey(key);
528
+ var result = await clusterStorage.execute(
529
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
530
+ [ck]
531
+ );
532
+ // Drop any matching tag rows. Best-effort: a stale tag row pointing
533
+ // at a non-existent cacheKey is dropped on the next invalidateTag
534
+ // sweep (by the JOIN-shape DELETE) anyway.
535
+ await clusterStorage.execute(
536
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
537
+ [ck]
538
+ ).catch(function () { /* best-effort */ });
539
+ return !!(result && result.rowCount && result.rowCount > 0);
540
+ }
541
+
542
+ async function invalidateTag(tag) {
543
+ // Find every cacheKey carrying the tag (namespace-scoped via the LIKE
544
+ // on the composed key), delete from the cache table + the junction.
545
+ var like = namespace + ":%";
546
+ var keysResult = await clusterStorage.execute(
547
+ "SELECT cacheKey FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
548
+ [tag, like]
549
+ );
550
+ var keys = (keysResult && keysResult.rows) || [];
551
+ if (keys.length === 0) {
552
+ // Nothing to invalidate; still drop any orphan tag rows for
553
+ // this tag scoped to our namespace.
554
+ await clusterStorage.execute(
555
+ "DELETE FROM _blamejs_cache_tags WHERE tag = ? AND cacheKey LIKE ?",
556
+ [tag, like]
557
+ );
558
+ return 0;
559
+ }
560
+ var purged = 0;
561
+ for (var i = 0; i < keys.length; i++) {
562
+ var ck = keys[i].cacheKey;
563
+ var r = await clusterStorage.execute(
564
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
565
+ [ck]
566
+ );
567
+ if (r && r.rowCount > 0) purged += r.rowCount;
568
+ await clusterStorage.execute(
569
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
570
+ [ck]
571
+ );
572
+ }
573
+ return purged;
574
+ }
575
+
576
+ async function getTags(key) {
577
+ var result = await clusterStorage.execute(
578
+ "SELECT tag FROM _blamejs_cache_tags WHERE cacheKey = ?",
579
+ [_composedKey(key)]
580
+ );
581
+ if (!result || !result.rows) return [];
582
+ return result.rows.map(function (r) { return r.tag; });
583
+ }
584
+
585
+ async function has(key) {
586
+ // Existence check without recency bump — cluster backend doesn't
587
+ // track LRU at all, so "without bumping" is automatic. Honors
588
+ // expiresAt the same as get().
589
+ var now = clock();
590
+ var result = await clusterStorage.execute(
591
+ "SELECT expiresAt FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt > ?",
592
+ [_composedKey(key), now]
593
+ );
594
+ return !!(result && result.rows && result.rows.length > 0);
595
+ }
596
+
597
+ async function clear() {
598
+ // Namespace-scoped wipe so two CacheInstance instances sharing the
599
+ // table don't cross-purge each other.
600
+ var like = namespace + ":%";
601
+ var result = await clusterStorage.execute(
602
+ "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
603
+ [like]
604
+ );
605
+ // Drop matching tag rows in the same namespace.
606
+ await clusterStorage.execute(
607
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey LIKE ?",
608
+ [like]
609
+ ).catch(function () { /* best-effort */ });
610
+ return (result && result.rowCount) || 0;
611
+ }
612
+
613
+ async function size() {
614
+ var now = clock();
615
+ var like = namespace + ":%";
616
+ var result = await clusterStorage.execute(
617
+ "SELECT COUNT(*) AS n FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt > ?",
618
+ [like, now]
619
+ );
620
+ if (!result || !result.rows || result.rows.length === 0) return 0;
621
+ return result.rows[0].n || 0;
622
+ }
623
+
624
+ async function _sweep() {
625
+ var now = clock();
626
+ var like = namespace + ":%";
627
+ // Capture the to-be-purged keys first so we can drop matching tag
628
+ // rows in the same sweep — keeps the junction table free of orphans
629
+ // pointing at expired cacheKeys.
630
+ var expiredResult = await clusterStorage.execute(
631
+ "SELECT cacheKey FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
632
+ [like, now]
633
+ );
634
+ var expiredKeys = (expiredResult && expiredResult.rows) || [];
635
+ await clusterStorage.execute(
636
+ "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
637
+ [like, now]
638
+ );
639
+ for (var i = 0; i < expiredKeys.length; i++) {
640
+ await clusterStorage.execute(
641
+ "DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
642
+ [expiredKeys[i].cacheKey]
643
+ ).catch(function () { /* best-effort */ });
644
+ }
645
+ }
646
+
647
+ function _startSweep(intervalMs) {
648
+ cfg._sweepTimer = safeAsync.repeating(_sweep, intervalMs, { name: "cache-sweep-cluster" });
649
+ }
650
+
651
+ async function close() {
652
+ if (cfg._sweepTimer) { cfg._sweepTimer.stop(); cfg._sweepTimer = null; }
653
+ }
654
+
655
+ return {
656
+ name: "cluster",
657
+ get: get,
658
+ set: set,
659
+ del: del,
660
+ has: has,
661
+ clear: clear,
662
+ size: size,
663
+ close: close,
664
+ invalidateTag: invalidateTag,
665
+ getTags: getTags,
666
+ _startSweep: _startSweep,
667
+ };
668
+ }
669
+
670
+ // ---- Custom backend wrapper ----
671
+ // Operator-supplied { get, set, del, clear, size, close } — wrap to
672
+ // uniform-shape (no _startSweep, _entries). The operator is responsible
673
+ // for their own expiration; we pass expiresAt to set().
674
+
675
+ function _customBackend(operatorBackend, cfg) {
676
+ return {
677
+ name: "custom",
678
+ get: function (key) { return operatorBackend.get(key); },
679
+ set: function (key, value, expiresAt, meta) {
680
+ // Older 3-arg backends remain compatible — meta is opt-in.
681
+ return operatorBackend.set(key, value, expiresAt, meta);
682
+ },
683
+ del: function (key) { return operatorBackend.del(key); },
684
+ has: function (key) {
685
+ // Optional has() — fall back to get-and-coerce if operator didn't
686
+ // implement it.
687
+ if (typeof operatorBackend.has === "function") return operatorBackend.has(key);
688
+ return Promise.resolve(operatorBackend.get(key)).then(function (v) { return v !== undefined; });
689
+ },
690
+ clear: function () { return operatorBackend.clear(); },
691
+ size: function () { return operatorBackend.size(); },
692
+ bytes: function () {
693
+ if (typeof operatorBackend.bytes === "function") return operatorBackend.bytes();
694
+ return Promise.resolve(0);
695
+ },
696
+ invalidateTag: function (tag) {
697
+ if (typeof operatorBackend.invalidateTag === "function") return operatorBackend.invalidateTag(tag);
698
+ return Promise.resolve(0);
699
+ },
700
+ getTags: function (key) {
701
+ if (typeof operatorBackend.getTags === "function") return operatorBackend.getTags(key);
702
+ return Promise.resolve(null);
703
+ },
704
+ close: function () { return operatorBackend.close(); },
705
+ _startSweep: function () { /* operator backend manages its own sweep */ },
706
+ };
707
+ }
708
+
709
+ // ---- Public create ----
710
+
711
+ function create(opts) {
712
+ opts = opts || {};
713
+ validateOpts(opts, [
714
+ "namespace", "backend", "ttlMs", "maxEntries", "maxBytes", "sizeOf",
715
+ "sweepIntervalMs", "staleWhileRevalidate", "slidingTtl",
716
+ "auditFailures", "auditClear",
717
+ "audit", "observability", "clock",
718
+ // backend === "redis" connection options. Ignored for memory /
719
+ // cluster / custom-backend modes.
720
+ "redisUrl", "redisPassword", "redisUsername", "redisTls", "redisCa",
721
+ "redisServername", "redisConnectTimeoutMs", "redisCommandTimeoutMs",
722
+ "redisMaxReconnectAttempts",
723
+ // Cross-node invalidation: when set, every successful
724
+ // del/clear/invalidateTag publishes an event on the supplied
725
+ // pubsub instance. Other cache instances on other nodes (or in
726
+ // other processes sharing the pubsub backend) react locally —
727
+ // mostly useful for the memory backend so stale per-node entries
728
+ // don't survive a global tag wipe. The cluster + redis backends
729
+ // are coherent by virtue of their shared store, but a hot
730
+ // memory-tier on top of either still benefits.
731
+ "invalidationPubsub",
732
+ ], "cache");
733
+ _validateCreateOpts(opts);
734
+ var cfg0 = validateOpts.applyDefaults(opts, DEFAULTS);
735
+
736
+ var namespace = opts.namespace;
737
+ var backendKind = cfg0.backend;
738
+ var defaultTtlMs = cfg0.ttlMs;
739
+ var maxEntries = cfg0.maxEntries;
740
+ var maxBytes = cfg0.maxBytes;
741
+ var sizeOf = (typeof opts.sizeOf === "function") ? opts.sizeOf : _defaultSizeOf;
742
+ var sweepIntervalMs = cfg0.sweepIntervalMs;
743
+ var staleRevalidate = cfg0.staleWhileRevalidate;
744
+ var slidingTtl = cfg0.slidingTtl;
745
+ var auditFailures = cfg0.auditFailures;
746
+ var auditClear = cfg0.auditClear;
747
+ var audit = opts.audit || null;
748
+ var operatorObs = opts.observability || null;
749
+ var clock = opts.clock || function () { return Date.now(); };
750
+ var invalidationPubsub = opts.invalidationPubsub || null;
751
+ if (invalidationPubsub && (
752
+ typeof invalidationPubsub.publish !== "function" ||
753
+ typeof invalidationPubsub.subscribe !== "function" ||
754
+ typeof invalidationPubsub.unsubscribe !== "function")) {
755
+ throw _err("BAD_OPT",
756
+ "cache.create: invalidationPubsub must implement { publish, subscribe, unsubscribe } (b.pubsub.create instance)");
757
+ }
758
+ var invalidationChannel = "cache:" + namespace + ":invalidate";
759
+ var invalidationToken = null;
760
+ // Re-entrancy guard — when we receive an invalidation event from
761
+ // another node we MUST NOT re-publish it (infinite fan-out loop).
762
+ var inboundInvalidation = false;
763
+
764
+ function emitObs(name, labels) {
765
+ try {
766
+ if (operatorObs) operatorObs.event(name, 1, labels || {});
767
+ else observability().event(name, 1, labels || {});
768
+ } catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
769
+ }
770
+
771
+ var emitAudit = validateOpts.makeAuditEmitter(audit);
772
+
773
+ function _actor(callerOpts) {
774
+ return requestHelpers.resolveActorWithOverride(callerOpts);
775
+ }
776
+
777
+ function _backendFailedAudit(op, err) {
778
+ if (!auditFailures) return;
779
+ emitAudit("cache.backend.failed", {
780
+ actor: requestHelpers.extractActorContext(null),
781
+ resource: { kind: "cache", id: namespace },
782
+ outcome: "failure",
783
+ reason: "backend-error",
784
+ metadata: { op: op, code: (err && err.code) || null, message: (err && err.message) || String(err) },
785
+ });
786
+ }
787
+
788
+ // Resolve backend
789
+ var cfg = {
790
+ namespace: namespace,
791
+ maxEntries: maxEntries,
792
+ maxBytes: maxBytes,
793
+ sizeOf: sizeOf,
794
+ slidingTtl: slidingTtl,
795
+ defaultTtlMs: defaultTtlMs,
796
+ clock: clock,
797
+ emitObs: emitObs,
798
+ _sweepTimer: null,
799
+ };
800
+ var backend;
801
+ if (backendKind === "memory") {
802
+ backend = _memoryBackend(cfg);
803
+ } else if (backendKind === "cluster") {
804
+ backend = _clusterBackend(cfg);
805
+ } else if (backendKind === "redis") {
806
+ backend = _customBackend(cacheRedis.create(Object.assign(
807
+ redisClient.pickClientOpts(opts, "redis"),
808
+ {
809
+ namespace: namespace,
810
+ slidingTtl: slidingTtl,
811
+ defaultTtlMs: defaultTtlMs,
812
+ clock: clock,
813
+ emitObs: emitObs,
814
+ }
815
+ )), cfg);
816
+ } else {
817
+ backend = _customBackend(opts.backend, cfg);
818
+ }
819
+
820
+ backend._startSweep(sweepIntervalMs);
821
+
822
+ var closed = false;
823
+ function _ensureOpen(method) {
824
+ if (closed) {
825
+ throw _err("BAD_STATE", "cache." + method + ": cache instance has been closed");
826
+ }
827
+ }
828
+
829
+ // Single-flight inflight map for wrap()
830
+ var inflight = new Map();
831
+
832
+ // Stale-while-revalidate tracking (per-instance, in-memory). When SWR
833
+ // is on, wrap() stores entries with a HARD TTL of 2× ttlMs and tracks
834
+ // the SOFT expiration here. Reads after soft but before hard return
835
+ // the cached value AND kick off a background refresh; reads after
836
+ // hard fall through to a normal miss + compute. The soft-TTL map is
837
+ // memory-only even when the backend is cluster — refreshes are a
838
+ // best-effort optimization, not a correctness invariant, so a cache
839
+ // miss after restart (no soft data) just means we serve fresh once.
840
+ var softExpiry = new Map(); // key → softExpiresAt
841
+ var swrInflight = new Map(); // key → background-refresh promise
842
+ var SWR_HARD_MULTIPLIER = 2;
843
+
844
+ // ---- Public methods ----
845
+
846
+ function _resolveTtl(callerOpts, methodName) {
847
+ if (callerOpts && callerOpts.ttlMs !== undefined) {
848
+ _validateTtl("cache." + methodName + ": ttlMs", callerOpts.ttlMs);
849
+ return callerOpts.ttlMs;
850
+ }
851
+ return defaultTtlMs;
852
+ }
853
+
854
+ async function get(key) {
855
+ _ensureOpen("get");
856
+ _validateKey(key, "cache.get");
857
+ var v;
858
+ try { v = await backend.get(key); }
859
+ catch (e) {
860
+ emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
861
+ _backendFailedAudit("get", e);
862
+ throw e;
863
+ }
864
+ if (v === undefined) emitObs("cache.miss", { namespace: namespace });
865
+ else emitObs("cache.hit", { namespace: namespace });
866
+ return v;
867
+ }
868
+
869
+ async function set(key, value, callerOpts) {
870
+ _ensureOpen("set");
871
+ _validateKey(key, "cache.set");
872
+ var ttlMs = _resolveTtl(callerOpts, "set");
873
+ if (ttlMs === 0) return; // 0 means "do not cache"
874
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
875
+ var tags = (callerOpts && Array.isArray(callerOpts.tags)) ? callerOpts.tags : null;
876
+ if (tags) {
877
+ for (var i = 0; i < tags.length; i++) {
878
+ if (typeof tags[i] !== "string" || tags[i].length === 0) {
879
+ throw _err("BAD_OPT", "cache.set: tags must be an array of non-empty strings");
880
+ }
881
+ }
882
+ }
883
+ try { await backend.set(key, value, expiresAt, { ttlMs: ttlMs, tags: tags }); }
884
+ catch (e) {
885
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
886
+ _backendFailedAudit("set", e);
887
+ throw e;
888
+ }
889
+ emitObs("cache.set", { namespace: namespace });
890
+ }
891
+
892
+ async function del(key) {
893
+ _ensureOpen("del");
894
+ _validateKey(key, "cache.del");
895
+ var existed;
896
+ try { existed = await backend.del(key); }
897
+ catch (e) {
898
+ emitObs("cache.backend.failed", { namespace: namespace, op: "del" });
899
+ _backendFailedAudit("del", e);
900
+ throw e;
901
+ }
902
+ if (existed) emitObs("cache.del", { namespace: namespace });
903
+ softExpiry.delete(key);
904
+ _publishInvalidation({ kind: "del", key: key });
905
+ return existed;
906
+ }
907
+
908
+ async function has(key) {
909
+ _ensureOpen("has");
910
+ _validateKey(key, "cache.has");
911
+ try { return await backend.has(key); }
912
+ catch (e) {
913
+ emitObs("cache.backend.failed", { namespace: namespace, op: "has" });
914
+ _backendFailedAudit("has", e);
915
+ throw e;
916
+ }
917
+ }
918
+
919
+ async function clear(callerOpts) {
920
+ _ensureOpen("clear");
921
+ var purged;
922
+ try { purged = await backend.clear(); }
923
+ catch (e) {
924
+ emitObs("cache.backend.failed", { namespace: namespace, op: "clear" });
925
+ _backendFailedAudit("clear", e);
926
+ throw e;
927
+ }
928
+ emitObs("cache.clear", { namespace: namespace });
929
+ if (auditClear) {
930
+ emitAudit("cache.cleared", {
931
+ actor: _actor(callerOpts),
932
+ resource: { kind: "cache", id: namespace },
933
+ outcome: "success",
934
+ metadata: { itemCount: purged },
935
+ });
936
+ }
937
+ // Drop any in-flight wrap promises — operator clear means "consumers
938
+ // should re-fetch", and in-flight resolves would seed stale entries
939
+ // post-clear.
940
+ inflight.clear();
941
+ swrInflight.clear();
942
+ softExpiry.clear();
943
+ _publishInvalidation({ kind: "clear" });
944
+ return purged;
945
+ }
946
+
947
+ async function size() {
948
+ _ensureOpen("size");
949
+ try { return await backend.size(); }
950
+ catch (e) {
951
+ emitObs("cache.backend.failed", { namespace: namespace, op: "size" });
952
+ _backendFailedAudit("size", e);
953
+ throw e;
954
+ }
955
+ }
956
+
957
+ async function bytes() {
958
+ _ensureOpen("bytes");
959
+ try {
960
+ if (typeof backend.bytes !== "function") return 0;
961
+ return await backend.bytes();
962
+ } catch (e) {
963
+ emitObs("cache.backend.failed", { namespace: namespace, op: "bytes" });
964
+ _backendFailedAudit("bytes", e);
965
+ throw e;
966
+ }
967
+ }
968
+
969
+ async function invalidateTag(tag, callerOpts) {
970
+ _ensureOpen("invalidateTag");
971
+ if (typeof tag !== "string" || tag.length === 0) {
972
+ throw _err("BAD_OPT", "cache.invalidateTag: tag must be a non-empty string");
973
+ }
974
+ if (typeof backend.invalidateTag !== "function") {
975
+ throw _err("NOT_SUPPORTED",
976
+ "cache.invalidateTag: backend '" + (backend.name || "custom") +
977
+ "' does not implement invalidateTag. Operator-supplied custom backends " +
978
+ "must export invalidateTag(tag) → number to participate in tag-based wipes.");
979
+ }
980
+ var purged;
981
+ try { purged = await backend.invalidateTag(tag); }
982
+ catch (e) {
983
+ emitObs("cache.backend.failed", { namespace: namespace, op: "invalidateTag" });
984
+ _backendFailedAudit("invalidateTag", e);
985
+ throw e;
986
+ }
987
+ emitObs("cache.tag.invalidated", { namespace: namespace, tag: tag });
988
+ if (auditClear && purged > 0) {
989
+ emitAudit("cache.tag.invalidated", {
990
+ actor: _actor(callerOpts),
991
+ resource: { kind: "cache.tag", id: namespace + ":" + tag },
992
+ outcome: "success",
993
+ metadata: { tag: tag, itemCount: purged },
994
+ });
995
+ }
996
+ // Drop in-flight wrap promises whose key WOULD have just been
997
+ // invalidated. We don't track per-key tags inflight, so a coarse
998
+ // drop matches clear()'s safer-than-stale posture.
999
+ inflight.clear();
1000
+ swrInflight.clear();
1001
+ _publishInvalidation({ kind: "tag", tag: tag });
1002
+ return purged;
1003
+ }
1004
+
1005
+ async function getTags(key) {
1006
+ _ensureOpen("getTags");
1007
+ _validateKey(key, "cache.getTags");
1008
+ if (typeof backend.getTags !== "function") return null;
1009
+ try { return await backend.getTags(key); }
1010
+ catch (e) {
1011
+ emitObs("cache.backend.failed", { namespace: namespace, op: "getTags" });
1012
+ _backendFailedAudit("getTags", e);
1013
+ throw e;
1014
+ }
1015
+ }
1016
+
1017
+ function _backgroundRefresh(key, fn, ttlMs) {
1018
+ if (swrInflight.has(key)) return; // already refreshing
1019
+ var p = (async function () {
1020
+ var startedAt = clock();
1021
+ var computed;
1022
+ try { computed = await fn(); }
1023
+ finally {
1024
+ emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
1025
+ }
1026
+ var expiresAt = _writeWithSwr(key, computed, ttlMs);
1027
+ void expiresAt;
1028
+ return computed;
1029
+ })();
1030
+ swrInflight.set(key, p);
1031
+ p.then(
1032
+ function () { swrInflight.delete(key); },
1033
+ function (_e) {
1034
+ swrInflight.delete(key);
1035
+ // Background refresh failed; stale value already served. Surface
1036
+ // via observability so operators see it without breaking the
1037
+ // request that triggered the refresh.
1038
+ emitObs("cache.refresh.failed", { namespace: namespace });
1039
+ }
1040
+ );
1041
+ }
1042
+
1043
+ function _writeWithSwr(key, value, ttlMs) {
1044
+ if (ttlMs === 0) return null; // 0 means "do not cache"
1045
+ var now = clock();
1046
+ var hardTtlMs = (ttlMs === Infinity)
1047
+ ? Infinity
1048
+ : (staleRevalidate ? ttlMs * SWR_HARD_MULTIPLIER : ttlMs);
1049
+ var expiresAt = (hardTtlMs === Infinity) ? Infinity : (now + hardTtlMs);
1050
+ if (staleRevalidate && ttlMs !== Infinity) {
1051
+ softExpiry.set(key, now + ttlMs);
1052
+ } else {
1053
+ softExpiry.delete(key);
1054
+ }
1055
+ // Backend write — failure surfaces via observability + audit but
1056
+ // doesn't bubble (caller already has the computed value).
1057
+ backend.set(key, value, expiresAt, { ttlMs: ttlMs }).catch(function (e) {
1058
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
1059
+ _backendFailedAudit("set", e);
1060
+ });
1061
+ return expiresAt;
1062
+ }
1063
+
1064
+ async function wrap(key, fn, callerOpts) {
1065
+ _ensureOpen("wrap");
1066
+ _validateKey(key, "cache.wrap");
1067
+ if (typeof fn !== "function") {
1068
+ throw _err("BAD_OPT", "cache.wrap: fn must be a function, got " + typeof fn);
1069
+ }
1070
+ var ttlMs = _resolveTtl(callerOpts, "wrap");
1071
+ var singleFlight = !(callerOpts && callerOpts.singleFlight === false);
1072
+
1073
+ var existing;
1074
+ try { existing = await backend.get(key); }
1075
+ catch (e) {
1076
+ emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
1077
+ _backendFailedAudit("get", e);
1078
+ throw e;
1079
+ }
1080
+
1081
+ if (existing !== undefined) {
1082
+ // SWR: served from backend, but might be stale (past soft TTL).
1083
+ var soft = softExpiry.get(key);
1084
+ var now = clock();
1085
+ if (staleRevalidate && soft !== undefined && soft <= now) {
1086
+ emitObs("cache.hit", { namespace: namespace });
1087
+ _backgroundRefresh(key, fn, ttlMs);
1088
+ return existing;
1089
+ }
1090
+ emitObs("cache.hit", { namespace: namespace });
1091
+ return existing;
1092
+ }
1093
+ emitObs("cache.miss", { namespace: namespace });
1094
+
1095
+ if (singleFlight && inflight.has(key)) {
1096
+ emitObs("cache.wrap.singleflight.collapsed", { namespace: namespace });
1097
+ return inflight.get(key);
1098
+ }
1099
+
1100
+ var promise = (async function () {
1101
+ var startedAt = clock();
1102
+ var computed;
1103
+ try { computed = await fn(); }
1104
+ finally {
1105
+ emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
1106
+ }
1107
+ if (ttlMs !== 0) {
1108
+ if (staleRevalidate) {
1109
+ _writeWithSwr(key, computed, ttlMs);
1110
+ } else {
1111
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
1112
+ try { await backend.set(key, computed, expiresAt, { ttlMs: ttlMs }); }
1113
+ catch (e) {
1114
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
1115
+ _backendFailedAudit("set", e);
1116
+ // Failed write doesn't fail the wrap — caller still gets the
1117
+ // computed value; cache just didn't persist.
1118
+ }
1119
+ }
1120
+ }
1121
+ return computed;
1122
+ })();
1123
+ if (singleFlight) {
1124
+ inflight.set(key, promise);
1125
+ promise.then(
1126
+ function () { inflight.delete(key); },
1127
+ function () { inflight.delete(key); }
1128
+ );
1129
+ }
1130
+ return promise;
1131
+ }
1132
+
1133
+ function _publishInvalidation(ev) {
1134
+ if (!invalidationPubsub || inboundInvalidation) return;
1135
+ try { invalidationPubsub.publish(invalidationChannel, ev); }
1136
+ catch (_e) { /* publish best-effort — local invalidation already happened */ }
1137
+ }
1138
+
1139
+ async function _onInboundInvalidation(ev /*, meta */) {
1140
+ if (!ev || closed) return;
1141
+ inboundInvalidation = true;
1142
+ try {
1143
+ if (ev.kind === "tag" && typeof ev.tag === "string" &&
1144
+ typeof backend.invalidateTag === "function") {
1145
+ try { await backend.invalidateTag(ev.tag); }
1146
+ catch (e) { log.debug("invalidation-apply-failed", { op: "invalidateTag", tag: ev.tag, error: e.message }); }
1147
+ } else if (ev.kind === "del" && typeof ev.key === "string") {
1148
+ try { await backend.del(ev.key); }
1149
+ catch (e) { log.debug("invalidation-apply-failed", { op: "del", key: ev.key, error: e.message }); }
1150
+ } else if (ev.kind === "clear") {
1151
+ try { await backend.clear(); }
1152
+ catch (e) { log.debug("invalidation-apply-failed", { op: "clear", error: e.message }); }
1153
+ }
1154
+ // Wipe local in-flight memoization so a freshly-invalidated key
1155
+ // can't resolve from a still-pending fetch on this node.
1156
+ inflight.clear();
1157
+ swrInflight.clear();
1158
+ softExpiry.clear();
1159
+ } finally {
1160
+ inboundInvalidation = false;
1161
+ }
1162
+ }
1163
+
1164
+ if (invalidationPubsub) {
1165
+ invalidationToken = invalidationPubsub.subscribe(invalidationChannel, _onInboundInvalidation);
1166
+ }
1167
+
1168
+ async function close() {
1169
+ if (closed) return;
1170
+ closed = true;
1171
+ if (invalidationPubsub && invalidationToken) {
1172
+ try { invalidationPubsub.unsubscribe(invalidationToken); }
1173
+ catch (e) { log.debug("close-cleanup-failed", { op: "unsubscribe", error: e.message }); }
1174
+ invalidationToken = null;
1175
+ }
1176
+ inflight.clear();
1177
+ swrInflight.clear();
1178
+ softExpiry.clear();
1179
+ try { await backend.close(); }
1180
+ catch (_e) { /* close best-effort */ }
1181
+ }
1182
+
1183
+ return {
1184
+ get: get,
1185
+ set: set,
1186
+ del: del,
1187
+ has: has,
1188
+ clear: clear,
1189
+ size: size,
1190
+ bytes: bytes,
1191
+ wrap: wrap,
1192
+ invalidateTag: invalidateTag,
1193
+ getTags: getTags,
1194
+ close: close,
1195
+ namespace: namespace,
1196
+ // Test hooks
1197
+ _backend: backend,
1198
+ _inflight: inflight,
1199
+ };
1200
+ }
1201
+
1202
+ module.exports = {
1203
+ create: create,
1204
+ CacheError: CacheError,
1205
+ DEFAULTS: DEFAULTS,
1206
+ };