@blamejs/blamejs-shop 0.2.14 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -2
  3. package/lib/admin.js +970 -1
  4. package/lib/asset-manifest.json +5 -1
  5. package/lib/customers.js +71 -4
  6. package/lib/storefront.js +462 -0
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  11. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  12. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  13. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  15. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  16. package/lib/vendor/blamejs/lib/cache.js +184 -23
  17. package/lib/vendor/blamejs/lib/cert.js +68 -11
  18. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  19. package/lib/vendor/blamejs/lib/db.js +205 -15
  20. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  21. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  22. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  23. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  24. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  25. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  26. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  27. package/lib/vendor/blamejs/lib/subject.js +8 -1
  28. package/lib/vendor/blamejs/package.json +1 -1
  29. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  38. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  39. package/lib/vendor/blamejs/test/20-db.js +191 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  49. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  52. package/package.json +1 -1
@@ -456,12 +456,203 @@ async function testUnsealRowNullsForgedValue() {
456
456
  }
457
457
  }
458
458
 
459
+ async function testEncryptedTmpfsCorruptionAutoRecovers() {
460
+ // Regression: in encrypted-at-rest mode the live SQLite copy lives in a
461
+ // tmpfs working file; a corrupt newer working copy (unclean shutdown, or
462
+ // a full /dev/shm — Docker's 64 MiB default) must NOT be trusted on
463
+ // boot. decryptToTmp's "newer plaintext wins" crash-recovery path used
464
+ // to keep it unconditionally, so db.init failed its integrity gate with
465
+ // "database disk image is malformed" identically on every boot — an
466
+ // unrecoverable crash loop (the blamejs.com wiki looped 4,625 times).
467
+ // The fix: integrity-probe the newer working copy; if bad, discard it
468
+ // and re-decrypt the last-good db.enc. db.enc is never modified.
469
+ //
470
+ // Scratch dir lives under the repo-local .test-output (not os.tmpdir):
471
+ // this test intentionally corrupts its working file in place, which
472
+ // static analysis (CodeQL js/insecure-temporary-file) flags as an
473
+ // insecure OS-temp-dir write. A gitignored per-test dir outside the
474
+ // shared OS temp dir sidesteps that false positive and is cleaner for
475
+ // test isolation besides.
476
+ var scratchBase = path.join(__dirname, "..", ".test-output");
477
+ fs.mkdirSync(scratchBase, { recursive: true });
478
+ var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-tmpfs-corrupt-"));
479
+ var schema = [{ name: "recovery_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
480
+ try {
481
+ await setupTestDb(tmpDir, schema);
482
+ check("recovery test runs in encrypted mode", b.db.getMode() === "encrypted");
483
+ b.db.prepare("INSERT INTO recovery_t (_id, v) VALUES (?, ?)").run("r1", "survives");
484
+ await b.db.flushToDisk(); // persist to db.enc (last-good snapshot)
485
+ b.db._resetForTest(); // close handle; leaves the working file on disk
486
+
487
+ // Locate the tmpfs working copy under our own scratch dir (setupTestDb
488
+ // points the working dir at <tmpDir>/tmpfs). Resolve it from `tmpDir`
489
+ // — our .test-output mkdtemp — rather than b.db.getDbPath(), so the
490
+ // path's non-OS-temp provenance stays visible to static analysis.
491
+ var workingDir = path.join(tmpDir, "tmpfs");
492
+ var workingFile = fs.readdirSync(workingDir).filter(function (f) { return /\.db$/.test(f); })[0];
493
+ var workingPath = path.join(workingDir, workingFile);
494
+
495
+ // Corrupt the existing working copy in place — overwrite the SQLite
496
+ // header so the file is no longer a valid database — and stamp it
497
+ // newer than db.enc (the exact trap shape that produced the crash
498
+ // loop). Open with "r+" (must already exist; never creates a file) so
499
+ // this is an in-place corruption of a known file.
500
+ var corruptFd = fs.openSync(workingPath, "r+");
501
+ try { fs.writeSync(corruptFd, Buffer.from("not a sqlite database -- corrupt header\n".repeat(8)), 0, undefined, 0); }
502
+ finally { fs.closeSync(corruptFd); }
503
+ var future = new Date(Date.now() + 60000);
504
+ fs.utimesSync(workingPath, future, future);
505
+
506
+ // Re-init against the same dataDir. Mirror setupTestDb's pre-init
507
+ // reset (passphrase env + audit reset) so db.init re-wires audit-sign
508
+ // from the same passphrase — but do NOT reset the vault, so the
509
+ // existing db.enc key still decrypts. The corrupt newer working copy
510
+ // must be discarded and db.enc re-decrypted — boot must succeed.
511
+ setTestPassphraseEnv();
512
+ b.audit._resetForTest();
513
+ await b.db.init({ dataDir: tmpDir, tmpDir: path.join(tmpDir, "tmpfs"), schema: schema });
514
+ var row = b.db.prepare("SELECT v FROM recovery_t WHERE _id = ?").get("r1");
515
+ check("corrupt tmpfs working copy auto-recovers from db.enc (no crash loop)",
516
+ row && row.v === "survives");
517
+ } finally {
518
+ try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
519
+ await teardownTestDb(tmpDir);
520
+ }
521
+ }
522
+
523
+ async function testEncryptedCloseKeepsPlaintextWhenEncryptFails() {
524
+ // close()'s final re-encrypt can fail (full /dev/shm, disk-full). When it
525
+ // does, the plaintext working copy is the ONLY carrier of writes since
526
+ // the last periodic flush — close() must NOT unlink it (it used to do so
527
+ // unconditionally), so the next boot's newer-mtime recovery can pick it
528
+ // up. db.enc still holds the prior snapshot, so nothing is lost either
529
+ // way; the bug was discarding the MORE-recent state on an encrypt error.
530
+ var scratchBase = path.join(__dirname, "..", ".test-output");
531
+ fs.mkdirSync(scratchBase, { recursive: true });
532
+ var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-close-encfail-"));
533
+ var schema = [{ name: "close_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
534
+ try {
535
+ await setupTestDb(tmpDir, schema);
536
+ check("close-encfail test runs in encrypted mode", b.db.getMode() === "encrypted");
537
+ b.db.prepare("INSERT INTO close_t (_id, v) VALUES (?, ?)").run("c1", "kept");
538
+ await b.db.flushToDisk(); // db.enc now holds the prior snapshot
539
+
540
+ var workingDir = path.join(tmpDir, "tmpfs");
541
+ var workingFile = fs.readdirSync(workingDir).filter(function (f) { return /\.db$/.test(f); })[0];
542
+ var workingPath = path.join(workingDir, workingFile);
543
+ check("working copy exists before close", fs.existsSync(workingPath));
544
+
545
+ // Force the final encrypt to throw: replace db.enc with a directory so
546
+ // atomicFile's rename-into-place fails (portable across platforms).
547
+ var encPath = path.join(tmpDir, "db.enc");
548
+ fs.rmSync(encPath, { force: true });
549
+ fs.mkdirSync(encPath);
550
+
551
+ // The real close() (the appShutdown db-phase path), not _resetForTest.
552
+ b.db.close();
553
+
554
+ check("encrypt-failed close keeps the plaintext working copy for recovery",
555
+ fs.existsSync(workingPath));
556
+ } finally {
557
+ try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
558
+ await teardownTestDb(tmpDir);
559
+ }
560
+ }
561
+
562
+ async function testTmpfsLowSpaceRefusesWritesFailClear() {
563
+ // When the encrypted-mode tmpfs working copy runs low on free space, the
564
+ // DB must REFUSE growth writes (INSERT/UPDATE/REPLACE) with a clear error
565
+ // instead of proceeding into an ENOSPC corruption. DELETE + reads stay
566
+ // available so retention can reclaim space and the app keeps serving. An
567
+ // injected statfs reader drives the probe deterministically.
568
+ var BYTES = b.constants.BYTES;
569
+ var scratchBase = path.join(__dirname, "..", ".test-output");
570
+ fs.mkdirSync(scratchBase, { recursive: true });
571
+ var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-lowspace-"));
572
+ var schema = [{ name: "space_t", columns: { _id: "TEXT PRIMARY KEY", v: "TEXT" } }];
573
+ var fakeFree = BYTES.mib(100); // start healthy
574
+ var fakeStatfs = function () { return { bavail: fakeFree, bsize: 1 }; };
575
+ try {
576
+ process.env.BLAMEJS_SKIP_NTP_CHECK = "1";
577
+ setTestPassphraseEnv();
578
+ b.cluster._resetForTest();
579
+ b.audit._resetForTest();
580
+ b.vault._resetForTest();
581
+ b.db._resetForTest();
582
+ await b.vault.init({ dataDir: tmpDir });
583
+ await b.db.init({
584
+ dataDir: tmpDir, tmpDir: path.join(tmpDir, "tmpfs"), schema: schema,
585
+ minFreeBytes: BYTES.mib(16), _statfsForTest: fakeStatfs,
586
+ });
587
+ check("low-space test runs in encrypted mode", b.db.getMode() === "encrypted");
588
+
589
+ b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("a", "1");
590
+ check("healthy free space → INSERT succeeds",
591
+ b.db.prepare("SELECT v FROM space_t WHERE _id = ?").get("a").v === "1");
592
+
593
+ // Drop below the threshold and force a probe.
594
+ fakeFree = BYTES.mib(1);
595
+ var st = b.db._probeStorageForTest();
596
+ check("low free space flips writesRefused", st.writesRefused === true);
597
+
598
+ var threw = null;
599
+ try { b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("bb", "2"); }
600
+ catch (e) { threw = e; }
601
+ check("low space → INSERT throws db/storage-low (fail-clear, not corruption)",
602
+ threw && threw.code === "db/storage-low");
603
+
604
+ // DELETE + reads must still work — the retention escape valve + serving.
605
+ var delThrew = null;
606
+ try { b.db.prepare("DELETE FROM space_t WHERE _id = ?").run("a"); } catch (e) { delThrew = e; }
607
+ check("low space → DELETE still allowed", delThrew === null);
608
+ check("low space → reads still allowed",
609
+ b.db.prepare("SELECT COUNT(*) AS n FROM space_t").get().n === 0);
610
+
611
+ // Recover: free space returns → flag clears → growth writes resume.
612
+ fakeFree = BYTES.mib(100);
613
+ var st2 = b.db._probeStorageForTest();
614
+ check("recovered free space clears writesRefused", st2.writesRefused === false);
615
+ var okErr = null;
616
+ try { b.db.prepare("INSERT INTO space_t (_id, v) VALUES (?, ?)").run("cc", "3"); }
617
+ catch (e) { okErr = e; }
618
+ check("recovered → INSERT succeeds again", okErr === null);
619
+ } finally {
620
+ try { b.db._resetForTest(); } catch (_e) { /* best effort */ }
621
+ await teardownTestDb(tmpDir);
622
+ }
623
+ }
624
+
625
+ async function testExitHandlerRegisteredOnce() {
626
+ // The process-exit final-flush handler must register ONCE for the process
627
+ // lifetime, not on every init(). Re-registering per init leaked an 'exit'
628
+ // listener on each init/close cycle (MaxListenersExceeded under long runs).
629
+ var scratchBase = path.join(__dirname, "..", ".test-output");
630
+ fs.mkdirSync(scratchBase, { recursive: true });
631
+ var schema = [{ name: "el_t", columns: { _id: "TEXT PRIMARY KEY" } }];
632
+ var before = process.listenerCount("exit");
633
+ for (var i = 0; i < 3; i++) {
634
+ var tmpDir = fs.mkdtempSync(path.join(scratchBase, "db-exit-listener-"));
635
+ try {
636
+ await setupTestDb(tmpDir, schema);
637
+ b.db._resetForTest();
638
+ } finally {
639
+ await teardownTestDb(tmpDir);
640
+ }
641
+ }
642
+ var added = process.listenerCount("exit") - before;
643
+ check("exit handler registered once across init/close cycles (no listener leak)", added <= 1);
644
+ }
645
+
459
646
  // ---- run() ----
460
647
 
461
648
  async function run() {
462
649
  // db basic
463
650
  await testDbBasic();
464
651
  await testUnsealRowNullsForgedValue();
652
+ await testEncryptedTmpfsCorruptionAutoRecovers();
653
+ await testEncryptedCloseKeepsPlaintextWhenEncryptFails();
654
+ await testTmpfsLowSpaceRefusesWritesFailClear();
655
+ await testExitHandlerRegisteredOnce();
465
656
  await testDbWriteOps();
466
657
  await testDbSealedWithoutDerived();
467
658
  await testDbTransactions();
@@ -38,6 +38,25 @@ async function testBasicGetPut() {
38
38
  check("hit2 replayCount = 2", hit2 && hit2.replayCount === 2);
39
39
  }
40
40
 
41
+ async function testInMemoryBackendBounded() {
42
+ // The default in-memory backend caps its entry count so a flood of
43
+ // distinct idempotency keys can't grow it without bound (OOM). Eviction
44
+ // is oldest-first — a dropped record just means that one key re-executes
45
+ // on retry, never a crash. Operators needing a hard guarantee at scale
46
+ // supply a durable opts.store.
47
+ var idem = b.agent.idempotency.create({ maxInMemoryEntries: 3 });
48
+ await idem.put("m", "u", "k1", { n: 1 });
49
+ await idem.put("m", "u", "k2", { n: 2 });
50
+ await idem.put("m", "u", "k3", { n: 3 });
51
+ check("at cap: oldest still present", (await idem.get("m", "u", "k1")) !== null);
52
+ // 4th distinct key pushes past the cap → evicts the oldest (k1).
53
+ await idem.put("m", "u", "k4", { n: 4 });
54
+ check("over cap: newest key retained", (await idem.get("m", "u", "k4")) !== null);
55
+ check("over cap: oldest key evicted", (await idem.get("m", "u", "k1")) === null);
56
+ check("over cap: middle keys retained",
57
+ (await idem.get("m", "u", "k2")) !== null && (await idem.get("m", "u", "k3")) !== null);
58
+ }
59
+
41
60
  async function testCrossActorIsolation() {
42
61
  var idem = b.agent.idempotency.create({});
43
62
  await idem.put("move", "u1", "shared-key", { changed: 1 });
@@ -275,6 +294,7 @@ async function run() {
275
294
  testSurface();
276
295
  await testAtRestSealingWithVault();
277
296
  await testBasicGetPut();
297
+ await testInMemoryBackendBounded();
278
298
  await testCrossActorIsolation();
279
299
  await testCrossMethodIsolation();
280
300
  await testKeyReuseDifferentArgs();
@@ -66,6 +66,38 @@ async function testList() {
66
66
  check("list filter tenant", aOnly.length === 2);
67
67
  }
68
68
 
69
+ async function testTenantScopeRegistryReads() {
70
+ // With tenantScope on, registry reads are scoped to the actor's tenant:
71
+ // an actor can't enumerate or acquire a handle to another tenant's agent
72
+ // (the leak agent-event-bus already prevented; orchestrator now mirrors).
73
+ var perms = {
74
+ check: function (actor, scope) {
75
+ // cross-tenant-admin only for actor "admin"; everyone may read.
76
+ if (scope === "framework-cross-tenant-admin") return !!(actor && actor.id === "admin");
77
+ return true;
78
+ },
79
+ };
80
+ var orch = b.agent.orchestrator.create({ tenantScope: true, permissions: perms });
81
+ var registrar = { id: "registrar", scopes: ["agent-registry:write"] };
82
+ await orch.register("ta.mail", _fakeAgent("a"), { agentKind: "mail", tenantId: "a", actor: registrar });
83
+ await orch.register("tb.mail", _fakeAgent("b"), { agentKind: "mail", tenantId: "b", actor: registrar });
84
+
85
+ var actorA = { id: "ua", tenantId: "a", scopes: ["agent-registry:read"] };
86
+ var listA = await orch.list({ actor: actorA });
87
+ check("tenant-scope list: actor sees only own-tenant agents",
88
+ listA.length === 1 && listA[0].tenantId === "a");
89
+ var ownLook = await orch.lookup("ta.mail", { actor: actorA });
90
+ check("tenant-scope lookup: own-tenant agent resolves", ownLook && typeof ownLook === "object");
91
+ var crossLook = await orch.lookup("tb.mail", { actor: actorA });
92
+ check("tenant-scope lookup: cross-tenant agent refused (null)", crossLook === null);
93
+
94
+ var admin = { id: "admin", tenantId: "ops", scopes: ["agent-registry:read", "framework-cross-tenant-admin"] };
95
+ var listAdmin = await orch.list({ actor: admin });
96
+ check("tenant-scope list: cross-tenant-admin sees all", listAdmin.length === 2);
97
+ var adminCross = await orch.lookup("ta.mail", { actor: admin });
98
+ check("tenant-scope lookup: cross-tenant-admin resolves any tenant", adminCross && typeof adminCross === "object");
99
+ }
100
+
69
101
  async function testGuardRefusals() {
70
102
  var orch = b.agent.orchestrator.create({});
71
103
  await expectRejection("register refuses bad name",
@@ -401,6 +433,7 @@ async function run() {
401
433
  testSurface();
402
434
  await testRegisterLookupUnregister();
403
435
  await testList();
436
+ await testTenantScopeRegistryReads();
404
437
  await testGuardRefusals();
405
438
  await testElect();
406
439
  await testElectCluster();
@@ -90,6 +90,40 @@ async function testNonceStoreMemoryPurge() {
90
90
  store.close();
91
91
  }
92
92
 
93
+ async function testNonceStoreMemoryCapacityFailsClosed() {
94
+ // A replay-protection store must bound its memory against a nonce flood,
95
+ // but must NOT evict a LIVE nonce to admit a new one — that reopens a
96
+ // replay window for the evicted nonce. So at capacity it fails CLOSED:
97
+ // the new (un-recordable) request is refused, not admitted unprotected.
98
+ var store = b.nonceStore.create({ backend: "memory", maxEntries: 3 });
99
+ var future = Date.now() + 60_000;
100
+ check("cap: first 3 live nonces admitted",
101
+ (await store.checkAndInsert("n1", future)) === true &&
102
+ (await store.checkAndInsert("n2", future)) === true &&
103
+ (await store.checkAndInsert("n3", future)) === true);
104
+ check("cap: store holds exactly maxEntries", store._size() === 3);
105
+ // 4th distinct LIVE nonce — store is full of live entries, so fail closed.
106
+ check("cap: 4th live nonce fails closed (refused)",
107
+ (await store.checkAndInsert("n4", future)) === false);
108
+ check("cap: refused nonce was NOT stored", store._size() === 3 && !(store._size() > 3));
109
+ check("cap: capacity rejection counted", store._capacityRejects() >= 1);
110
+ // A previously-seen live nonce still reports as replay (not capacity).
111
+ check("cap: existing nonce still detected as replay",
112
+ (await store.checkAndInsert("n1", future)) === false);
113
+ store.close();
114
+
115
+ // When the ceiling is hit but EXPIRED entries exist, the inline purge
116
+ // reclaims room so a legitimate new nonce is admitted (not falsely
117
+ // refused). Fill with expired nonces, then a fresh one must succeed.
118
+ var store2 = b.nonceStore.create({ backend: "memory", maxEntries: 2 });
119
+ var past = Date.now() - 1000;
120
+ await store2.checkAndInsert("old1", past);
121
+ await store2.checkAndInsert("old2", past);
122
+ check("cap: full of EXPIRED entries → fresh nonce admitted (purge reclaims)",
123
+ (await store2.checkAndInsert("freshOne", Date.now() + 60_000)) === true);
124
+ store2.close();
125
+ }
126
+
93
127
  async function testNonceStoreClusterBasics() {
94
128
  var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-ns-"));
95
129
  try {
@@ -1030,6 +1064,7 @@ async function run() {
1030
1064
  await testNonceStoreMemoryBasics();
1031
1065
  await testNonceStoreMemoryRejectsBadInput();
1032
1066
  await testNonceStoreMemoryPurge();
1067
+ await testNonceStoreMemoryCapacityFailsClosed();
1033
1068
  await testNonceStoreClusterBasics();
1034
1069
  await testNonceStoreCustomBackend();
1035
1070
  await testNonceStoreUnknownBackend();
@@ -275,6 +275,10 @@ async function testAppShutdownConfigValidation() {
275
275
  } catch (e) { threw = e; }
276
276
  check("create: bad-shaped phase rejected", threw && threw.code === "app-shutdown/bad-phase");
277
277
 
278
+ threw = null;
279
+ try { b.appShutdown.create({ forceExitMarginMs: -1 }); } catch (e) { threw = e; }
280
+ check("create: bad forceExitMarginMs rejected", threw && threw.code === "app-shutdown/bad-force-exit-margin-ms");
281
+
278
282
  threw = null;
279
283
  var o = b.appShutdown.create({ phases: [] });
280
284
  try { o.addPhase({ name: "x" }); } catch (e) { threw = e; }
@@ -282,6 +286,65 @@ async function testAppShutdownConfigValidation() {
282
286
  o._resetForTest();
283
287
  }
284
288
 
289
+ async function testAppShutdownWatchdogForcesExitOnHang() {
290
+ // A shutdown phase that never settles must NOT hold the process open
291
+ // until the supervisor SIGKILLs it (losing the final DB flush). When the
292
+ // operator delegates lifecycle via installSignalHandlers, a watchdog
293
+ // forces a clean exit graceMs + forceExitMarginMs after the signal so
294
+ // exit handlers (the DB re-encrypt) still run. Verified in a child
295
+ // process — the watchdog calls process.exit, which would kill the runner.
296
+ if (process.platform === "win32") {
297
+ // Node can't deliver SIGTERM to a JS handler on Windows (the OS
298
+ // terminates the process), so the graceful signal path the watchdog
299
+ // guards doesn't exist here. This defends a Linux-container SIGTERM
300
+ // deployment; the container smoke leg exercises it for real.
301
+ check("watchdog test skipped on win32 (no deliverable SIGTERM)", true);
302
+ return;
303
+ }
304
+ var cp = require("node:child_process");
305
+ var repoRoot = require("node:path").resolve(__dirname, "..", "..");
306
+ var script =
307
+ "var b = require(" + JSON.stringify(repoRoot) + ");" +
308
+ "b.appShutdown.create({" +
309
+ " graceMs: 100, forceExitMarginMs: 150, installSignalHandlers: true," +
310
+ " phases: [{ name: 'hang', run: function () { return new Promise(function () {}); } }]" +
311
+ "});" +
312
+ "setInterval(function () {}, 60000);" + // keep the loop alive until the signal
313
+ "process.stdout.write('READY\\n');";
314
+ var child = cp.spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "pipe"] });
315
+
316
+ // Single exit observer + bounded waits so a misbehaving child can never
317
+ // hang the test (it fails loudly via waitUntil's timeout instead).
318
+ var sentAt = null;
319
+ var exited = null;
320
+ var stderr = "";
321
+ child.stderr.on("data", function (d) { stderr += d.toString(); });
322
+ child.on("exit", function (code, signal) {
323
+ exited = { code: code, signal: signal, ms: sentAt === null ? 0 : Date.now() - sentAt };
324
+ });
325
+
326
+ var sawReady = false;
327
+ child.stdout.on("data", function (d) { if (d.toString().indexOf("READY") !== -1) sawReady = true; });
328
+ try {
329
+ await helpers.waitUntil(function () { return sawReady || exited !== null; },
330
+ { timeoutMs: 6000, label: "app-shutdown watchdog: child reached READY" });
331
+ } catch (_e) { /* fall through to the check below */ }
332
+ check("watchdog child reached READY (stderr: " + stderr.slice(0, 160).replace(/\n/g, " ") + ")",
333
+ sawReady && exited === null);
334
+ if (!sawReady || exited !== null) { try { child.kill("SIGKILL"); } catch (_e2) { /* gone */ } return; }
335
+
336
+ sentAt = Date.now();
337
+ child.kill("SIGTERM");
338
+ // The watchdog fires at graceMs(100) + forceExitMarginMs(150) = 250ms.
339
+ await helpers.waitUntil(function () { return exited !== null; },
340
+ { timeoutMs: 6000, label: "app-shutdown watchdog: child forced exit after SIGTERM" });
341
+ // It EXITS (not hangs) and does so on its own — a forced process.exit
342
+ // (numeric code, no kill signal), not because the OS killed it.
343
+ check("hung shutdown forced to exit by watchdog (not hung)", exited && exited.ms < 4000);
344
+ check("watchdog exit was a clean process.exit (not a kill signal)",
345
+ exited && exited.signal === null && typeof exited.code === "number");
346
+ }
347
+
285
348
  async function run() {
286
349
  testAppShutdownSurface();
287
350
  await testAppShutdownEmpty();
@@ -299,6 +362,7 @@ async function run() {
299
362
  await testAppShutdownStandardPhasesOmitsAbsentComponents();
300
363
  await testAppShutdownSignalHandlersInstall();
301
364
  await testAppShutdownConfigValidation();
365
+ await testAppShutdownWatchdogForcesExitOnHang();
302
366
  }
303
367
 
304
368
  module.exports = { run: run };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ /**
3
+ * bounded-map — entry-count-capped Map facade.
4
+ *
5
+ * Run standalone: `node test/layer-0-primitives/bounded-map.test.js`
6
+ * Or via smoke: `node test/smoke.js`
7
+ */
8
+
9
+ var helpers = require("../helpers");
10
+ var check = helpers.check;
11
+ var { boundedMap, BoundedMapError } = require("../../lib/bounded-map");
12
+
13
+ function testEvictOldest() {
14
+ var evicted = [];
15
+ var m = boundedMap({ maxEntries: 3, onEvict: function (k, v) { evicted.push(k + "=" + v); } });
16
+ m.set("a", 1); m.set("b", 2); m.set("c", 3);
17
+ check("evict-oldest: at capacity, all present", m.size === 3 && m.has("a") && m.has("c"));
18
+ m.set("d", 4);
19
+ check("evict-oldest: size stays at cap", m.size === 3);
20
+ check("evict-oldest: oldest (a) evicted", !m.has("a") && m.has("d"));
21
+ m.set("e", 5);
22
+ check("evict-oldest: next oldest (b) evicted", !m.has("b") && m.has("e"));
23
+ check("evict-oldest: onEvict fired for a,b", evicted.join(",") === "a=1,b=2");
24
+ }
25
+
26
+ function testUpdateDoesNotGrowOrEvict() {
27
+ var evicted = [];
28
+ var m = boundedMap({ maxEntries: 2, onEvict: function (k) { evicted.push(k); } });
29
+ m.set("x", 1); m.set("y", 1);
30
+ var r = m.set("x", 99); // update existing — must not evict y
31
+ check("update returns true", r === true);
32
+ check("update keeps size at 2", m.size === 2);
33
+ check("update did not evict", m.has("y") && evicted.length === 0);
34
+ check("update changed the value", m.get("x") === 99);
35
+ }
36
+
37
+ function testRejectPolicy() {
38
+ var m = boundedMap({ maxEntries: 2, policy: "reject" });
39
+ check("reject: first two stored", m.set("a", 1) === true && m.set("b", 1) === true);
40
+ check("reject: third refused", m.set("c", 1) === false);
41
+ check("reject: new key NOT stored", !m.has("c") && m.size === 2);
42
+ check("reject: live entries untouched", m.has("a") && m.has("b"));
43
+ // Updating an existing key is still allowed at capacity (no growth).
44
+ check("reject: update existing allowed", m.set("a", 2) === true && m.get("a") === 2);
45
+ }
46
+
47
+ function testMapFacade() {
48
+ var m = boundedMap({ maxEntries: 10 });
49
+ m.set("k1", "v1"); m.set("k2", "v2");
50
+ check("get", m.get("k1") === "v1");
51
+ check("has", m.has("k2") && !m.has("nope"));
52
+ check("delete", m.delete("k1") === true && !m.has("k1") && m.size === 1);
53
+ var iter = [];
54
+ for (var e of m) iter.push(e[0] + "=" + e[1]);
55
+ check("iterable yields entries", iter.join(",") === "k2=v2");
56
+ var keys = []; var it = m.keys(); var n; while (!(n = it.next()).done) keys.push(n.value);
57
+ check("keys()", keys.join(",") === "k2");
58
+ var fe = []; m.forEach(function (v, k) { fe.push(k + "=" + v); });
59
+ check("forEach", fe.join(",") === "k2=v2");
60
+ m.clear();
61
+ check("clear", m.size === 0);
62
+ }
63
+
64
+ function testValidation() {
65
+ function threw(fn) { try { fn(); return null; } catch (e) { return e; } }
66
+ check("maxEntries 0 → throws", threw(function () { boundedMap({ maxEntries: 0 }); }) instanceof BoundedMapError);
67
+ check("maxEntries -1 → throws", threw(function () { boundedMap({ maxEntries: -1 }); }));
68
+ check("maxEntries 1.5 → throws", threw(function () { boundedMap({ maxEntries: 1.5 }); }));
69
+ check("maxEntries missing → throws", threw(function () { boundedMap({}); }));
70
+ var e = threw(function () { boundedMap({ maxEntries: 5, policy: "nope" }); });
71
+ check("bad policy → throws bad-policy", e && e.code === "bounded-map/bad-policy");
72
+ }
73
+
74
+ function run() {
75
+ testEvictOldest();
76
+ testUpdateDoesNotGrowOrEvict();
77
+ testRejectPolicy();
78
+ testMapFacade();
79
+ testValidation();
80
+ }
81
+
82
+ module.exports = { run: run };
83
+
84
+ if (require.main === module) {
85
+ try { run(); console.log("[bounded-map] OK — " + helpers.getChecks() + " checks passed"); }
86
+ catch (e) { console.error("FAIL:", e.stack || e); process.exit(1); }
87
+ }
@@ -639,6 +639,52 @@ function _newCache(extra) {
639
639
  return b.cache.create(Object.assign({ namespace: "v411", backend: "memory" }, extra || {}));
640
640
  }
641
641
 
642
+ async function testUpdateMemory() {
643
+ var c = _newCache({ namespace: "upd-mem" });
644
+ var r1 = await c.update("n", function (cur) { return { value: (cur || 0) + 1 }; });
645
+ await c.update("n", function (cur) { return { value: cur + 1 }; });
646
+ check("update memory: increments atomically", (await c.get("n")) === 2 && r1.value === 1);
647
+ var ab = await c.update("n", function () { return { abort: { why: "nope" } }; });
648
+ check("update memory: abort leaves value + returns aborted",
649
+ ab.aborted && ab.aborted.why === "nope" && (await c.get("n")) === 2);
650
+ var dl = await c.update("n", function () { return { delete: true }; });
651
+ check("update memory: delete removes the entry",
652
+ dl.deleted === true && (await c.get("n")) === undefined);
653
+ // update on an absent key sees null and may create.
654
+ var cr = await c.update("fresh", function (cur) { return { value: cur === null ? "created" : "x" }; });
655
+ check("update memory: absent key seen as null", cr.value === "created" && (await c.get("fresh")) === "created");
656
+ await c.close();
657
+ }
658
+
659
+ async function testUpdateClusterCas() {
660
+ var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-cache-upd-"));
661
+ try {
662
+ await setupTestDb(tmpDir);
663
+ var c = b.cache.create({ namespace: "upd-clu", backend: "cluster", ttlMs: b.constants.TIME.minutes(5) });
664
+ await c.update("set", function (cur) { return { value: { items: (cur ? cur.items : []).concat("a") } }; });
665
+ await c.update("set", function (cur) { return { value: { items: cur.items.concat("b") } }; });
666
+ // Concurrent appends: the compare-and-set + retry must preserve BOTH
667
+ // (no lost update) — the cluster race the get/set version dropped.
668
+ await Promise.all([
669
+ c.update("set", function (cur) { return { value: { items: cur.items.concat("x") } }; }),
670
+ c.update("set", function (cur) { return { value: { items: cur.items.concat("y") } }; }),
671
+ ]);
672
+ var fin = await c.get("set");
673
+ check("update cluster: concurrent CAS loses no write", fin.items.length === 4);
674
+ check("update cluster: all appends present",
675
+ fin.items.indexOf("a") !== -1 && fin.items.indexOf("b") !== -1 &&
676
+ fin.items.indexOf("x") !== -1 && fin.items.indexOf("y") !== -1);
677
+ var ab = await c.update("set", function () { return { abort: { stop: 1 } }; });
678
+ check("update cluster: abort returns aborted + leaves value",
679
+ ab.aborted && ab.aborted.stop === 1 && (await c.get("set")).items.length === 4);
680
+ await c.update("set", function () { return { delete: true }; });
681
+ check("update cluster: delete removes the entry", (await c.get("set")) === undefined);
682
+ await c.close();
683
+ } finally {
684
+ await teardownTestDb(tmpDir);
685
+ }
686
+ }
687
+
642
688
  async function testMaxBytesEvictsLru() {
643
689
  // Cache size budget: 30 bytes. Each entry ~12 bytes JSON ("aaaaaaaa" → 10).
644
690
  var c = _newCache({
@@ -847,6 +893,8 @@ async function run() {
847
893
  await testClusterBackendBasics();
848
894
  await testClusterNamespaceIsolation();
849
895
  await testClusterTtlExpiration();
896
+ await testUpdateMemory();
897
+ await testUpdateClusterCas();
850
898
 
851
899
  // v0.4.11
852
900
  await testValidationNewOpts();