@blamejs/core 0.4.1

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 (160) hide show
  1. package/CHANGELOG.md +230 -0
  2. package/LICENSE +201 -0
  3. package/LTS-CALENDAR.md +29 -0
  4. package/MIGRATING.md +7 -0
  5. package/NOTICE +59 -0
  6. package/README.md +100 -0
  7. package/bin/blamejs.js +13 -0
  8. package/index.js +253 -0
  9. package/lib/api-key.js +705 -0
  10. package/lib/api-snapshot.js +335 -0
  11. package/lib/app-shutdown.js +381 -0
  12. package/lib/app.js +364 -0
  13. package/lib/atomic-file.js +525 -0
  14. package/lib/audit-chain.js +168 -0
  15. package/lib/audit-sign.js +319 -0
  16. package/lib/audit-tools.js +682 -0
  17. package/lib/audit.js +753 -0
  18. package/lib/auth/jwt.js +280 -0
  19. package/lib/auth/oauth.js +691 -0
  20. package/lib/auth/passkey.js +185 -0
  21. package/lib/auth/password.js +139 -0
  22. package/lib/auth/totp.js +17 -0
  23. package/lib/auth-header.js +81 -0
  24. package/lib/backup/bundle.js +219 -0
  25. package/lib/backup/crypto.js +174 -0
  26. package/lib/backup/index.js +490 -0
  27. package/lib/backup/manifest.js +275 -0
  28. package/lib/bundler.js +295 -0
  29. package/lib/cache.js +819 -0
  30. package/lib/chain-writer.js +234 -0
  31. package/lib/cli-helpers.js +201 -0
  32. package/lib/cli.js +1377 -0
  33. package/lib/cluster-provider-db.js +245 -0
  34. package/lib/cluster-storage.js +166 -0
  35. package/lib/cluster.js +691 -0
  36. package/lib/consent.js +222 -0
  37. package/lib/constants.js +186 -0
  38. package/lib/cookies.js +293 -0
  39. package/lib/credential-hash.js +303 -0
  40. package/lib/crypto-field.js +159 -0
  41. package/lib/crypto.js +250 -0
  42. package/lib/db-query.js +297 -0
  43. package/lib/db-schema.js +250 -0
  44. package/lib/db.js +1054 -0
  45. package/lib/deprecate.js +226 -0
  46. package/lib/dev.js +324 -0
  47. package/lib/error-page.js +424 -0
  48. package/lib/events.js +135 -0
  49. package/lib/external-db.js +422 -0
  50. package/lib/forms.js +378 -0
  51. package/lib/framework-error.js +189 -0
  52. package/lib/framework-schema.js +604 -0
  53. package/lib/handlers.js +350 -0
  54. package/lib/html-balance.js +227 -0
  55. package/lib/http-client.js +615 -0
  56. package/lib/i18n.js +780 -0
  57. package/lib/jobs.js +181 -0
  58. package/lib/lazy-require.js +48 -0
  59. package/lib/log-stream-local.js +137 -0
  60. package/lib/log-stream-webhook.js +170 -0
  61. package/lib/log-stream.js +211 -0
  62. package/lib/log.js +355 -0
  63. package/lib/mail-bounce.js +507 -0
  64. package/lib/mail.js +701 -0
  65. package/lib/metrics.js +647 -0
  66. package/lib/middleware/api-encrypt.js +553 -0
  67. package/lib/middleware/attach-user.js +156 -0
  68. package/lib/middleware/body-parser.js +883 -0
  69. package/lib/middleware/bot-guard.js +148 -0
  70. package/lib/middleware/compression.js +436 -0
  71. package/lib/middleware/cors.js +236 -0
  72. package/lib/middleware/csp-nonce.js +332 -0
  73. package/lib/middleware/csrf-protect.js +275 -0
  74. package/lib/middleware/error-handler.js +46 -0
  75. package/lib/middleware/health.js +358 -0
  76. package/lib/middleware/index.js +52 -0
  77. package/lib/middleware/rate-limit.js +319 -0
  78. package/lib/middleware/request-id.js +53 -0
  79. package/lib/middleware/require-auth.js +95 -0
  80. package/lib/middleware/security-headers.js +91 -0
  81. package/lib/migrations.js +353 -0
  82. package/lib/mtls-ca.js +333 -0
  83. package/lib/mtls-engine-default.js +285 -0
  84. package/lib/nonce-store.js +177 -0
  85. package/lib/notify.js +643 -0
  86. package/lib/ntp-check.js +178 -0
  87. package/lib/object-store/azure-blob.js +467 -0
  88. package/lib/object-store/gcs.js +469 -0
  89. package/lib/object-store/http-put.js +153 -0
  90. package/lib/object-store/index.js +140 -0
  91. package/lib/object-store/local.js +163 -0
  92. package/lib/object-store/retry.js +15 -0
  93. package/lib/object-store/sigv4.js +535 -0
  94. package/lib/observability.js +114 -0
  95. package/lib/pagination.js +371 -0
  96. package/lib/parsers/index.js +64 -0
  97. package/lib/parsers/safe-csv.js +224 -0
  98. package/lib/parsers/safe-env.js +614 -0
  99. package/lib/parsers/safe-toml.js +745 -0
  100. package/lib/parsers/safe-xml.js +379 -0
  101. package/lib/parsers/safe-yaml.js +977 -0
  102. package/lib/permissions.js +430 -0
  103. package/lib/pqc-agent.js +85 -0
  104. package/lib/pqc-gate.js +266 -0
  105. package/lib/protocol-dispatcher.js +144 -0
  106. package/lib/queue-local.js +327 -0
  107. package/lib/queue.js +430 -0
  108. package/lib/redact.js +192 -0
  109. package/lib/render.js +193 -0
  110. package/lib/request-helpers.js +178 -0
  111. package/lib/restore-bundle.js +239 -0
  112. package/lib/restore-rollback.js +254 -0
  113. package/lib/restore.js +301 -0
  114. package/lib/retry.js +329 -0
  115. package/lib/router.js +437 -0
  116. package/lib/safe-async.js +520 -0
  117. package/lib/safe-buffer.js +162 -0
  118. package/lib/safe-json.js +532 -0
  119. package/lib/safe-schema.js +1176 -0
  120. package/lib/safe-sql.js +157 -0
  121. package/lib/safe-url.js +109 -0
  122. package/lib/scheduler.js +680 -0
  123. package/lib/seeders.js +622 -0
  124. package/lib/session.js +304 -0
  125. package/lib/slug.js +243 -0
  126. package/lib/static.js +268 -0
  127. package/lib/storage.js +470 -0
  128. package/lib/subject.js +281 -0
  129. package/lib/template.js +781 -0
  130. package/lib/testing.js +621 -0
  131. package/lib/totp.js +285 -0
  132. package/lib/tracing.js +484 -0
  133. package/lib/validate-opts.js +56 -0
  134. package/lib/vault/index.js +299 -0
  135. package/lib/vault/passphrase-ops.js +311 -0
  136. package/lib/vault/passphrase-source.js +198 -0
  137. package/lib/vault/rotate.js +761 -0
  138. package/lib/vault/wrap.js +289 -0
  139. package/lib/vendor/MANIFEST.json +84 -0
  140. package/lib/vendor/argon2/argon2.cjs +466 -0
  141. package/lib/vendor/argon2/argon2.d.cts +62 -0
  142. package/lib/vendor/argon2/package.json +1 -0
  143. package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
  144. package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
  145. package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
  146. package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
  147. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
  148. package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
  149. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
  150. package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
  151. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
  152. package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
  153. package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
  154. package/lib/vendor/noble-ciphers.cjs +9 -0
  155. package/lib/vendor/pki.cjs +181 -0
  156. package/lib/vendor/simplewebauthn-server.cjs +328 -0
  157. package/lib/webhook.js +632 -0
  158. package/lib/websocket-channels.js +413 -0
  159. package/lib/websocket.js +833 -0
  160. package/package.json +39 -0
