@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.
- package/CHANGELOG.md +230 -0
- package/LICENSE +201 -0
- package/LTS-CALENDAR.md +29 -0
- package/MIGRATING.md +7 -0
- package/NOTICE +59 -0
- package/README.md +100 -0
- package/bin/blamejs.js +13 -0
- package/index.js +253 -0
- package/lib/api-key.js +705 -0
- package/lib/api-snapshot.js +335 -0
- package/lib/app-shutdown.js +381 -0
- package/lib/app.js +364 -0
- package/lib/atomic-file.js +525 -0
- package/lib/audit-chain.js +168 -0
- package/lib/audit-sign.js +319 -0
- package/lib/audit-tools.js +682 -0
- package/lib/audit.js +753 -0
- package/lib/auth/jwt.js +280 -0
- package/lib/auth/oauth.js +691 -0
- package/lib/auth/passkey.js +185 -0
- package/lib/auth/password.js +139 -0
- package/lib/auth/totp.js +17 -0
- package/lib/auth-header.js +81 -0
- package/lib/backup/bundle.js +219 -0
- package/lib/backup/crypto.js +174 -0
- package/lib/backup/index.js +490 -0
- package/lib/backup/manifest.js +275 -0
- package/lib/bundler.js +295 -0
- package/lib/cache.js +819 -0
- package/lib/chain-writer.js +234 -0
- package/lib/cli-helpers.js +201 -0
- package/lib/cli.js +1377 -0
- package/lib/cluster-provider-db.js +245 -0
- package/lib/cluster-storage.js +166 -0
- package/lib/cluster.js +691 -0
- package/lib/consent.js +222 -0
- package/lib/constants.js +186 -0
- package/lib/cookies.js +293 -0
- package/lib/credential-hash.js +303 -0
- package/lib/crypto-field.js +159 -0
- package/lib/crypto.js +250 -0
- package/lib/db-query.js +297 -0
- package/lib/db-schema.js +250 -0
- package/lib/db.js +1054 -0
- package/lib/deprecate.js +226 -0
- package/lib/dev.js +324 -0
- package/lib/error-page.js +424 -0
- package/lib/events.js +135 -0
- package/lib/external-db.js +422 -0
- package/lib/forms.js +378 -0
- package/lib/framework-error.js +189 -0
- package/lib/framework-schema.js +604 -0
- package/lib/handlers.js +350 -0
- package/lib/html-balance.js +227 -0
- package/lib/http-client.js +615 -0
- package/lib/i18n.js +780 -0
- package/lib/jobs.js +181 -0
- package/lib/lazy-require.js +48 -0
- package/lib/log-stream-local.js +137 -0
- package/lib/log-stream-webhook.js +170 -0
- package/lib/log-stream.js +211 -0
- package/lib/log.js +355 -0
- package/lib/mail-bounce.js +507 -0
- package/lib/mail.js +701 -0
- package/lib/metrics.js +647 -0
- package/lib/middleware/api-encrypt.js +553 -0
- package/lib/middleware/attach-user.js +156 -0
- package/lib/middleware/body-parser.js +883 -0
- package/lib/middleware/bot-guard.js +148 -0
- package/lib/middleware/compression.js +436 -0
- package/lib/middleware/cors.js +236 -0
- package/lib/middleware/csp-nonce.js +332 -0
- package/lib/middleware/csrf-protect.js +275 -0
- package/lib/middleware/error-handler.js +46 -0
- package/lib/middleware/health.js +358 -0
- package/lib/middleware/index.js +52 -0
- package/lib/middleware/rate-limit.js +319 -0
- package/lib/middleware/request-id.js +53 -0
- package/lib/middleware/require-auth.js +95 -0
- package/lib/middleware/security-headers.js +91 -0
- package/lib/migrations.js +353 -0
- package/lib/mtls-ca.js +333 -0
- package/lib/mtls-engine-default.js +285 -0
- package/lib/nonce-store.js +177 -0
- package/lib/notify.js +643 -0
- package/lib/ntp-check.js +178 -0
- package/lib/object-store/azure-blob.js +467 -0
- package/lib/object-store/gcs.js +469 -0
- package/lib/object-store/http-put.js +153 -0
- package/lib/object-store/index.js +140 -0
- package/lib/object-store/local.js +163 -0
- package/lib/object-store/retry.js +15 -0
- package/lib/object-store/sigv4.js +535 -0
- package/lib/observability.js +114 -0
- package/lib/pagination.js +371 -0
- package/lib/parsers/index.js +64 -0
- package/lib/parsers/safe-csv.js +224 -0
- package/lib/parsers/safe-env.js +614 -0
- package/lib/parsers/safe-toml.js +745 -0
- package/lib/parsers/safe-xml.js +379 -0
- package/lib/parsers/safe-yaml.js +977 -0
- package/lib/permissions.js +430 -0
- package/lib/pqc-agent.js +85 -0
- package/lib/pqc-gate.js +266 -0
- package/lib/protocol-dispatcher.js +144 -0
- package/lib/queue-local.js +327 -0
- package/lib/queue.js +430 -0
- package/lib/redact.js +192 -0
- package/lib/render.js +193 -0
- package/lib/request-helpers.js +178 -0
- package/lib/restore-bundle.js +239 -0
- package/lib/restore-rollback.js +254 -0
- package/lib/restore.js +301 -0
- package/lib/retry.js +329 -0
- package/lib/router.js +437 -0
- package/lib/safe-async.js +520 -0
- package/lib/safe-buffer.js +162 -0
- package/lib/safe-json.js +532 -0
- package/lib/safe-schema.js +1176 -0
- package/lib/safe-sql.js +157 -0
- package/lib/safe-url.js +109 -0
- package/lib/scheduler.js +680 -0
- package/lib/seeders.js +622 -0
- package/lib/session.js +304 -0
- package/lib/slug.js +243 -0
- package/lib/static.js +268 -0
- package/lib/storage.js +470 -0
- package/lib/subject.js +281 -0
- package/lib/template.js +781 -0
- package/lib/testing.js +621 -0
- package/lib/totp.js +285 -0
- package/lib/tracing.js +484 -0
- package/lib/validate-opts.js +56 -0
- package/lib/vault/index.js +299 -0
- package/lib/vault/passphrase-ops.js +311 -0
- package/lib/vault/passphrase-source.js +198 -0
- package/lib/vault/rotate.js +761 -0
- package/lib/vault/wrap.js +289 -0
- package/lib/vendor/MANIFEST.json +84 -0
- package/lib/vendor/argon2/argon2.cjs +466 -0
- package/lib/vendor/argon2/argon2.d.cts +62 -0
- package/lib/vendor/argon2/package.json +1 -0
- package/lib/vendor/argon2/prebuilds/darwin-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/darwin-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/freebsd-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm/argon2.armv7.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-arm64/argon2.armv8.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.glibc.node +0 -0
- package/lib/vendor/argon2/prebuilds/linux-x64/argon2.musl.node +0 -0
- package/lib/vendor/argon2/prebuilds/win32-x64/argon2.glibc.node +0 -0
- package/lib/vendor/noble-ciphers.cjs +9 -0
- package/lib/vendor/pki.cjs +181 -0
- package/lib/vendor/simplewebauthn-server.cjs +328 -0
- package/lib/webhook.js +632 -0
- package/lib/websocket-channels.js +413 -0
- package/lib/websocket.js +833 -0
- 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
|
+
};
|