@blamejs/blamejs-shop 0.2.15 → 0.2.18
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 +5 -1
- package/README.md +2 -2
- package/lib/admin.js +81 -3
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- package/package.json +1 -1
|
@@ -53,6 +53,34 @@ services:
|
|
|
53
53
|
ports: !override []
|
|
54
54
|
expose:
|
|
55
55
|
- "3008"
|
|
56
|
+
# Encrypted-at-rest mode (WIKI_DB_AT_REST=encrypted) keeps the live
|
|
57
|
+
# SQLite working copy in a tmpfs (BLAMEJS_TMPDIR=/dev/shm below);
|
|
58
|
+
# plaintext only ever touches RAM. Docker's default /dev/shm is just
|
|
59
|
+
# 64 MiB, which the DB outgrows over time (append-only audit chain,
|
|
60
|
+
# sessions) — and a tmpfs overflow truncates the working copy mid-
|
|
61
|
+
# write, surfacing as "database disk image is malformed" on the next
|
|
62
|
+
# boot. Give it real headroom.
|
|
63
|
+
shm_size: '512m'
|
|
64
|
+
# Persist the encrypted-at-rest copy (db.enc) + sealed keys so they
|
|
65
|
+
# survive `docker rm`, host reboot, and image redeploys, and give a
|
|
66
|
+
# restore point. Without a volume, /data lives in the container's
|
|
67
|
+
# writable layer and is destroyed on every recreate — a corrupt boot
|
|
68
|
+
# then has nothing to roll back to.
|
|
69
|
+
volumes:
|
|
70
|
+
- wiki-data:/data
|
|
71
|
+
# Stop grace must exceed the app's 30s shutdown budget (graceMs) plus
|
|
72
|
+
# the forced-exit watchdog margin, so a `docker stop` / rolling redeploy
|
|
73
|
+
# lets the DB re-encrypt phase finish before SIGKILL — otherwise the
|
|
74
|
+
# final flush is lost and the next boot rolls back to the prior db.enc
|
|
75
|
+
# snapshot. (Inherited from the base compose; restated here because the
|
|
76
|
+
# data-loss risk is highest in encrypted mode.)
|
|
77
|
+
stop_grace_period: 40s
|
|
78
|
+
# NOTE for PaaS platforms (Coolify, Dokku, CapRover, …) that
|
|
79
|
+
# regenerate this compose file on deploy: set the above via the platform
|
|
80
|
+
# UI instead of hand-editing here — a "Persistent Storage" mount for
|
|
81
|
+
# /data, a custom docker run option `--shm-size 512m`, and a stop grace
|
|
82
|
+
# period of at least 40s. Edits to this file are otherwise overwritten
|
|
83
|
+
# on the next deploy.
|
|
56
84
|
# Full operator-facing environment surface. Every value is
|
|
57
85
|
# overridable from the .env file; defaults match the framework's
|
|
58
86
|
# built-in defaults so an operator who just wants the basics can
|
|
@@ -227,5 +255,6 @@ services:
|
|
|
227
255
|
condition: service_healthy
|
|
228
256
|
|
|
229
257
|
volumes:
|
|
258
|
+
wiki-data: # encrypted-at-rest DB (db.enc) + sealed keys — persistent, survives recreate/redeploy
|
|
230
259
|
caddy-data:
|
|
231
260
|
caddy-config:
|
|
@@ -17,6 +17,13 @@ services:
|
|
|
17
17
|
image: blamejs-wiki:0.11.45
|
|
18
18
|
container_name: blamejs-wiki
|
|
19
19
|
restart: unless-stopped
|
|
20
|
+
# Graceful-shutdown budget. The app's shutdown orchestrator runs its
|
|
21
|
+
# phases within a 30s grace (graceMs) — drain in-flight, close the HTTP
|
|
22
|
+
# server, then the DB phase that WAL-checkpoints and (in encrypted mode)
|
|
23
|
+
# re-encrypts to db.enc. Docker's default 10s stop grace SIGKILLs before
|
|
24
|
+
# the DB phase gets to run, losing the final flush. Allow more than
|
|
25
|
+
# graceMs plus the forced-exit watchdog margin.
|
|
26
|
+
stop_grace_period: 40s
|
|
20
27
|
ports:
|
|
21
28
|
- "3008:3008"
|
|
22
29
|
environment:
|
|
@@ -68,6 +68,16 @@ var bCrypto = require("./crypto");
|
|
|
68
68
|
var safeJson = require("./safe-json");
|
|
69
69
|
var guardIdempotencyKey = require("./guard-idempotency-key");
|
|
70
70
|
var agentAudit = require("./agent-audit");
|
|
71
|
+
var { boundedMap } = require("./bounded-map");
|
|
72
|
+
|
|
73
|
+
// The default in-memory backend is keyed on (method, actorId, keyHash) —
|
|
74
|
+
// the key hash comes from request-supplied idempotency keys, so a flood of
|
|
75
|
+
// distinct keys would grow the Map without bound (gc only reclaims EXPIRED
|
|
76
|
+
// rows, and only if the operator wires a scheduler to call it). Cap it.
|
|
77
|
+
// Evict-oldest degrades gracefully under flood: the worst case is a dropped
|
|
78
|
+
// dedup record, so a retry of that one key re-executes — never an OOM.
|
|
79
|
+
// Operators who need a hard guarantee at scale supply a durable `opts.store`.
|
|
80
|
+
var DEFAULT_IN_MEMORY_MAX_ENTRIES = 100000;
|
|
71
81
|
|
|
72
82
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
73
83
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
@@ -130,7 +140,7 @@ function _ensureSealTable() {
|
|
|
130
140
|
*/
|
|
131
141
|
function create(opts) {
|
|
132
142
|
opts = opts || {};
|
|
133
|
-
var store = opts.store || _inMemoryBackend();
|
|
143
|
+
var store = opts.store || _inMemoryBackend(opts.maxInMemoryEntries);
|
|
134
144
|
if (typeof store.get !== "function" || typeof store.put !== "function" ||
|
|
135
145
|
typeof store.delete !== "function") {
|
|
136
146
|
throw new AgentIdempotencyError("agent-idempotency/bad-store",
|
|
@@ -470,8 +480,10 @@ function _checkArgs(method, actorId, key) {
|
|
|
470
480
|
// key is validated separately via guardIdempotencyKey.validate.
|
|
471
481
|
}
|
|
472
482
|
|
|
473
|
-
function _inMemoryBackend() {
|
|
474
|
-
|
|
483
|
+
function _inMemoryBackend(maxEntries) {
|
|
484
|
+
// boundedMap validates maxEntries (throws bounded-map/bad-max-entries on a
|
|
485
|
+
// non-positive-int); undefined falls back to the default ceiling.
|
|
486
|
+
var map = boundedMap({ maxEntries: maxEntries || DEFAULT_IN_MEMORY_MAX_ENTRIES, policy: "evict-oldest" });
|
|
475
487
|
function _k(method, actorId, hash) { return method + "\0" + actorId + "\0" + hash; }
|
|
476
488
|
return {
|
|
477
489
|
get: function (method, actorId, hash) {
|
|
@@ -64,6 +64,7 @@ var cluster = lazyRequire(function () { return require("./cluster"); }
|
|
|
64
64
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
65
65
|
var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
|
|
66
66
|
var safeJson = require("./safe-json");
|
|
67
|
+
var agentTenant = lazyRequire(function () { return require("./agent-tenant"); });
|
|
67
68
|
|
|
68
69
|
var AgentOrchestratorError = defineClass("AgentOrchestratorError", { alwaysPermanent: true });
|
|
69
70
|
|
|
@@ -168,6 +169,12 @@ function create(opts) {
|
|
|
168
169
|
cluster: clusterImpl,
|
|
169
170
|
audit: auditImpl,
|
|
170
171
|
permissions: permissions,
|
|
172
|
+
// When true, registry reads (list / lookup) are scoped to the actor's
|
|
173
|
+
// tenant — an actor only sees / resolves agents in its own tenant
|
|
174
|
+
// unless it holds the cross-tenant-admin scope. Mirrors the tenant
|
|
175
|
+
// scoping agent-event-bus enforces on subscribe / delivery. Off by
|
|
176
|
+
// default (single-tenant deployments are unaffected).
|
|
177
|
+
tenantScope: opts.tenantScope === true,
|
|
171
178
|
spawnedConsumers: [],
|
|
172
179
|
streams: new Map(),
|
|
173
180
|
elections: new Map(),
|
|
@@ -358,6 +365,19 @@ async function _unregister(ctx, name, args) {
|
|
|
358
365
|
async function _lookup(ctx, name, args) {
|
|
359
366
|
guardAgentRegistry.validate({ kind: "lookup", name: name }, {});
|
|
360
367
|
_checkPermission(ctx, args.actor, "agent-registry:read");
|
|
368
|
+
// Tenant-scope gate: the row's declared tenant gates access even to a
|
|
369
|
+
// live in-process ref, so an actor can't acquire a handle to another
|
|
370
|
+
// tenant's agent. Consult the backend row (it exists as a metadata
|
|
371
|
+
// declaration even where a live ref is hydrated).
|
|
372
|
+
if (ctx.tenantScope) {
|
|
373
|
+
var sealedRow = await ctx.backend.get(name);
|
|
374
|
+
var declRow = sealedRow ? _unsealRegistryRow(sealedRow) : null;
|
|
375
|
+
if (declRow && !_tenantAllows(ctx, args.actor, declRow.tenantId)) {
|
|
376
|
+
_safeAudit(ctx, "agent.orchestrator.lookup_denied", args.actor,
|
|
377
|
+
{ name: name, reason: "cross-tenant" });
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
361
381
|
// Live agent ref lives in-process; the backend row exists only as
|
|
362
382
|
// a metadata declaration. In multi-process deployments each process
|
|
363
383
|
// hydrates its own liveAgents map by calling register() locally.
|
|
@@ -383,6 +403,10 @@ async function _list(ctx, args) {
|
|
|
383
403
|
return rows.filter(function (r) {
|
|
384
404
|
if (args.kind && r.kind !== args.kind) return false;
|
|
385
405
|
if (args.tenantId && r.tenantId !== args.tenantId) return false;
|
|
406
|
+
// Tenant-scope gate: drop rows the actor's tenant may not see, so
|
|
407
|
+
// enumeration can't disclose other tenants' agents. The args.tenantId
|
|
408
|
+
// above is a caller-supplied FILTER, not an authorization boundary.
|
|
409
|
+
if (!_tenantAllows(ctx, args.actor, r.tenantId)) return false;
|
|
386
410
|
return true;
|
|
387
411
|
}).map(function (r) {
|
|
388
412
|
return {
|
|
@@ -753,6 +777,21 @@ function _checkPermission(ctx, actor, scope) {
|
|
|
753
777
|
}
|
|
754
778
|
}
|
|
755
779
|
|
|
780
|
+
// Tenant-scope gate for registry reads. Returns true when the actor may
|
|
781
|
+
// see / resolve an agent row in `rowTenantId`: scoping disabled, the actor
|
|
782
|
+
// holds the cross-tenant-admin scope, or the actor's tenant matches the
|
|
783
|
+
// row's. Mirrors agent-tenant's CROSS_TENANT_ADMIN_SCOPE check so registry
|
|
784
|
+
// enumeration can't leak agents (or hand out live refs) across tenants.
|
|
785
|
+
function _tenantAllows(ctx, actor, rowTenantId) {
|
|
786
|
+
if (!ctx.tenantScope) return true;
|
|
787
|
+
if (ctx.permissions && actor &&
|
|
788
|
+
ctx.permissions.check(actor, agentTenant().CROSS_TENANT_ADMIN_SCOPE)) {
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
var actorTenant = (actor && actor.tenantId) || null;
|
|
792
|
+
return actorTenant !== null && actorTenant === (rowTenantId || null);
|
|
793
|
+
}
|
|
794
|
+
|
|
756
795
|
function _safeAudit(ctx, action, actor, metadata) {
|
|
757
796
|
agentAudit.safeAudit(ctx.audit, action, actor, metadata);
|
|
758
797
|
}
|
|
@@ -53,6 +53,10 @@ var AppShutdownError = defineClass("AppShutdownError", { alwaysPermanent: true }
|
|
|
53
53
|
var log = boot("app-shutdown");
|
|
54
54
|
|
|
55
55
|
var DEFAULT_GRACE_MS = C.TIME.seconds(30);
|
|
56
|
+
// Headroom between the shutdown grace budget and the hard forced-exit
|
|
57
|
+
// watchdog, so the watchdog fires before a container supervisor's own
|
|
58
|
+
// stop-grace SIGKILL (set stop_grace_period > graceMs + this margin).
|
|
59
|
+
var FORCE_EXIT_MARGIN_MS = C.TIME.seconds(5);
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
62
|
* @primitive b.appShutdown.create
|
|
@@ -71,6 +75,7 @@ var DEFAULT_GRACE_MS = C.TIME.seconds(30);
|
|
|
71
75
|
*
|
|
72
76
|
* @opts
|
|
73
77
|
* graceMs: number, // total budget across all phases (default 30000)
|
|
78
|
+
* forceExitMarginMs: number, // headroom after graceMs before the signal-handler watchdog forces exit (default 5000); set the container stop grace above graceMs + this
|
|
74
79
|
* phases: array, // [{ name, run: async fn, timeoutMs? }]
|
|
75
80
|
* installSignalHandlers: boolean, // wire SIGTERM/SIGINT (default false)
|
|
76
81
|
* signals: array, // signal names (default ["SIGTERM","SIGINT"])
|
|
@@ -94,6 +99,9 @@ function create(opts) {
|
|
|
94
99
|
numericBounds.requirePositiveFiniteIntIfPresent(opts.graceMs,
|
|
95
100
|
"app-shutdown.create: opts.graceMs", AppShutdownError, "app-shutdown/bad-grace-ms");
|
|
96
101
|
var graceMs = opts.graceMs !== undefined ? opts.graceMs : DEFAULT_GRACE_MS;
|
|
102
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.forceExitMarginMs,
|
|
103
|
+
"app-shutdown.create: opts.forceExitMarginMs", AppShutdownError, "app-shutdown/bad-force-exit-margin-ms");
|
|
104
|
+
var forceExitMarginMs = opts.forceExitMarginMs !== undefined ? opts.forceExitMarginMs : FORCE_EXIT_MARGIN_MS;
|
|
97
105
|
var phases = Array.isArray(opts.phases) ? opts.phases.slice() : [];
|
|
98
106
|
var installSignalHandlers = !!opts.installSignalHandlers;
|
|
99
107
|
for (var i = 0; i < phases.length; i++) {
|
|
@@ -224,6 +232,30 @@ function create(opts) {
|
|
|
224
232
|
function _signalCallback(sig) {
|
|
225
233
|
return function () {
|
|
226
234
|
log("received " + sig + " — initiating graceful shutdown");
|
|
235
|
+
// Hard-deadline safety net. Per-phase budgets use a SOFT timeout
|
|
236
|
+
// (withTimeout lets the underlying work keep running on expiry), so
|
|
237
|
+
// shutdown() RESOLVING does not guarantee the process EXITS: a hung
|
|
238
|
+
// phase's leaked handle (a socket that won't close, a timer that keeps
|
|
239
|
+
// firing) can hold the event loop alive past the grace window, after
|
|
240
|
+
// which the supervisor SIGKILLs us and the final DB re-encrypt is lost.
|
|
241
|
+
// Arm an unref'd watchdog that forces a clean exit at the deadline.
|
|
242
|
+
// It is deliberately NOT cleared when shutdown() resolves — the whole
|
|
243
|
+
// point is to catch the case where the orchestration finished but the
|
|
244
|
+
// process won't die. unref() so it never itself keeps us alive: a clean
|
|
245
|
+
// shutdown with no leaked handles exits naturally well before it fires.
|
|
246
|
+
// process.exit() runs the registered exit handlers (db re-encrypts
|
|
247
|
+
// there), so the last flush still happens.
|
|
248
|
+
var watchdog = setTimeout(function () {
|
|
249
|
+
log.error("shutdown exceeded " + (graceMs + forceExitMarginMs) +
|
|
250
|
+
"ms without the process exiting — forcing exit (exit handlers run " +
|
|
251
|
+
"the final DB flush) before the supervisor SIGKILLs");
|
|
252
|
+
// Bounded forced exit after the grace deadline, armed ONLY inside the
|
|
253
|
+
// signal handler (operator opted into installSignalHandlers,
|
|
254
|
+
// delegating process lifecycle to the orchestrator).
|
|
255
|
+
// allow:process-exit — operator-delegated lifecycle, watchdog only
|
|
256
|
+
process.exit(process.exitCode || 1);
|
|
257
|
+
}, graceMs + forceExitMarginMs);
|
|
258
|
+
if (typeof watchdog.unref === "function") watchdog.unref();
|
|
227
259
|
shutdown().then(function (result) {
|
|
228
260
|
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
229
261
|
process.exitCode = result.ok ? 0 : 1;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* bounded-map — a Map facade that caps its entry count.
|
|
4
|
+
*
|
|
5
|
+
* Defends the unbounded-in-bounded resource-exhaustion class: an in-memory
|
|
6
|
+
* store keyed on request-derived input (a locale, a hostname, an
|
|
7
|
+
* idempotency key, a replay nonce) grows without limit between sweeps
|
|
8
|
+
* unless something enforces a ceiling. A periodic TTL sweep alone does not
|
|
9
|
+
* bound peak memory — a flood of unique keys arrives faster than the sweep
|
|
10
|
+
* interval. This adds the missing ceiling.
|
|
11
|
+
*
|
|
12
|
+
* Two policies for what happens on `set` when already at `maxEntries`:
|
|
13
|
+
*
|
|
14
|
+
* "evict-oldest" (default) — drop the oldest entry (insertion order)
|
|
15
|
+
* to make room, then store the new one. For caches whose entries are
|
|
16
|
+
* re-derivable on demand (Intl formatters, DNS results, idempotency
|
|
17
|
+
* records) eviction is cheap — the worst case is a recomputed value or
|
|
18
|
+
* a missed dedup under active flood, never a correctness or security
|
|
19
|
+
* hole. `set` always stores and returns true.
|
|
20
|
+
*
|
|
21
|
+
* "reject" — refuse the new entry (do NOT evict a live one) and return
|
|
22
|
+
* false. For stores where evicting an unexpired entry would be unsafe:
|
|
23
|
+
* a replay-protection nonce store must not drop a live nonce to admit a
|
|
24
|
+
* new one, because that reopens a replay window for the dropped nonce.
|
|
25
|
+
* The caller fails closed on a false return (treats the request as
|
|
26
|
+
* un-recordable → reject it). Callers should purge expired entries
|
|
27
|
+
* before relying on this so the ceiling is hit only under genuine flood.
|
|
28
|
+
*
|
|
29
|
+
* This is deliberately NOT `b.cache` — that is an operator-facing primitive
|
|
30
|
+
* with TTL, LRU-touch, observability, and pluggable backends. This is the
|
|
31
|
+
* minimal internal ceiling the framework's own request-keyed Maps need, and
|
|
32
|
+
* leaves TTL/expiry semantics to the caller (which already owns them).
|
|
33
|
+
*
|
|
34
|
+
* `onEvict(key, value)` (optional) fires when an entry is dropped to make
|
|
35
|
+
* room under "evict-oldest" — for an observability counter, say. It never
|
|
36
|
+
* fires under "reject" (nothing is evicted; the new entry is dropped).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var numericBounds = require("./numeric-bounds");
|
|
40
|
+
var { defineClass } = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var BoundedMapError = defineClass("BoundedMapError");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
* @param {number} opts.maxEntries - hard ceiling; throws if not a positive finite int
|
|
47
|
+
* @param {string} [opts.policy] - "evict-oldest" (default) | "reject"
|
|
48
|
+
* @param {function} [opts.onEvict] - (key, value) called on eviction under "evict-oldest"
|
|
49
|
+
* @returns Map-like facade: get/has/set/delete/clear, size getter, keys/values/entries/forEach, [Symbol.iterator]
|
|
50
|
+
*/
|
|
51
|
+
function boundedMap(opts) {
|
|
52
|
+
opts = opts || {};
|
|
53
|
+
if (!numericBounds.isPositiveFiniteInt(opts.maxEntries)) {
|
|
54
|
+
throw new BoundedMapError("bounded-map/bad-max-entries",
|
|
55
|
+
"boundedMap: opts.maxEntries must be a positive finite integer, got " + JSON.stringify(opts.maxEntries));
|
|
56
|
+
}
|
|
57
|
+
var maxEntries = opts.maxEntries;
|
|
58
|
+
var policy = opts.policy || "evict-oldest";
|
|
59
|
+
if (policy !== "evict-oldest" && policy !== "reject") {
|
|
60
|
+
throw new BoundedMapError("bounded-map/bad-policy",
|
|
61
|
+
"boundedMap: opts.policy must be 'evict-oldest' | 'reject', got " + JSON.stringify(policy));
|
|
62
|
+
}
|
|
63
|
+
var onEvict = typeof opts.onEvict === "function" ? opts.onEvict : null;
|
|
64
|
+
var inner = new Map();
|
|
65
|
+
|
|
66
|
+
function set(key, value) {
|
|
67
|
+
// Updating an existing key never grows the map — always allowed.
|
|
68
|
+
if (inner.has(key)) { inner.set(key, value); return true; }
|
|
69
|
+
if (inner.size >= maxEntries) {
|
|
70
|
+
if (policy === "reject") return false;
|
|
71
|
+
// evict-oldest: the first key in insertion order is the oldest.
|
|
72
|
+
var oldest = inner.keys().next().value;
|
|
73
|
+
if (oldest !== undefined || inner.has(oldest)) {
|
|
74
|
+
var evictedVal = inner.get(oldest);
|
|
75
|
+
inner.delete(oldest);
|
|
76
|
+
if (onEvict) { try { onEvict(oldest, evictedVal); } catch (_e) { /* obs hook — drop-silent */ } }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
inner.set(key, value);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
get: function (k) { return inner.get(k); },
|
|
85
|
+
has: function (k) { return inner.has(k); },
|
|
86
|
+
set: set,
|
|
87
|
+
delete: function (k) { return inner.delete(k); },
|
|
88
|
+
clear: function () { inner.clear(); },
|
|
89
|
+
keys: function () { return inner.keys(); },
|
|
90
|
+
values: function () { return inner.values(); },
|
|
91
|
+
entries: function () { return inner.entries(); },
|
|
92
|
+
forEach: function (fn, thisArg) { return inner.forEach(fn, thisArg); },
|
|
93
|
+
get size() { return inner.size; },
|
|
94
|
+
get maxEntries() { return maxEntries; },
|
|
95
|
+
get policy() { return policy; },
|
|
96
|
+
// Iterable like a Map, so `for (var e of bmap)` yields [key, value]
|
|
97
|
+
// entries — callers that iterate a plain Map keep working unchanged.
|
|
98
|
+
[Symbol.iterator]: function () { return inner[Symbol.iterator](); },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { boundedMap: boundedMap, BoundedMapError: BoundedMapError };
|
|
@@ -331,6 +331,25 @@ function _memoryBackend(cfg) {
|
|
|
331
331
|
return true;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
// Atomic read-modify-write. Single-process V8 is single-threaded and the
|
|
335
|
+
// read + mutatorFn decision run with no `await` before the write, so no
|
|
336
|
+
// concurrent task can interleave between reading the current value and
|
|
337
|
+
// committing the new one. Same contract as the cluster backend's update.
|
|
338
|
+
async function _updateEntry(key, mutatorFn, expiresAt, meta) {
|
|
339
|
+
var now = clock();
|
|
340
|
+
var entry = entries.get(key);
|
|
341
|
+
var current = (entry && !_isExpired(entry, now)) ? entry.value : null;
|
|
342
|
+
var decision = mutatorFn(current);
|
|
343
|
+
if (decision && decision.abort !== undefined) return { aborted: decision.abort };
|
|
344
|
+
if (decision && decision.delete === true) {
|
|
345
|
+
if (entry) { _untrack(key, entry); entries.delete(key); }
|
|
346
|
+
return { updated: true, deleted: true };
|
|
347
|
+
}
|
|
348
|
+
var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
|
|
349
|
+
await set(key, decision.value, effExpires, meta);
|
|
350
|
+
return { updated: true, value: decision.value };
|
|
351
|
+
}
|
|
352
|
+
|
|
334
353
|
async function has(key) {
|
|
335
354
|
var entry = entries.get(key);
|
|
336
355
|
if (!entry) return false;
|
|
@@ -420,6 +439,7 @@ function _memoryBackend(cfg) {
|
|
|
420
439
|
name: "memory",
|
|
421
440
|
get: get,
|
|
422
441
|
set: set,
|
|
442
|
+
update: _updateEntry,
|
|
423
443
|
del: del,
|
|
424
444
|
has: has,
|
|
425
445
|
clear: clear,
|
|
@@ -511,32 +531,109 @@ function _clusterBackend(cfg) {
|
|
|
511
531
|
var storedExpires = (expiresAt === Infinity) ? Number.MAX_SAFE_INTEGER : expiresAt;
|
|
512
532
|
var now = clock();
|
|
513
533
|
var ck = _composedKey(key);
|
|
514
|
-
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
515
|
-
await clusterStorage.execute(
|
|
516
|
-
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
517
|
-
"VALUES (?, ?, ?, ?) " +
|
|
518
|
-
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
519
|
-
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
520
|
-
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
521
|
-
);
|
|
522
|
-
// Tag handling: drop any prior tags for this key (tags can change
|
|
523
|
-
// across sets), then INSERT the new ones. The PRIMARY KEY on
|
|
524
|
-
// (cacheKey, tag) makes the INSERT idempotent if duplicate tags
|
|
525
|
-
// sneak in.
|
|
526
534
|
var tags = meta && Array.isArray(meta.tags) ? meta.tags : null;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
535
|
+
// The value UPSERT and the tag-index rewrite (DELETE prior tags, then
|
|
536
|
+
// INSERT the new set) must commit as ONE unit. Done as separate
|
|
537
|
+
// statements they race: two concurrent set()s on the same key can
|
|
538
|
+
// interleave their DELETE/INSERT pairs, leaving a tag index that no
|
|
539
|
+
// longer matches the value row — so a later invalidateTag misses the
|
|
540
|
+
// key (a stale, possibly authorization-bearing, value survives a wipe).
|
|
541
|
+
// Wrapping them in a transaction makes a concurrent set see either the
|
|
542
|
+
// whole prior state or the whole new state, never a mix.
|
|
543
|
+
// SQLite + Postgres both honor ON CONFLICT (cacheKey) DO UPDATE.
|
|
544
|
+
await clusterStorage.transaction(async function (tx) {
|
|
545
|
+
await tx.execute(
|
|
546
|
+
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
547
|
+
"VALUES (?, ?, ?, ?) " +
|
|
548
|
+
"ON CONFLICT (cacheKey) DO UPDATE SET " +
|
|
549
|
+
"valueJson = ?, expiresAt = ?, updatedAt = ?",
|
|
550
|
+
[ck, json, storedExpires, now, json, storedExpires, now]
|
|
551
|
+
);
|
|
552
|
+
// Drop any prior tags for this key (tags can change across sets),
|
|
553
|
+
// then INSERT the new ones. The PRIMARY KEY on (cacheKey, tag) makes
|
|
554
|
+
// the INSERT idempotent if duplicate tags sneak in.
|
|
555
|
+
await tx.execute(
|
|
556
|
+
"DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?",
|
|
557
|
+
[ck]
|
|
558
|
+
);
|
|
559
|
+
if (tags && tags.length > 0) {
|
|
560
|
+
for (var i = 0; i < tags.length; i++) {
|
|
561
|
+
await tx.execute(
|
|
562
|
+
"INSERT INTO _blamejs_cache_tags (cacheKey, tag) VALUES (?, ?) " +
|
|
563
|
+
"ON CONFLICT (cacheKey, tag) DO NOTHING",
|
|
564
|
+
[ck, tags[i]]
|
|
565
|
+
);
|
|
566
|
+
}
|
|
538
567
|
}
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Atomic read-modify-write. Reads the current value, calls mutatorFn,
|
|
572
|
+
// and commits the result in one transaction — with a compare-and-set
|
|
573
|
+
// (UPDATE ... WHERE valueJson = <the exact bytes we read>) so a
|
|
574
|
+
// concurrent writer on another node cannot clobber the change (lost
|
|
575
|
+
// update). On a CAS miss the whole thing retries against the fresh
|
|
576
|
+
// value. Single-node serializes via clusterStorage.transaction, so the
|
|
577
|
+
// CAS never misses there; the retry only fires in cluster mode.
|
|
578
|
+
// mutatorFn(current|null) returns { value } to commit, { abort: data }
|
|
579
|
+
// to leave the row untouched and surface `data`, or { delete: true }.
|
|
580
|
+
async function _updateRow(key, mutatorFn, expiresAt, meta) {
|
|
581
|
+
var ck = _composedKey(key);
|
|
582
|
+
var maxRetries = 5;
|
|
583
|
+
for (var attempt = 0; attempt < maxRetries; attempt++) {
|
|
584
|
+
var outcome = await clusterStorage.transaction(async function (tx) {
|
|
585
|
+
var now = clock();
|
|
586
|
+
var row = await tx.executeOne(
|
|
587
|
+
"SELECT valueJson, expiresAt FROM _blamejs_cache WHERE cacheKey = ?", [ck]);
|
|
588
|
+
var oldRaw = null;
|
|
589
|
+
var current = null;
|
|
590
|
+
if (row && row.expiresAt > now) {
|
|
591
|
+
oldRaw = row.valueJson;
|
|
592
|
+
var stored = row.valueJson;
|
|
593
|
+
if (typeof stored === "string" && stored.indexOf(CACHE_SEAL_PREFIX) === 0) {
|
|
594
|
+
stored = vault().unseal(stored.substring(CACHE_SEAL_PREFIX.length));
|
|
595
|
+
}
|
|
596
|
+
current = safeJson.parse(stored, { maxBytes: C.BYTES.mib(64) });
|
|
597
|
+
}
|
|
598
|
+
var decision = mutatorFn(current);
|
|
599
|
+
if (decision && decision.abort !== undefined) return { aborted: decision.abort };
|
|
600
|
+
if (decision && decision.delete === true) {
|
|
601
|
+
if (oldRaw !== null) {
|
|
602
|
+
await tx.execute("DELETE FROM _blamejs_cache WHERE cacheKey = ? AND valueJson = ?", [ck, oldRaw]);
|
|
603
|
+
await tx.execute("DELETE FROM _blamejs_cache_tags WHERE cacheKey = ?", [ck]);
|
|
604
|
+
}
|
|
605
|
+
return { updated: true, deleted: true };
|
|
606
|
+
}
|
|
607
|
+
var json = safeJson.stringify(decision.value);
|
|
608
|
+
if (meta && meta.seal === true) json = CACHE_SEAL_PREFIX + vault().seal(json);
|
|
609
|
+
// The mutator may pin the entry's expiry to the value's own
|
|
610
|
+
// lifetime (e.g. a grant whose expiresAt the mutator just read);
|
|
611
|
+
// otherwise the caller-resolved ttl applies.
|
|
612
|
+
var effExpires = (decision.expiresAt !== undefined) ? decision.expiresAt : expiresAt;
|
|
613
|
+
var storedExpires = (effExpires === Infinity) ? Number.MAX_SAFE_INTEGER : effExpires;
|
|
614
|
+
if (oldRaw === null) {
|
|
615
|
+
// Row was absent/expired — insert, but lose the race if another
|
|
616
|
+
// writer inserted concurrently (ON CONFLICT DO NOTHING → 0 rows).
|
|
617
|
+
var ins = await tx.execute(
|
|
618
|
+
"INSERT INTO _blamejs_cache (cacheKey, valueJson, expiresAt, updatedAt) " +
|
|
619
|
+
"VALUES (?, ?, ?, ?) ON CONFLICT (cacheKey) DO NOTHING",
|
|
620
|
+
[ck, json, storedExpires, now]);
|
|
621
|
+
if (!ins || ins.rowCount !== 1) return { conflict: true };
|
|
622
|
+
} else {
|
|
623
|
+
// CAS: only commit if the row still holds the exact bytes we read.
|
|
624
|
+
var upd = await tx.execute(
|
|
625
|
+
"UPDATE _blamejs_cache SET valueJson = ?, expiresAt = ?, updatedAt = ? " +
|
|
626
|
+
"WHERE cacheKey = ? AND valueJson = ?",
|
|
627
|
+
[json, storedExpires, now, ck, oldRaw]);
|
|
628
|
+
if (!upd || upd.rowCount !== 1) return { conflict: true };
|
|
629
|
+
}
|
|
630
|
+
return { updated: true, value: decision.value };
|
|
631
|
+
});
|
|
632
|
+
if (outcome && outcome.conflict) continue; // value moved under us — retry
|
|
633
|
+
return outcome;
|
|
539
634
|
}
|
|
635
|
+
throw _err("UPDATE_CONTENTION",
|
|
636
|
+
"cache.update: exceeded " + maxRetries + " retries under write contention for key");
|
|
540
637
|
}
|
|
541
638
|
|
|
542
639
|
async function del(key) {
|
|
@@ -672,6 +769,7 @@ function _clusterBackend(cfg) {
|
|
|
672
769
|
name: "cluster",
|
|
673
770
|
get: get,
|
|
674
771
|
set: set,
|
|
772
|
+
update: _updateRow,
|
|
675
773
|
del: del,
|
|
676
774
|
has: has,
|
|
677
775
|
clear: clear,
|
|
@@ -1014,6 +1112,68 @@ function create(opts) {
|
|
|
1014
1112
|
emitObs("cache.set", { namespace: namespace });
|
|
1015
1113
|
}
|
|
1016
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* @primitive b.cache.update
|
|
1117
|
+
* @signature b.cache.update(key, mutatorFn, opts?)
|
|
1118
|
+
* @since 0.13.39
|
|
1119
|
+
* @status stable
|
|
1120
|
+
* @related b.cache.create
|
|
1121
|
+
*
|
|
1122
|
+
* Atomic read-modify-write. Reads the current value, calls
|
|
1123
|
+
* `mutatorFn(current | null)`, and commits the result in one operation
|
|
1124
|
+
* so a concurrent writer cannot clobber the change (lost update) — the
|
|
1125
|
+
* race that makes a plain `get` → mutate → `set` unsafe for counters,
|
|
1126
|
+
* sets, and quorum state. The memory backend is atomic by single-thread;
|
|
1127
|
+
* the cluster backend uses a transaction with compare-and-set + retry.
|
|
1128
|
+
*
|
|
1129
|
+
* `mutatorFn` returns one of: `{ value }` to commit the new value,
|
|
1130
|
+
* `{ abort: data }` to leave the entry untouched and surface `data` to
|
|
1131
|
+
* the caller, or `{ delete: true }` to remove the entry. The call
|
|
1132
|
+
* resolves to `{ updated: true, value }`, `{ updated: true, deleted: true }`,
|
|
1133
|
+
* or `{ aborted: data }`.
|
|
1134
|
+
*
|
|
1135
|
+
* @opts
|
|
1136
|
+
* ttlMs: number | Infinity, // lifetime of the written value; default the instance ttlMs
|
|
1137
|
+
* seal: boolean, // cluster backend only — seal the value at rest
|
|
1138
|
+
*
|
|
1139
|
+
* @example
|
|
1140
|
+
* await counters.update("hits", function (n) {
|
|
1141
|
+
* return { value: (n || 0) + 1 };
|
|
1142
|
+
* });
|
|
1143
|
+
*/
|
|
1144
|
+
async function update(key, mutatorFn, callerOpts) {
|
|
1145
|
+
_ensureOpen("update");
|
|
1146
|
+
_validateKey(key, "cache.update");
|
|
1147
|
+
if (typeof mutatorFn !== "function") {
|
|
1148
|
+
throw _err("BAD_OPT", "cache.update: mutatorFn must be a function, got " + typeof mutatorFn);
|
|
1149
|
+
}
|
|
1150
|
+
if (typeof backend.update !== "function") {
|
|
1151
|
+
throw _err("UNSUPPORTED",
|
|
1152
|
+
"cache.update is unsupported by the '" + (backend.name || "custom") + "' backend " +
|
|
1153
|
+
"(memory + cluster implement it; a custom backend must provide update for atomic RMW).");
|
|
1154
|
+
}
|
|
1155
|
+
var ttlMs = _resolveTtl(callerOpts, "update");
|
|
1156
|
+
var expiresAt = (ttlMs === Infinity) ? Infinity : (clock() + ttlMs);
|
|
1157
|
+
var seal = !!(callerOpts && callerOpts.seal === true);
|
|
1158
|
+
if (seal && backend.name !== "cluster") {
|
|
1159
|
+
throw _err("BAD_OPT",
|
|
1160
|
+
"cache.update: seal: true is only supported on the cluster backend " +
|
|
1161
|
+
"(this cache instance uses '" + (backend.name || "custom") + "').");
|
|
1162
|
+
}
|
|
1163
|
+
var result;
|
|
1164
|
+
try { result = await backend.update(key, mutatorFn, expiresAt, { ttlMs: ttlMs, seal: seal }); }
|
|
1165
|
+
catch (e) {
|
|
1166
|
+
emitObs("cache.backend.failed", { namespace: namespace, op: "update" });
|
|
1167
|
+
_backendFailedAudit("update", e);
|
|
1168
|
+
throw e;
|
|
1169
|
+
}
|
|
1170
|
+
if (result && (result.updated || result.deleted)) {
|
|
1171
|
+
emitObs("cache.update", { namespace: namespace });
|
|
1172
|
+
if (result.deleted) { softExpiry.delete(key); _publishInvalidation({ kind: "del", key: key }); }
|
|
1173
|
+
}
|
|
1174
|
+
return result;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1017
1177
|
async function del(key) {
|
|
1018
1178
|
_ensureOpen("del");
|
|
1019
1179
|
_validateKey(key, "cache.del");
|
|
@@ -1308,6 +1468,7 @@ function create(opts) {
|
|
|
1308
1468
|
return {
|
|
1309
1469
|
get: get,
|
|
1310
1470
|
set: set,
|
|
1471
|
+
update: update,
|
|
1311
1472
|
del: del,
|
|
1312
1473
|
has: has,
|
|
1313
1474
|
clear: clear,
|