package/lib/cache.js ADDED
@@ -0,0 +1,819 @@
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
+ * audit: b.audit, // optional
11
+ * });
12
+ *
13
+ * await cache.set("u-42", record);
14
+ * var hit = await cache.get("u-42");
15
+ *
16
+ * // Memoize / read-through:
17
+ * var profile = await cache.wrap("u-42", function () {
18
+ * return db.users.findOne({ _id: "u-42" });
19
+ * });
20
+ *
21
+ * Surface (returned by create):
22
+ *
23
+ * get(key) → value | undefined
24
+ * set(key, value, opts?) → void (opts: { ttlMs })
25
+ * del(key) → boolean (existed)
26
+ * has(key) → boolean (does NOT bump LRU recency)
27
+ * clear(opts?) → number (purged) (opts: { req, context })
28
+ * size() → number
29
+ * wrap(key, fn, opts?) → fn's return value (opts: { ttlMs, singleFlight })
30
+ * close() → void
31
+ *
32
+ * Backends:
33
+ *
34
+ * "memory" (default) — Map + LRU eviction (maxEntries) + periodic
35
+ * sweep timer (sweepIntervalMs). Single-process accuracy only.
36
+ *
37
+ * "cluster" — _blamejs_cache table via cluster-storage. PRIMARY KEY
38
+ * is "<namespace>:<key>" so one table serves every CacheInstance.
39
+ * UPSERT via ON CONFLICT for atomic set; DELETE WHERE expiresAt
40
+ * for sweep. JSON-only value serialization.
41
+ *
42
+ * { get, set, del, clear, size, close } — operator-supplied custom
43
+ * backend (Redis, Memcached, …). All methods async.
44
+ *
45
+ * Validation tiers:
46
+ *
47
+ * - create() opts → Tier A (throw at boot)
48
+ * - get/set/del/has/wrap key arg type → Tier A (throw — programming bug)
49
+ * - set value type → C (operator decides what to store)
50
+ * - per-call ttlMs override → Tier A (throw — bad ttl is silent footgun)
51
+ * - audit / observability emit failures → Tier B (drop silent)
52
+ * - method called after close() → Tier A (throw BAD_STATE)
53
+ *
54
+ * Security defaults:
55
+ *
56
+ * - auditClear: true — mass purge is operator-action shaped (can hide forensics)
57
+ * - auditFailures: true — backend errors are signal
58
+ * - hot-path get/set/hit/miss/eviction → observability only (audit chain
59
+ * would drown at any reasonable QPS)
60
+ *
61
+ * The cache supports single-flight wrap (concurrent calls collapse),
62
+ * stale-while-revalidate, LRU eviction on the memory backend, a shared
63
+ * cluster backend, and a custom-backend escape hatch.
64
+ *
65
+ * Distributed pubsub invalidation, tag-based invalidation, and
66
+ * compression for the cluster backend are not built in — the cluster
67
+ * backend is always-fresh-by-shared-table, memory caches go
68
+ * stale-on-other-nodes by design, and read-through is what `wrap()`
69
+ * is for.
70
+ */
71
+
72
+ var clusterStorage = require("./cluster-storage");
73
+ var C = require("./constants");
74
+ var lazyRequire = require("./lazy-require");
75
+ var requestHelpers = require("./request-helpers");
76
+ var validateOpts = require("./validate-opts");
77
+ var { CacheError } = require("./framework-error");
78
+
79
+ var observability = lazyRequire(function () { return require("./observability"); });
80
+
81
+ var _err = CacheError.factory;
82
+
83
+ var DEFAULTS = Object.freeze({
84
+ backend: "memory",
85
+ ttlMs: C.TIME.minutes(5),
86
+ maxEntries: 10000,
87
+ sweepIntervalMs: C.TIME.minutes(1),
88
+ staleWhileRevalidate: false,
89
+ auditFailures: true,
90
+ auditClear: true,
91
+ });
92
+
93
+ // ---- Tier-A validation helpers ----
94
+
95
+ function _isFiniteNonNegative(n) {
96
+ return typeof n === "number" && isFinite(n) && n >= 0;
97
+ }
98
+
99
+ function _isPositiveInt(n) {
100
+ return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
101
+ }
102
+
103
+ // ttlMs accepts: any non-negative finite number OR Infinity. NaN, negative,
104
+ // or non-number is rejected.
105
+ function _validateTtl(name, value) {
106
+ if (value === Infinity) return;
107
+ if (typeof value !== "number" || isNaN(value) || !isFinite(value) || value < 0) {
108
+ throw _err("BAD_OPT", name + " must be a non-negative finite number or Infinity, got " +
109
+ (typeof value) + " " + JSON.stringify(value));
110
+ }
111
+ }
112
+
113
+ function _validateMaxEntries(value) {
114
+ if (value === Infinity) return;
115
+ if (!_isPositiveInt(value)) {
116
+ throw _err("BAD_OPT", "cache.create: maxEntries must be a positive integer or Infinity, got " +
117
+ JSON.stringify(value));
118
+ }
119
+ }
120
+
121
+ function _validateBackendObject(backend) {
122
+ var required = ["get", "set", "del", "clear", "size", "close"];
123
+ if (typeof backend !== "object" || backend === null) {
124
+ throw _err("BAD_OPT", "cache.create: custom backend must be an object");
125
+ }
126
+ for (var i = 0; i < required.length; i++) {
127
+ if (typeof backend[required[i]] !== "function") {
128
+ throw _err("BAD_OPT", "cache.create: custom backend missing method '" + required[i] +
129
+ "' (required: " + required.join(", ") + ")");
130
+ }
131
+ }
132
+ }
133
+
134
+ function _validateCreateOpts(opts) {
135
+ if (!opts || typeof opts !== "object") {
136
+ throw _err("BAD_OPT", "cache.create: opts must be an object");
137
+ }
138
+ if (typeof opts.namespace !== "string" || opts.namespace.length === 0) {
139
+ throw _err("BAD_OPT", "cache.create: namespace must be a non-empty string (cache identity for observability + audit)");
140
+ }
141
+ // Composite cluster-key separator is ":" — namespace must not contain it
142
+ // or two namespaces could collide ("a:b" + "c" = "a:b:c" = "a" + "b:c").
143
+ if (opts.namespace.indexOf(":") !== -1) {
144
+ throw _err("BAD_OPT", "cache.create: namespace must not contain ':' (used as cluster-key separator), got " +
145
+ JSON.stringify(opts.namespace));
146
+ }
147
+ if (opts.backend !== undefined) {
148
+ if (typeof opts.backend === "string") {
149
+ if (opts.backend !== "memory" && opts.backend !== "cluster") {
150
+ throw _err("BAD_OPT", "cache.create: backend string must be 'memory' or 'cluster', got " +
151
+ JSON.stringify(opts.backend));
152
+ }
153
+ } else {
154
+ _validateBackendObject(opts.backend);
155
+ }
156
+ }
157
+ if (opts.ttlMs !== undefined) _validateTtl("cache.create: ttlMs", opts.ttlMs);
158
+ if (opts.maxEntries !== undefined) _validateMaxEntries(opts.maxEntries);
159
+ if (opts.sweepIntervalMs !== undefined) {
160
+ if (!_isFiniteNonNegative(opts.sweepIntervalMs) || opts.sweepIntervalMs < 1000) {
161
+ throw _err("BAD_OPT", "cache.create: sweepIntervalMs must be a finite number ≥ 1000ms, got " +
162
+ JSON.stringify(opts.sweepIntervalMs));
163
+ }
164
+ }
165
+ if (opts.staleWhileRevalidate !== undefined && typeof opts.staleWhileRevalidate !== "boolean") {
166
+ throw _err("BAD_OPT", "cache.create: staleWhileRevalidate must be a boolean");
167
+ }
168
+ if (opts.auditFailures !== undefined && typeof opts.auditFailures !== "boolean") {
169
+ throw _err("BAD_OPT", "cache.create: auditFailures must be a boolean");
170
+ }
171
+ if (opts.auditClear !== undefined && typeof opts.auditClear !== "boolean") {
172
+ throw _err("BAD_OPT", "cache.create: auditClear must be a boolean");
173
+ }
174
+ if (opts.audit !== undefined && opts.audit !== null) {
175
+ if (typeof opts.audit !== "object" || typeof opts.audit.safeEmit !== "function") {
176
+ throw _err("BAD_OPT", "cache.create: audit must be a b.audit-shaped object (safeEmit fn)");
177
+ }
178
+ }
179
+ if (opts.observability !== undefined && opts.observability !== null) {
180
+ if (typeof opts.observability !== "object" ||
181
+ typeof opts.observability.event !== "function") {
182
+ throw _err("BAD_OPT", "cache.create: observability must be a b.observability-shaped object (event fn)");
183
+ }
184
+ }
185
+ if (opts.clock !== undefined && typeof opts.clock !== "function") {
186
+ throw _err("BAD_OPT", "cache.create: clock must be a function");
187
+ }
188
+ }
189
+
190
+ function _validateKey(key, ctx) {
191
+ if (typeof key !== "string" || key.length === 0) {
192
+ throw _err("BAD_KEY", ctx + ": key must be a non-empty string, got " +
193
+ (typeof key) + " " + JSON.stringify(key));
194
+ }
195
+ }
196
+
197
+ // ---- Memory backend ----
198
+ // LRU realized by Map insertion order (Node Map iterates in insertion order;
199
+ // re-inserting a key on hit moves it to the most-recent position).
200
+
201
+ function _memoryBackend(cfg) {
202
+ var entries = new Map(); // key → { value, expiresAt }
203
+ var maxEntries = cfg.maxEntries;
204
+ var clock = cfg.clock;
205
+ var emitObs = cfg.emitObs;
206
+ var namespace = cfg.namespace;
207
+ var sweepTimer = null;
208
+
209
+ function _isExpired(entry, now) {
210
+ return entry.expiresAt !== Infinity && entry.expiresAt <= now;
211
+ }
212
+
213
+ function _evictOldestIfFull() {
214
+ if (maxEntries === Infinity) return;
215
+ while (entries.size > maxEntries) {
216
+ var oldest = entries.keys().next().value;
217
+ entries.delete(oldest);
218
+ emitObs("cache.eviction.size", { namespace: namespace });
219
+ }
220
+ }
221
+
222
+ async function get(key) {
223
+ var now = clock();
224
+ var entry = entries.get(key);
225
+ if (!entry) return undefined;
226
+ if (_isExpired(entry, now)) {
227
+ entries.delete(key);
228
+ emitObs("cache.eviction.expired", { namespace: namespace });
229
+ return undefined;
230
+ }
231
+ // LRU recency bump: re-insert moves to the most-recent slot.
232
+ entries.delete(key);
233
+ entries.set(key, entry);
234
+ return entry.value;
235
+ }
236
+
237
+ async function set(key, value, expiresAt) {
238
+ // Existing key replacement: delete first so re-insert lands at the
239
+ // most-recent position (LRU on overwrite).
240
+ entries.delete(key);
241
+ entries.set(key, { value: value, expiresAt: expiresAt });
242
+ _evictOldestIfFull();
243
+ }
244
+
245
+ async function del(key) {
246
+ return entries.delete(key);
247
+ }
248
+
249
+ async function has(key) {
250
+ var entry = entries.get(key);
251
+ if (!entry) return false;
252
+ if (_isExpired(entry, clock())) {
253
+ entries.delete(key);
254
+ emitObs("cache.eviction.expired", { namespace: namespace });
255
+ return false;
256
+ }
257
+ return true;
258
+ }
259
+
260
+ async function clear() {
261
+ var n = entries.size;
262
+ entries.clear();
263
+ return n;
264
+ }
265
+
266
+ async function size() {
267
+ // Lazy purge: count only non-expired so size() reflects "live" entries.
268
+ var now = clock();
269
+ var live = 0;
270
+ for (var entry of entries.values()) {
271
+ if (!_isExpired(entry, now)) live++;
272
+ }
273
+ return live;
274
+ }
275
+
276
+ function _sweep() {
277
+ var now = clock();
278
+ var purged = 0;
279
+ for (var k of Array.from(entries.keys())) {
280
+ var e = entries.get(k);
281
+ if (_isExpired(e, now)) {
282
+ entries.delete(k);
283
+ purged++;
284
+ }
285
+ }
286
+ if (purged > 0) {
287
+ // Single observability event per sweep cycle is enough — operators
288
+ // see "purge happened with N evictions" via labels in dashboards.
289
+ for (var i = 0; i < purged; i++) emitObs("cache.eviction.expired", { namespace: namespace });
290
+ }
291
+ }
292
+
293
+ function _startSweep(intervalMs) {
294
+ if (sweepTimer) return;
295
+ sweepTimer = setInterval(_sweep, intervalMs);
296
+ if (typeof sweepTimer.unref === "function") sweepTimer.unref();
297
+ }
298
+
299
+ async function close() {
300
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
301
+ entries.clear();
302
+ }
303
+
304
+ return {
305
+ name: "memory",
306
+ get: get,
307
+ set: set,
308
+ del: del,
309
+ has: has,
310
+ clear: clear,
311
+ size: size,
312
+ close: close,
313
+ _startSweep: _startSweep,
314
+ // Test hook: raw entries map for state inspection
315
+ _entries: entries,
316
+ };
317
+ }
318
+
319
+ // ---- Cluster backend ----
320
+ // Single _blamejs_cache table; cacheKey = "<namespace>:<key>". JSON-only
321
+ // value serialization. UPSERT via ON CONFLICT for atomic set.
322
+
323
+ function _clusterBackend(cfg) {
324
+ var namespace = cfg.namespace;
325
+ var clock = cfg.clock;
326
+ var emitObs = cfg.emitObs;
327
+
328
+ // Composite cluster key. Namespace was validated to not contain ":"
329
+ // at create time, so the split is unambiguous.
330
+ function _composedKey(key) { return namespace + ":" + key; }
331
+
332
+ async function get(key) {
333
+ var now = clock();
334
+ var result = await clusterStorage.execute(
335
+ "SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?",
336
+ [_composedKey(key)]
337
+ );
338
+ if (!result || !result.rows || result.rows.length === 0) return undefined;
339
+ var row = result.rows[0];
340
+ if (row.expiresAt <= now) {
341
+ // Lazy purge: opportunistic delete on stale read.
342
+ try {
343
+ await clusterStorage.execute(
344
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt <= ?",
345
+ [_composedKey(key), now]
346
+ );
347
+ } catch (_e) { /* sweeper will catch it next pass */ }
348
+ emitObs("cache.eviction.expired", { namespace: namespace });
349
+ return undefined;
350
+ }
351
+ try { return JSON.parse(row.valueJson); }
352
+ catch (_e) { return undefined; }
353
+ }
354
+
355
+ async function set(key, value, expiresAt) {
356
+ var json = JSON.stringify(value);
357
+ var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
358
+ var now = clock();
359
+ // SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
360
+ await clusterStorage.execute(
361
+ "INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
362
+ "VALUES (?, ?, ?, ?) " +
363
+ "ON CONFLICT (cacheKey) DO UPDATE SET " +
364
+ "valueJson = ?, expiresAt = ?, updatedAt = ?",
365
+ [_composedKey(key), json, storedExpires, now, json, storedExpires, now]
366
+ );
367
+ }
368
+
369
+ async function del(key) {
370
+ var result = await clusterStorage.execute(
371
+ "DELETE FROM _blamejs_cache WHERE cacheKey = ?",
372
+ [_composedKey(key)]
373
+ );
374
+ return !!(result && result.rowCount && result.rowCount > 0);
375
+ }
376
+
377
+ async function has(key) {
378
+ // Existence check without recency bump — cluster backend doesn't
379
+ // track LRU at all, so "without bumping" is automatic. Honors
380
+ // expiresAt the same as get().
381
+ var now = clock();
382
+ var result = await clusterStorage.execute(
383
+ "SELECT expiresAt FROM _blamejs_cache WHERE cacheKey = ? AND expiresAt > ?",
384
+ [_composedKey(key), now]
385
+ );
386
+ return !!(result && result.rows && result.rows.length > 0);
387
+ }
388
+
389
+ async function clear() {
390
+ // Namespace-scoped wipe so two CacheInstance instances sharing the
391
+ // table don't cross-purge each other.
392
+ var like = namespace + ":%";
393
+ var result = await clusterStorage.execute(
394
+ "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ?",
395
+ [like]
396
+ );
397
+ return (result && result.rowCount) || 0;
398
+ }
399
+
400
+ async function size() {
401
+ var now = clock();
402
+ var like = namespace + ":%";
403
+ var result = await clusterStorage.execute(
404
+ "SELECT COUNT(*) AS n FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt > ?",
405
+ [like, now]
406
+ );
407
+ if (!result || !result.rows || result.rows.length === 0) return 0;
408
+ return result.rows[0].n || 0;
409
+ }
410
+
411
+ async function _sweep() {
412
+ var now = clock();
413
+ var like = namespace + ":%";
414
+ await clusterStorage.execute(
415
+ "DELETE FROM _blamejs_cache WHERE cacheKey LIKE ? AND expiresAt <= ?",
416
+ [like, now]
417
+ );
418
+ }
419
+
420
+ function _startSweep(intervalMs) {
421
+ var t = setInterval(function () {
422
+ _sweep().catch(function () { /* sweeper best-effort; next pass picks it up */ });
423
+ }, intervalMs);
424
+ if (typeof t.unref === "function") t.unref();
425
+ cfg._sweepTimer = t;
426
+ }
427
+
428
+ async function close() {
429
+ if (cfg._sweepTimer) { clearInterval(cfg._sweepTimer); cfg._sweepTimer = null; }
430
+ }
431
+
432
+ return {
433
+ name: "cluster",
434
+ get: get,
435
+ set: set,
436
+ del: del,
437
+ has: has,
438
+ clear: clear,
439
+ size: size,
440
+ close: close,
441
+ _startSweep: _startSweep,
442
+ };
443
+ }
444
+
445
+ // ---- Custom backend wrapper ----
446
+ // Operator-supplied { get, set, del, clear, size, close } — wrap to
447
+ // uniform-shape (no _startSweep, _entries). The operator is responsible
448
+ // for their own expiration; we pass expiresAt to set().
449
+
450
+ function _customBackend(operatorBackend, cfg) {
451
+ return {
452
+ name: "custom",
453
+ get: function (key) { return operatorBackend.get(key); },
454
+ set: function (key, value, expiresAt) { return operatorBackend.set(key, value, expiresAt); },
455
+ del: function (key) { return operatorBackend.del(key); },
456
+ has: function (key) {
457
+ // Optional has() — fall back to get-and-coerce if operator didn't
458
+ // implement it.
459
+ if (typeof operatorBackend.has === "function") return operatorBackend.has(key);
460
+ return Promise.resolve(operatorBackend.get(key)).then(function (v) { return v !== undefined; });
461
+ },
462
+ clear: function () { return operatorBackend.clear(); },
463
+ size: function () { return operatorBackend.size(); },
464
+ close: function () { return operatorBackend.close(); },
465
+ _startSweep: function () { /* operator backend manages its own sweep */ },
466
+ };
467
+ }
468
+
469
+ // ---- Public create ----
470
+
471
+ function create(opts) {
472
+ opts = opts || {};
473
+ validateOpts(opts, [
474
+ "namespace", "backend", "ttlMs", "maxEntries",
475
+ "sweepIntervalMs", "staleWhileRevalidate",
476
+ "auditFailures", "auditClear",
477
+ "audit", "observability", "clock",
478
+ ], "cache");
479
+ _validateCreateOpts(opts);
480
+
481
+ var namespace = opts.namespace;
482
+ var backendKind = opts.backend || DEFAULTS.backend;
483
+ var defaultTtlMs = (opts.ttlMs === undefined) ? DEFAULTS.ttlMs : opts.ttlMs;
484
+ var maxEntries = (opts.maxEntries === undefined) ? DEFAULTS.maxEntries : opts.maxEntries;
485
+ var sweepIntervalMs = (opts.sweepIntervalMs === undefined) ? DEFAULTS.sweepIntervalMs : opts.sweepIntervalMs;
486
+ var staleRevalidate = (opts.staleWhileRevalidate === undefined) ? DEFAULTS.staleWhileRevalidate : opts.staleWhileRevalidate;
487
+ var auditFailures = (opts.auditFailures === undefined) ? DEFAULTS.auditFailures : opts.auditFailures;
488
+ var auditClear = (opts.auditClear === undefined) ? DEFAULTS.auditClear : opts.auditClear;
489
+ var audit = opts.audit || null;
490
+ var operatorObs = opts.observability || null;
491
+ var clock = opts.clock || function () { return Date.now(); };
492
+
493
+ function emitObs(name, labels) {
494
+ try {
495
+ if (operatorObs) operatorObs.event(name, 1, labels || {});
496
+ else observability().event(name, 1, labels || {});
497
+ } catch (_e) { /* Tier B: hot-path observability sink */ }
498
+ }
499
+
500
+ function emitAudit(action, info) {
501
+ if (!audit) return;
502
+ try { audit.safeEmit(Object.assign({ action: action }, info || {})); }
503
+ catch (_e) { /* audit best-effort */ }
504
+ }
505
+
506
+ function _actor(callerOpts) {
507
+ var override = {};
508
+ if (callerOpts && callerOpts.context && typeof callerOpts.context === "object") {
509
+ for (var k in callerOpts.context) {
510
+ if (Object.prototype.hasOwnProperty.call(callerOpts.context, k)) {
511
+ override[k] = callerOpts.context[k];
512
+ }
513
+ }
514
+ }
515
+ return requestHelpers.extractActorContext(callerOpts && callerOpts.req, override);
516
+ }
517
+
518
+ function _backendFailedAudit(op, err) {
519
+ if (!auditFailures) return;
520
+ emitAudit("cache.backend.failed", {
521
+ actor: requestHelpers.extractActorContext(null),
522
+ resource: { kind: "cache", id: namespace },
523
+ outcome: "failure",
524
+ reason: "backend-error",
525
+ metadata: { op: op, code: (err && err.code) || null, message: (err && err.message) || String(err) },
526
+ });
527
+ }
528
+
529
+ // Resolve backend
530
+ var cfg = {
531
+ namespace: namespace,
532
+ maxEntries: maxEntries,
533
+ clock: clock,
534
+ emitObs: emitObs,
535
+ _sweepTimer: null,
536
+ };
537
+ var backend;
538
+ if (backendKind === "memory") {
539
+ backend = _memoryBackend(cfg);
540
+ } else if (backendKind === "cluster") {
541
+ backend = _clusterBackend(cfg);
542
+ } else {
543
+ backend = _customBackend(opts.backend, cfg);
544
+ }
545
+
546
+ backend._startSweep(sweepIntervalMs);
547
+
548
+ var closed = false;
549
+ function _ensureOpen(method) {
550
+ if (closed) {
551
+ throw _err("BAD_STATE", "cache." + method + ": cache instance has been closed");
552
+ }
553
+ }
554
+
555
+ // Single-flight inflight map for wrap()
556
+ var inflight = new Map();
557
+
558
+ // Stale-while-revalidate tracking (per-instance, in-memory). When SWR
559
+ // is on, wrap() stores entries with a HARD TTL of 2× ttlMs and tracks
560
+ // the SOFT expiration here. Reads after soft but before hard return
561
+ // the cached value AND kick off a background refresh; reads after
562
+ // hard fall through to a normal miss + compute. The soft-TTL map is
563
+ // memory-only even when the backend is cluster — refreshes are a
564
+ // best-effort optimization, not a correctness invariant, so a cache
565
+ // miss after restart (no soft data) just means we serve fresh once.
566
+ var softExpiry = new Map(); // key → softExpiresAt
567
+ var swrInflight = new Map(); // key → background-refresh promise
568
+ var SWR_HARD_MULTIPLIER = 2;
569
+
570
+ // ---- Public methods ----
571
+
572
+ function _resolveTtl(callerOpts, methodName) {
573
+ if (callerOpts && callerOpts.ttlMs !== undefined) {
574
+ _validateTtl("cache." + methodName + ": ttlMs", callerOpts.ttlMs);
575
+ return callerOpts.ttlMs;
576
+ }
577
+ return defaultTtlMs;
578
+ }
579
+
580
+ async function get(key) {
581
+ _ensureOpen("get");
582
+ _validateKey(key, "cache.get");
583
+ var v;
584
+ try { v = await backend.get(key); }
585
+ catch (e) {
586
+ emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
587
+ _backendFailedAudit("get", e);
588
+ throw e;
589
+ }
590
+ if (v === undefined) emitObs("cache.miss", { namespace: namespace });
591
+ else emitObs("cache.hit", { namespace: namespace });
592
+ return v;
593
+ }
594
+
595
+ async function set(key, value, callerOpts) {
596
+ _ensureOpen("set");
597
+ _validateKey(key, "cache.set");
598
+ var ttlMs = _resolveTtl(callerOpts, "set");
599
+ if (ttlMs === 0) return; // 0 means "do not cache"
600
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
601
+ try { await backend.set(key, value, expiresAt); }
602
+ catch (e) {
603
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
604
+ _backendFailedAudit("set", e);
605
+ throw e;
606
+ }
607
+ emitObs("cache.set", { namespace: namespace });
608
+ }
609
+
610
+ async function del(key) {
611
+ _ensureOpen("del");
612
+ _validateKey(key, "cache.del");
613
+ var existed;
614
+ try { existed = await backend.del(key); }
615
+ catch (e) {
616
+ emitObs("cache.backend.failed", { namespace: namespace, op: "del" });
617
+ _backendFailedAudit("del", e);
618
+ throw e;
619
+ }
620
+ if (existed) emitObs("cache.del", { namespace: namespace });
621
+ softExpiry.delete(key);
622
+ return existed;
623
+ }
624
+
625
+ async function has(key) {
626
+ _ensureOpen("has");
627
+ _validateKey(key, "cache.has");
628
+ try { return await backend.has(key); }
629
+ catch (e) {
630
+ emitObs("cache.backend.failed", { namespace: namespace, op: "has" });
631
+ _backendFailedAudit("has", e);
632
+ throw e;
633
+ }
634
+ }
635
+
636
+ async function clear(callerOpts) {
637
+ _ensureOpen("clear");
638
+ var purged;
639
+ try { purged = await backend.clear(); }
640
+ catch (e) {
641
+ emitObs("cache.backend.failed", { namespace: namespace, op: "clear" });
642
+ _backendFailedAudit("clear", e);
643
+ throw e;
644
+ }
645
+ emitObs("cache.clear", { namespace: namespace });
646
+ if (auditClear) {
647
+ emitAudit("cache.cleared", {
648
+ actor: _actor(callerOpts),
649
+ resource: { kind: "cache", id: namespace },
650
+ outcome: "success",
651
+ metadata: { itemCount: purged },
652
+ });
653
+ }
654
+ // Drop any in-flight wrap promises — operator clear means "consumers
655
+ // should re-fetch", and in-flight resolves would seed stale entries
656
+ // post-clear.
657
+ inflight.clear();
658
+ swrInflight.clear();
659
+ softExpiry.clear();
660
+ return purged;
661
+ }
662
+
663
+ async function size() {
664
+ _ensureOpen("size");
665
+ try { return await backend.size(); }
666
+ catch (e) {
667
+ emitObs("cache.backend.failed", { namespace: namespace, op: "size" });
668
+ _backendFailedAudit("size", e);
669
+ throw e;
670
+ }
671
+ }
672
+
673
+ function _backgroundRefresh(key, fn, ttlMs) {
674
+ if (swrInflight.has(key)) return; // already refreshing
675
+ var p = (async function () {
676
+ var startedAt = clock();
677
+ var computed;
678
+ try { computed = await fn(); }
679
+ finally {
680
+ emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
681
+ }
682
+ var expiresAt = _writeWithSwr(key, computed, ttlMs);
683
+ void expiresAt;
684
+ return computed;
685
+ })();
686
+ swrInflight.set(key, p);
687
+ p.then(
688
+ function () { swrInflight.delete(key); },
689
+ function (_e) {
690
+ swrInflight.delete(key);
691
+ // Background refresh failed; stale value already served. Surface
692
+ // via observability so operators see it without breaking the
693
+ // request that triggered the refresh.
694
+ emitObs("cache.refresh.failed", { namespace: namespace });
695
+ }
696
+ );
697
+ }
698
+
699
+ function _writeWithSwr(key, value, ttlMs) {
700
+ if (ttlMs === 0) return null; // 0 means "do not cache"
701
+ var now = clock();
702
+ var hardTtlMs = (ttlMs === Infinity)
703
+ ? Infinity
704
+ : (staleRevalidate ? ttlMs * SWR_HARD_MULTIPLIER : ttlMs);
705
+ var expiresAt = (hardTtlMs === Infinity) ? Infinity : (now + hardTtlMs);
706
+ if (staleRevalidate && ttlMs !== Infinity) {
707
+ softExpiry.set(key, now + ttlMs);
708
+ } else {
709
+ softExpiry.delete(key);
710
+ }
711
+ // Backend write — failure surfaces via observability + audit but
712
+ // doesn't bubble (caller already has the computed value).
713
+ backend.set(key, value, expiresAt).catch(function (e) {
714
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
715
+ _backendFailedAudit("set", e);
716
+ });
717
+ return expiresAt;
718
+ }
719
+
720
+ async function wrap(key, fn, callerOpts) {
721
+ _ensureOpen("wrap");
722
+ _validateKey(key, "cache.wrap");
723
+ if (typeof fn !== "function") {
724
+ throw _err("BAD_OPT", "cache.wrap: fn must be a function, got " + typeof fn);
725
+ }
726
+ var ttlMs = _resolveTtl(callerOpts, "wrap");
727
+ var singleFlight = !(callerOpts && callerOpts.singleFlight === false);
728
+
729
+ var existing;
730
+ try { existing = await backend.get(key); }
731
+ catch (e) {
732
+ emitObs("cache.backend.failed", { namespace: namespace, op: "get" });
733
+ _backendFailedAudit("get", e);
734
+ throw e;
735
+ }
736
+
737
+ if (existing !== undefined) {
738
+ // SWR: served from backend, but might be stale (past soft TTL).
739
+ var soft = softExpiry.get(key);
740
+ var now = clock();
741
+ if (staleRevalidate && soft !== undefined && soft <= now) {
742
+ emitObs("cache.hit", { namespace: namespace });
743
+ _backgroundRefresh(key, fn, ttlMs);
744
+ return existing;
745
+ }
746
+ emitObs("cache.hit", { namespace: namespace });
747
+ return existing;
748
+ }
749
+ emitObs("cache.miss", { namespace: namespace });
750
+
751
+ if (singleFlight && inflight.has(key)) {
752
+ emitObs("cache.wrap.singleflight.collapsed", { namespace: namespace });
753
+ return inflight.get(key);
754
+ }
755
+
756
+ var promise = (async function () {
757
+ var startedAt = clock();
758
+ var computed;
759
+ try { computed = await fn(); }
760
+ finally {
761
+ emitObs("cache.wrap.compute", { namespace: namespace, ms: clock() - startedAt });
762
+ }
763
+ if (ttlMs !== 0) {
764
+ if (staleRevalidate) {
765
+ _writeWithSwr(key, computed, ttlMs);
766
+ } else {
767
+ var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
768
+ try { await backend.set(key, computed, expiresAt); }
769
+ catch (e) {
770
+ emitObs("cache.backend.failed", { namespace: namespace, op: "set" });
771
+ _backendFailedAudit("set", e);
772
+ // Failed write doesn't fail the wrap — caller still gets the
773
+ // computed value; cache just didn't persist.
774
+ }
775
+ }
776
+ }
777
+ return computed;
778
+ })();
779
+ if (singleFlight) {
780
+ inflight.set(key, promise);
781
+ promise.then(
782
+ function () { inflight.delete(key); },
783
+ function () { inflight.delete(key); }
784
+ );
785
+ }
786
+ return promise;
787
+ }
788
+
789
+ async function close() {
790
+ if (closed) return;
791
+ closed = true;
792
+ inflight.clear();
793
+ swrInflight.clear();
794
+ softExpiry.clear();
795
+ try { await backend.close(); }
796
+ catch (_e) { /* close best-effort */ }
797
+ }
798
+
799
+ return {
800
+ get: get,
801
+ set: set,
802
+ del: del,
803
+ has: has,
804
+ clear: clear,
805
+ size: size,
806
+ wrap: wrap,
807
+ close: close,
808
+ namespace: namespace,
809
+ // Test hooks
810
+ _backend: backend,
811
+ _inflight: inflight,
812
+ };
813
+ }
814
+
815
+ module.exports = {
816
+ create: create,
817
+ CacheError: CacheError,
818
+ DEFAULTS: DEFAULTS,
819
+ };