@blamejs/core 0.7.18 → 0.7.19
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 +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +337 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +628 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -369
- package/lib/mail.js +981 -981
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +82 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -111
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -340
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/cache-redis.js
CHANGED
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Redis-backed cache backend for b.cache.
|
|
4
|
-
*
|
|
5
|
-
* Storage layout (operator-overridable namespace; default = "blamejs:cache"):
|
|
6
|
-
* <namespace>:e:<key> STRING — JSON-encoded value
|
|
7
|
-
* PEXPIREAT honours the cache's TTL
|
|
8
|
-
* <namespace>:t:<tag> SET — cacheKeys carrying that tag
|
|
9
|
-
* (powers invalidateTag fan-out)
|
|
10
|
-
* <namespace>:k:<key>:tags SET — tags this key carries
|
|
11
|
-
* (powers per-key tag cleanup on
|
|
12
|
-
* del / set-overwrite, expires
|
|
13
|
-
* alongside the entry)
|
|
14
|
-
*
|
|
15
|
-
* Same backend contract as the framework's other cache backends (memory,
|
|
16
|
-
* cluster, custom): get / set / del / has / clear / size / close, plus
|
|
17
|
-
* the optional invalidateTag + getTags + bytes + _startSweep hooks the
|
|
18
|
-
* `_customBackend` wrapper looks for.
|
|
19
|
-
*
|
|
20
|
-
* TTL is enforced by Redis itself (PEXPIREAT) — the framework does NOT
|
|
21
|
-
* run a sweep timer for this backend; expired entries vanish from the
|
|
22
|
-
* key space without app-side intervention.
|
|
23
|
-
*
|
|
24
|
-
* Sliding TTL: bumps the entry's expiry to `now + defaultTtlMs` on every
|
|
25
|
-
* read when the cache was created with `slidingTtl: true` and the cache
|
|
26
|
-
* has a finite defaultTtlMs. Same shape as the cluster backend.
|
|
27
|
-
*
|
|
28
|
-
* Concurrency: Redis is single-threaded for command execution; our get
|
|
29
|
-
* + set + tag updates are sequential, not pipelined inside a MULTI, so
|
|
30
|
-
* a worst-case interleaving might leave a tag set with a key that no
|
|
31
|
-
* longer exists. The reverse `keyTags` set + `del()` cleanup means the
|
|
32
|
-
* stale entry is reaped on the next `invalidateTag` (the SET membership
|
|
33
|
-
* is filtered against actual key existence — see invalidateTag below).
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
var C = require("./constants");
|
|
37
|
-
var { boot } = require("./log");
|
|
38
|
-
var redisClient = require("./redis-client");
|
|
39
|
-
var safeJson = require("./safe-json");
|
|
40
|
-
var { CacheError } = require("./framework-error");
|
|
41
|
-
|
|
42
|
-
var log = boot("cache-redis");
|
|
43
|
-
|
|
44
|
-
var _err = CacheError.factory;
|
|
45
|
-
|
|
46
|
-
function _toStr(v) {
|
|
47
|
-
if (v === null || v === undefined) return null;
|
|
48
|
-
return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function create(cfg) {
|
|
52
|
-
cfg = cfg || {};
|
|
53
|
-
if (typeof cfg.url !== "string" || cfg.url.length === 0) {
|
|
54
|
-
throw _err("BAD_OPT", "cache-redis: opts.url is required (e.g. redis://localhost:6379/0)");
|
|
55
|
-
}
|
|
56
|
-
var namespace = cfg.namespace;
|
|
57
|
-
var clock = cfg.clock || function () { return Date.now(); };
|
|
58
|
-
var emitObs = cfg.emitObs || function () {};
|
|
59
|
-
var slidingTtl = cfg.slidingTtl;
|
|
60
|
-
var defaultTtlMs = cfg.defaultTtlMs;
|
|
61
|
-
|
|
62
|
-
var client = redisClient.create(redisClient.pickClientOpts(cfg));
|
|
63
|
-
|
|
64
|
-
// Lazy connect — defer until first op so cache.create stays sync-safe.
|
|
65
|
-
var connectPromise = null;
|
|
66
|
-
function _ensureConnected() {
|
|
67
|
-
if (client.isOpen()) return Promise.resolve();
|
|
68
|
-
if (!connectPromise) connectPromise = client.connect();
|
|
69
|
-
return connectPromise;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function _key(k) { return namespace + ":e:" + k; }
|
|
73
|
-
function _tagKey(t) { return namespace + ":t:" + t; }
|
|
74
|
-
function _keyTagsKey(k) { return namespace + ":k:" + k + ":tags"; }
|
|
75
|
-
|
|
76
|
-
async function get(key) {
|
|
77
|
-
await _ensureConnected();
|
|
78
|
-
var v = await client.command("GET", _key(key));
|
|
79
|
-
var s = _toStr(v);
|
|
80
|
-
if (s === null) return undefined;
|
|
81
|
-
var parsed;
|
|
82
|
-
try { parsed = safeJson.parse(s, { maxBytes: C.BYTES.mib(64) }); }
|
|
83
|
-
catch (_e) { return undefined; }
|
|
84
|
-
// Sliding TTL: extend the entry's life on every read. Best-effort
|
|
85
|
-
// (PEXPIREAT may race with delete; if it fails the operator just
|
|
86
|
-
// sees the original TTL play out).
|
|
87
|
-
if (slidingTtl && typeof defaultTtlMs === "number" && isFinite(defaultTtlMs) && defaultTtlMs > 0) {
|
|
88
|
-
var newExp = clock() + defaultTtlMs;
|
|
89
|
-
client.command("PEXPIREAT", _key(key), String(Math.floor(newExp)))
|
|
90
|
-
.catch(function () { /* best-effort */ });
|
|
91
|
-
client.command("PEXPIREAT", _keyTagsKey(key), String(Math.floor(newExp)))
|
|
92
|
-
.catch(function () { /* best-effort */ });
|
|
93
|
-
}
|
|
94
|
-
return parsed;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function set(key, value, expiresAt, meta) {
|
|
98
|
-
await _ensureConnected();
|
|
99
|
-
var json = JSON.stringify(value);
|
|
100
|
-
|
|
101
|
-
// Drop any prior tag membership for this key (tags may have changed
|
|
102
|
-
// across sets). The reverse-tag set tells us which tag SETs need
|
|
103
|
-
// pruning without scanning every tag in the namespace.
|
|
104
|
-
var oldTagsRv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
105
|
-
var oldTags = (oldTagsRv || []).map(_toStr).filter(Boolean);
|
|
106
|
-
for (var ot = 0; ot < oldTags.length; ot++) {
|
|
107
|
-
try { await client.command("SREM", _tagKey(oldTags[ot]), key); }
|
|
108
|
-
catch (_e) { /* best-effort */ }
|
|
109
|
-
}
|
|
110
|
-
if (oldTags.length > 0) {
|
|
111
|
-
try { await client.command("DEL", _keyTagsKey(key)); }
|
|
112
|
-
catch (_e) { /* best-effort */ }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Write the value with PEXPIREAT for finite TTLs; un-expiring SET
|
|
116
|
-
// for Infinity (operator opted for "cache forever until evicted").
|
|
117
|
-
if (typeof expiresAt === "number" && isFinite(expiresAt)) {
|
|
118
|
-
await client.command("SET", _key(key), json, "PXAT", String(Math.floor(expiresAt)));
|
|
119
|
-
} else {
|
|
120
|
-
await client.command("SET", _key(key), json);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Wire the new tags. SADD is idempotent on duplicate tag names.
|
|
124
|
-
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
125
|
-
if (tags && tags.length > 0) {
|
|
126
|
-
for (var t = 0; t < tags.length; t++) {
|
|
127
|
-
await client.command("SADD", _tagKey(tags[t]), key);
|
|
128
|
-
await client.command("SADD", _keyTagsKey(key), tags[t]);
|
|
129
|
-
}
|
|
130
|
-
// Match the entry's lifetime on the keyTags reverse-set so
|
|
131
|
-
// membership doesn't outlive the value.
|
|
132
|
-
if (typeof expiresAt === "number" && isFinite(expiresAt)) {
|
|
133
|
-
try { await client.command("PEXPIREAT", _keyTagsKey(key), String(Math.floor(expiresAt))); }
|
|
134
|
-
catch (_e) { /* best-effort */ }
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
emitObs("cache.redis.set", { namespace: namespace });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function del(key) {
|
|
141
|
-
await _ensureConnected();
|
|
142
|
-
var oldTagsRv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
143
|
-
var oldTags = (oldTagsRv || []).map(_toStr).filter(Boolean);
|
|
144
|
-
for (var i = 0; i < oldTags.length; i++) {
|
|
145
|
-
try { await client.command("SREM", _tagKey(oldTags[i]), key); }
|
|
146
|
-
catch (_e) { /* best-effort */ }
|
|
147
|
-
}
|
|
148
|
-
var dels = await Promise.all([
|
|
149
|
-
client.command("DEL", _key(key)),
|
|
150
|
-
client.command("DEL", _keyTagsKey(key)),
|
|
151
|
-
]);
|
|
152
|
-
return Number(dels[0]) === 1;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function has(key) {
|
|
156
|
-
await _ensureConnected();
|
|
157
|
-
var rv = await client.command("EXISTS", _key(key));
|
|
158
|
-
return Number(rv) === 1;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
async function clear() {
|
|
162
|
-
await _ensureConnected();
|
|
163
|
-
// SCAN-and-DEL every key under this namespace. Using SCAN avoids
|
|
164
|
-
// KEYS' O(N) blocking pass and lets a busy Redis serve other
|
|
165
|
-
// commands between cursor batches.
|
|
166
|
-
var cursor = "0";
|
|
167
|
-
do {
|
|
168
|
-
var rv = await client.command("SCAN", cursor, "MATCH", namespace + ":*", "COUNT", "200");
|
|
169
|
-
cursor = _toStr(rv[0]) || "0";
|
|
170
|
-
var keys = (rv[1] || []).map(_toStr).filter(Boolean);
|
|
171
|
-
if (keys.length > 0) {
|
|
172
|
-
await client.command.apply(client, ["DEL"].concat(keys));
|
|
173
|
-
}
|
|
174
|
-
} while (cursor !== "0");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function size() {
|
|
178
|
-
await _ensureConnected();
|
|
179
|
-
var cursor = "0";
|
|
180
|
-
var n = 0;
|
|
181
|
-
do {
|
|
182
|
-
var rv = await client.command("SCAN", cursor, "MATCH", namespace + ":e:*", "COUNT", "200");
|
|
183
|
-
cursor = _toStr(rv[0]) || "0";
|
|
184
|
-
n += (rv[1] || []).length;
|
|
185
|
-
} while (cursor !== "0");
|
|
186
|
-
return n;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function bytes() {
|
|
190
|
-
// Per-entry byte accounting on Redis would mean a MEMORY USAGE call
|
|
191
|
-
// per key, which is operator-expensive and Redis-version-dependent.
|
|
192
|
-
// Operators wanting this use the memory backend or run their own
|
|
193
|
-
// monitoring against MEMORY STATS at the cluster level.
|
|
194
|
-
return Promise.resolve(0);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
async function invalidateTag(tag) {
|
|
198
|
-
await _ensureConnected();
|
|
199
|
-
var rv = await client.command("SMEMBERS", _tagKey(tag));
|
|
200
|
-
var keys = (rv || []).map(_toStr).filter(Boolean);
|
|
201
|
-
var dropped = 0;
|
|
202
|
-
for (var i = 0; i < keys.length; i++) {
|
|
203
|
-
// Filter out stale tag-membership entries (key already expired
|
|
204
|
-
// from PEXPIREAT but lingered in the tag SET) by checking
|
|
205
|
-
// EXISTS before del. Saves an unnecessary DEL round-trip on
|
|
206
|
-
// ghost keys.
|
|
207
|
-
var existsRv = await client.command("EXISTS", _key(keys[i]));
|
|
208
|
-
if (Number(existsRv) === 1) {
|
|
209
|
-
await del(keys[i]);
|
|
210
|
-
dropped += 1;
|
|
211
|
-
} else {
|
|
212
|
-
// Ghost — just SREM from this tag set + drop the reverse.
|
|
213
|
-
try { await client.command("SREM", _tagKey(tag), keys[i]); }
|
|
214
|
-
catch (e) { log.debug("invalidateTag-cleanup-failed", { op: "SREM", tag: tag, error: e.message }); }
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Drop the tag set itself if it's now empty (or empty after the
|
|
218
|
-
// ghost cleanup above).
|
|
219
|
-
var remaining = await client.command("SCARD", _tagKey(tag));
|
|
220
|
-
if (Number(remaining) === 0) {
|
|
221
|
-
try { await client.command("DEL", _tagKey(tag)); }
|
|
222
|
-
catch (e) { log.debug("invalidateTag-cleanup-failed", { op: "DEL", tag: tag, error: e.message }); }
|
|
223
|
-
}
|
|
224
|
-
emitObs("cache.redis.invalidateTag", { namespace: namespace, tag: tag, dropped: dropped });
|
|
225
|
-
return dropped;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
async function getTags(key) {
|
|
229
|
-
await _ensureConnected();
|
|
230
|
-
var rv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
231
|
-
return (rv || []).map(_toStr).filter(Boolean);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function close() {
|
|
235
|
-
try { await client.close(); }
|
|
236
|
-
catch (_e) { /* best-effort */ }
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
name: "redis",
|
|
241
|
-
get: get,
|
|
242
|
-
set: set,
|
|
243
|
-
del: del,
|
|
244
|
-
has: has,
|
|
245
|
-
clear: clear,
|
|
246
|
-
size: size,
|
|
247
|
-
bytes: bytes,
|
|
248
|
-
invalidateTag: invalidateTag,
|
|
249
|
-
getTags: getTags,
|
|
250
|
-
close: close,
|
|
251
|
-
// Redis enforces TTL itself; the framework's sweeper is a no-op here.
|
|
252
|
-
_startSweep: function () {},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
module.exports = { create: create };
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Redis-backed cache backend for b.cache.
|
|
4
|
+
*
|
|
5
|
+
* Storage layout (operator-overridable namespace; default = "blamejs:cache"):
|
|
6
|
+
* <namespace>:e:<key> STRING — JSON-encoded value
|
|
7
|
+
* PEXPIREAT honours the cache's TTL
|
|
8
|
+
* <namespace>:t:<tag> SET — cacheKeys carrying that tag
|
|
9
|
+
* (powers invalidateTag fan-out)
|
|
10
|
+
* <namespace>:k:<key>:tags SET — tags this key carries
|
|
11
|
+
* (powers per-key tag cleanup on
|
|
12
|
+
* del / set-overwrite, expires
|
|
13
|
+
* alongside the entry)
|
|
14
|
+
*
|
|
15
|
+
* Same backend contract as the framework's other cache backends (memory,
|
|
16
|
+
* cluster, custom): get / set / del / has / clear / size / close, plus
|
|
17
|
+
* the optional invalidateTag + getTags + bytes + _startSweep hooks the
|
|
18
|
+
* `_customBackend` wrapper looks for.
|
|
19
|
+
*
|
|
20
|
+
* TTL is enforced by Redis itself (PEXPIREAT) — the framework does NOT
|
|
21
|
+
* run a sweep timer for this backend; expired entries vanish from the
|
|
22
|
+
* key space without app-side intervention.
|
|
23
|
+
*
|
|
24
|
+
* Sliding TTL: bumps the entry's expiry to `now + defaultTtlMs` on every
|
|
25
|
+
* read when the cache was created with `slidingTtl: true` and the cache
|
|
26
|
+
* has a finite defaultTtlMs. Same shape as the cluster backend.
|
|
27
|
+
*
|
|
28
|
+
* Concurrency: Redis is single-threaded for command execution; our get
|
|
29
|
+
* + set + tag updates are sequential, not pipelined inside a MULTI, so
|
|
30
|
+
* a worst-case interleaving might leave a tag set with a key that no
|
|
31
|
+
* longer exists. The reverse `keyTags` set + `del()` cleanup means the
|
|
32
|
+
* stale entry is reaped on the next `invalidateTag` (the SET membership
|
|
33
|
+
* is filtered against actual key existence — see invalidateTag below).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
var C = require("./constants");
|
|
37
|
+
var { boot } = require("./log");
|
|
38
|
+
var redisClient = require("./redis-client");
|
|
39
|
+
var safeJson = require("./safe-json");
|
|
40
|
+
var { CacheError } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var log = boot("cache-redis");
|
|
43
|
+
|
|
44
|
+
var _err = CacheError.factory;
|
|
45
|
+
|
|
46
|
+
function _toStr(v) {
|
|
47
|
+
if (v === null || v === undefined) return null;
|
|
48
|
+
return Buffer.isBuffer(v) ? v.toString("utf8") : String(v);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function create(cfg) {
|
|
52
|
+
cfg = cfg || {};
|
|
53
|
+
if (typeof cfg.url !== "string" || cfg.url.length === 0) {
|
|
54
|
+
throw _err("BAD_OPT", "cache-redis: opts.url is required (e.g. redis://localhost:6379/0)");
|
|
55
|
+
}
|
|
56
|
+
var namespace = cfg.namespace;
|
|
57
|
+
var clock = cfg.clock || function () { return Date.now(); };
|
|
58
|
+
var emitObs = cfg.emitObs || function () {};
|
|
59
|
+
var slidingTtl = cfg.slidingTtl;
|
|
60
|
+
var defaultTtlMs = cfg.defaultTtlMs;
|
|
61
|
+
|
|
62
|
+
var client = redisClient.create(redisClient.pickClientOpts(cfg));
|
|
63
|
+
|
|
64
|
+
// Lazy connect — defer until first op so cache.create stays sync-safe.
|
|
65
|
+
var connectPromise = null;
|
|
66
|
+
function _ensureConnected() {
|
|
67
|
+
if (client.isOpen()) return Promise.resolve();
|
|
68
|
+
if (!connectPromise) connectPromise = client.connect();
|
|
69
|
+
return connectPromise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _key(k) { return namespace + ":e:" + k; }
|
|
73
|
+
function _tagKey(t) { return namespace + ":t:" + t; }
|
|
74
|
+
function _keyTagsKey(k) { return namespace + ":k:" + k + ":tags"; }
|
|
75
|
+
|
|
76
|
+
async function get(key) {
|
|
77
|
+
await _ensureConnected();
|
|
78
|
+
var v = await client.command("GET", _key(key));
|
|
79
|
+
var s = _toStr(v);
|
|
80
|
+
if (s === null) return undefined;
|
|
81
|
+
var parsed;
|
|
82
|
+
try { parsed = safeJson.parse(s, { maxBytes: C.BYTES.mib(64) }); }
|
|
83
|
+
catch (_e) { return undefined; }
|
|
84
|
+
// Sliding TTL: extend the entry's life on every read. Best-effort
|
|
85
|
+
// (PEXPIREAT may race with delete; if it fails the operator just
|
|
86
|
+
// sees the original TTL play out).
|
|
87
|
+
if (slidingTtl && typeof defaultTtlMs === "number" && isFinite(defaultTtlMs) && defaultTtlMs > 0) {
|
|
88
|
+
var newExp = clock() + defaultTtlMs;
|
|
89
|
+
client.command("PEXPIREAT", _key(key), String(Math.floor(newExp)))
|
|
90
|
+
.catch(function () { /* best-effort */ });
|
|
91
|
+
client.command("PEXPIREAT", _keyTagsKey(key), String(Math.floor(newExp)))
|
|
92
|
+
.catch(function () { /* best-effort */ });
|
|
93
|
+
}
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function set(key, value, expiresAt, meta) {
|
|
98
|
+
await _ensureConnected();
|
|
99
|
+
var json = JSON.stringify(value);
|
|
100
|
+
|
|
101
|
+
// Drop any prior tag membership for this key (tags may have changed
|
|
102
|
+
// across sets). The reverse-tag set tells us which tag SETs need
|
|
103
|
+
// pruning without scanning every tag in the namespace.
|
|
104
|
+
var oldTagsRv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
105
|
+
var oldTags = (oldTagsRv || []).map(_toStr).filter(Boolean);
|
|
106
|
+
for (var ot = 0; ot < oldTags.length; ot++) {
|
|
107
|
+
try { await client.command("SREM", _tagKey(oldTags[ot]), key); }
|
|
108
|
+
catch (_e) { /* best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
if (oldTags.length > 0) {
|
|
111
|
+
try { await client.command("DEL", _keyTagsKey(key)); }
|
|
112
|
+
catch (_e) { /* best-effort */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Write the value with PEXPIREAT for finite TTLs; un-expiring SET
|
|
116
|
+
// for Infinity (operator opted for "cache forever until evicted").
|
|
117
|
+
if (typeof expiresAt === "number" && isFinite(expiresAt)) {
|
|
118
|
+
await client.command("SET", _key(key), json, "PXAT", String(Math.floor(expiresAt)));
|
|
119
|
+
} else {
|
|
120
|
+
await client.command("SET", _key(key), json);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Wire the new tags. SADD is idempotent on duplicate tag names.
|
|
124
|
+
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
125
|
+
if (tags && tags.length > 0) {
|
|
126
|
+
for (var t = 0; t < tags.length; t++) {
|
|
127
|
+
await client.command("SADD", _tagKey(tags[t]), key);
|
|
128
|
+
await client.command("SADD", _keyTagsKey(key), tags[t]);
|
|
129
|
+
}
|
|
130
|
+
// Match the entry's lifetime on the keyTags reverse-set so
|
|
131
|
+
// membership doesn't outlive the value.
|
|
132
|
+
if (typeof expiresAt === "number" && isFinite(expiresAt)) {
|
|
133
|
+
try { await client.command("PEXPIREAT", _keyTagsKey(key), String(Math.floor(expiresAt))); }
|
|
134
|
+
catch (_e) { /* best-effort */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
emitObs("cache.redis.set", { namespace: namespace });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function del(key) {
|
|
141
|
+
await _ensureConnected();
|
|
142
|
+
var oldTagsRv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
143
|
+
var oldTags = (oldTagsRv || []).map(_toStr).filter(Boolean);
|
|
144
|
+
for (var i = 0; i < oldTags.length; i++) {
|
|
145
|
+
try { await client.command("SREM", _tagKey(oldTags[i]), key); }
|
|
146
|
+
catch (_e) { /* best-effort */ }
|
|
147
|
+
}
|
|
148
|
+
var dels = await Promise.all([
|
|
149
|
+
client.command("DEL", _key(key)),
|
|
150
|
+
client.command("DEL", _keyTagsKey(key)),
|
|
151
|
+
]);
|
|
152
|
+
return Number(dels[0]) === 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function has(key) {
|
|
156
|
+
await _ensureConnected();
|
|
157
|
+
var rv = await client.command("EXISTS", _key(key));
|
|
158
|
+
return Number(rv) === 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function clear() {
|
|
162
|
+
await _ensureConnected();
|
|
163
|
+
// SCAN-and-DEL every key under this namespace. Using SCAN avoids
|
|
164
|
+
// KEYS' O(N) blocking pass and lets a busy Redis serve other
|
|
165
|
+
// commands between cursor batches.
|
|
166
|
+
var cursor = "0";
|
|
167
|
+
do {
|
|
168
|
+
var rv = await client.command("SCAN", cursor, "MATCH", namespace + ":*", "COUNT", "200");
|
|
169
|
+
cursor = _toStr(rv[0]) || "0";
|
|
170
|
+
var keys = (rv[1] || []).map(_toStr).filter(Boolean);
|
|
171
|
+
if (keys.length > 0) {
|
|
172
|
+
await client.command.apply(client, ["DEL"].concat(keys));
|
|
173
|
+
}
|
|
174
|
+
} while (cursor !== "0");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function size() {
|
|
178
|
+
await _ensureConnected();
|
|
179
|
+
var cursor = "0";
|
|
180
|
+
var n = 0;
|
|
181
|
+
do {
|
|
182
|
+
var rv = await client.command("SCAN", cursor, "MATCH", namespace + ":e:*", "COUNT", "200");
|
|
183
|
+
cursor = _toStr(rv[0]) || "0";
|
|
184
|
+
n += (rv[1] || []).length;
|
|
185
|
+
} while (cursor !== "0");
|
|
186
|
+
return n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function bytes() {
|
|
190
|
+
// Per-entry byte accounting on Redis would mean a MEMORY USAGE call
|
|
191
|
+
// per key, which is operator-expensive and Redis-version-dependent.
|
|
192
|
+
// Operators wanting this use the memory backend or run their own
|
|
193
|
+
// monitoring against MEMORY STATS at the cluster level.
|
|
194
|
+
return Promise.resolve(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function invalidateTag(tag) {
|
|
198
|
+
await _ensureConnected();
|
|
199
|
+
var rv = await client.command("SMEMBERS", _tagKey(tag));
|
|
200
|
+
var keys = (rv || []).map(_toStr).filter(Boolean);
|
|
201
|
+
var dropped = 0;
|
|
202
|
+
for (var i = 0; i < keys.length; i++) {
|
|
203
|
+
// Filter out stale tag-membership entries (key already expired
|
|
204
|
+
// from PEXPIREAT but lingered in the tag SET) by checking
|
|
205
|
+
// EXISTS before del. Saves an unnecessary DEL round-trip on
|
|
206
|
+
// ghost keys.
|
|
207
|
+
var existsRv = await client.command("EXISTS", _key(keys[i]));
|
|
208
|
+
if (Number(existsRv) === 1) {
|
|
209
|
+
await del(keys[i]);
|
|
210
|
+
dropped += 1;
|
|
211
|
+
} else {
|
|
212
|
+
// Ghost — just SREM from this tag set + drop the reverse.
|
|
213
|
+
try { await client.command("SREM", _tagKey(tag), keys[i]); }
|
|
214
|
+
catch (e) { log.debug("invalidateTag-cleanup-failed", { op: "SREM", tag: tag, error: e.message }); }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Drop the tag set itself if it's now empty (or empty after the
|
|
218
|
+
// ghost cleanup above).
|
|
219
|
+
var remaining = await client.command("SCARD", _tagKey(tag));
|
|
220
|
+
if (Number(remaining) === 0) {
|
|
221
|
+
try { await client.command("DEL", _tagKey(tag)); }
|
|
222
|
+
catch (e) { log.debug("invalidateTag-cleanup-failed", { op: "DEL", tag: tag, error: e.message }); }
|
|
223
|
+
}
|
|
224
|
+
emitObs("cache.redis.invalidateTag", { namespace: namespace, tag: tag, dropped: dropped });
|
|
225
|
+
return dropped;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function getTags(key) {
|
|
229
|
+
await _ensureConnected();
|
|
230
|
+
var rv = await client.command("SMEMBERS", _keyTagsKey(key));
|
|
231
|
+
return (rv || []).map(_toStr).filter(Boolean);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function close() {
|
|
235
|
+
try { await client.close(); }
|
|
236
|
+
catch (_e) { /* best-effort */ }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
name: "redis",
|
|
241
|
+
get: get,
|
|
242
|
+
set: set,
|
|
243
|
+
del: del,
|
|
244
|
+
has: has,
|
|
245
|
+
clear: clear,
|
|
246
|
+
size: size,
|
|
247
|
+
bytes: bytes,
|
|
248
|
+
invalidateTag: invalidateTag,
|
|
249
|
+
getTags: getTags,
|
|
250
|
+
close: close,
|
|
251
|
+
// Redis enforces TTL itself; the framework's sweeper is a no-op here.
|
|
252
|
+
_startSweep: function () {},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { create: create };
|