@blamejs/blamejs-shop 0.4.106 → 0.4.107

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.107 (2026-06-25) — **Vendored framework refreshed to 0.15.27.** Updates the vendored blamejs framework to 0.15.27. The 0.15.27 release is an upstream test-harness reliability change only — its published library is byte-for-byte identical to 0.15.26, with no runtime API or behavior change. No operator action required. **Changed:** *Vendored framework refreshed to 0.15.27* — Refreshes the vendored blamejs framework to 0.15.27, an upstream release whose shipped library is byte-for-byte identical to 0.15.26 (the change is to the framework's own test harness). There are no runtime API changes and no operator-visible behavior change; no action is required.
12
+
11
13
  - v0.4.106 (2026-06-25) — **A capped discount rule's redemption counter no longer drifts toward its limit after a transient database error mid-claim.** Claiming a redemption against a discount rule with a total-redemption cap reserves a slot by incrementing the rule's used-count, then writes the per-order claim row. If writing that claim row failed for a reason other than the expected duplicate-claim collision — a transient backend error — the reserved slot was not returned, so the rule's used-count drifted one higher than the claims actually made and a capped rule could reach its limit early and start refusing legitimate redemptions. The claim path already returns the reservation when it reuses an existing claim and when the per-customer cap refuses; it now also returns it on this transient-failure path. The return is safe and cannot over-grant: the code re-reads the claim by its unique key and only returns the slot when that read confirms no claim row was written, so there is no case where a slot is returned for a claim that actually landed. **Fixed:** *A failed redemption claim returns its reserved cap slot instead of leaking it* — A redemption claim against a total-capped discount rule increments the rule's used-count to reserve a slot before inserting the per-order claim row. A failure inserting that row that was not the expected duplicate-claim collision (a transient backend error) left the reserved slot un-returned, drifting the used-count above the real claim count so the rule could hit its cap and refuse valid redemptions prematurely. The claim path now returns the reserved slot on that path too, matching the existing reuse and per-customer-cap refusals. It is safe against over-granting because it returns the slot only after a re-read on the claim's unique key confirms no row was written — a claim that actually landed is detected and reused, never double-counted.
12
14
 
13
15
  - v0.4.105 (2026-06-25) — **A loyalty points balance is now an exactly-once, tamper-evident running ledger instead of a stored counter updated alongside its history.** A customer's loyalty balance, lifetime points, and tier were held in a stored account row and updated in a statement separate from the transaction-history insert. On a backend without interactive transactions, a failure between those two writes could leave the balance and the history disagreeing, and there was no way to make a points credit exactly-once with a stored counter. Every loyalty mutation — earn, redeem, expire, adjust, credit, and the refund-time reverse/restore credits — is now a single guarded insert into the transaction ledger that also carries the new running balance, lifetime, and tier, so the balance change and its audit row are one write with no window to half-apply. The running balance is read from the ledger tip rather than a separate counter, redemptions refuse an overdraft inside that one statement, and concurrent writes to the same customer are serialized by a per-customer chain so two redemptions can never both spend the same points. Each ledger row is linked into a per-customer SHA3-512 hash chain, so the history is tamper-evident and can be verified end to end (the loyalty addition to the store-credit and gift-card ledger tamper-evidence). Balances, tiers, the points ratios, and the entire loyalty API are unchanged; the running-balance columns are added by a migration that anchors each existing customer to their current trusted balance without replaying history. The account row is kept as a denormalized mirror for the tier leaderboard and tier-expiry, refreshed after each write and never on a balance decision path. **Changed:** *Loyalty balance, lifetime, and tier are computed from a single guarded ledger write* — The loyalty balance, lifetime points, and tier are no longer a stored counter mutated next to a separate history insert. Each earn / redeem / expire / adjust / credit and each refund-time reverse or restore is now one guarded insert into the loyalty transaction ledger that records the event and carries the resulting running balance, lifetime, and tier in the same row — so a balance change and its audit entry are a single write that cannot half-apply, and a points credit is exactly-once and crash-safe. The current balance is read from the latest ledger row; a redemption's overdraft refusal lives inside that insert; and concurrent writes to one customer are serialized so two redemptions can't both spend the same balance. The customer's balances, tier, the points-per-dollar and redemption ratios, and the public loyalty API are all unchanged. · *Loyalty history is now a tamper-evident hash chain with a verifyChain audit* — Each loyalty transaction row is linked to the previous one for the same customer by a SHA3-512 hash chain, the same tamper-evidence the store-credit and gift-card ledgers already carry. A new verifyChain audit recomputes the chain to prove no past row was edited or re-ordered. A schema migration adds the running-balance and chain columns and anchors every existing customer to their current trusted balance — it copies the live balance verbatim rather than replaying history, so a balance is never recomputed (and never corrupted) during the upgrade. The migration applies automatically on deploy; no operator action is required.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.106",
2
+ "version": "0.4.107",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.15.26",
7
- "tag": "v0.15.26",
6
+ "version": "0.15.27",
7
+ "tag": "v0.15.27",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -848,6 +848,7 @@
848
848
  "release-notes/v0.15.24.json": "lib/vendor/blamejs/release-notes/v0.15.24.json",
849
849
  "release-notes/v0.15.25.json": "lib/vendor/blamejs/release-notes/v0.15.25.json",
850
850
  "release-notes/v0.15.26.json": "lib/vendor/blamejs/release-notes/v0.15.26.json",
851
+ "release-notes/v0.15.27.json": "lib/vendor/blamejs/release-notes/v0.15.27.json",
851
852
  "release-notes/v0.15.3.json": "lib/vendor/blamejs/release-notes/v0.15.3.json",
852
853
  "release-notes/v0.15.4.json": "lib/vendor/blamejs/release-notes/v0.15.4.json",
853
854
  "release-notes/v0.15.5.json": "lib/vendor/blamejs/release-notes/v0.15.5.json",
@@ -1557,7 +1558,7 @@
1557
1558
  ".npmrc": "sha256:66f104e7d07c496d2d0409988225e8c0e4ceb8d247dbcac3be75b2128d20ce66",
1558
1559
  ".pinact.yaml": "sha256:0213ffda55961dc49b64c0a5dfa3c0567419633b1499d57eaf7c8d842d7da6c7",
1559
1560
  "ARCHITECTURE.md": "sha256:9b1c8d2b1b7a41838eb348b0a008e4b4369718fd72bfe2974b37155f7536d35b",
1560
- "CHANGELOG.md": "sha256:a7808b6f3dc20b1081799bf24a1aa0510e937982e76726875354e2e28d78165f",
1561
+ "CHANGELOG.md": "sha256:36801b1ca30771a1ecf59838fbbeb76ea5b5a8aa52ef09f9d786ef713f8264b0",
1561
1562
  "CODE_OF_CONDUCT.md": "sha256:148a281960fff7c2fe6554dab66da572c72245ddeb00b0d14811558397bff386",
1562
1563
  "CONTRIBUTING.md": "sha256:bb4dbdbc8598da31dbce653a8ed322e08ff46560173f2eb67a4d684653948332",
1563
1564
  "GOVERNANCE.md": "sha256:906df6afb1f552b27b9acb50f7f96c47b917a2f1021cd4e987dbf4ee0e0a821b",
@@ -1567,7 +1568,7 @@
1567
1568
  "NOTICE": "sha256:f487fa47a11aca0f89e2615cdd3c713e9842abf7a30d8d328eeeae1c864aa774",
1568
1569
  "README.md": "sha256:3ddcc197b003da0b02db8bdd1aef1e943c94f7eab613c633d6a45bb11d0a80e9",
1569
1570
  "SECURITY.md": "sha256:77d8c2bcc04b425a08ef30e51204cebddbd48954b028a259c7a8412dbfeab40a",
1570
- "api-snapshot.json": "sha256:d9fb755995449f8e89767fff95c2558a412f8b54d2b3bf188b2d126cb253de1b",
1571
+ "api-snapshot.json": "sha256:cf6ac2360a4e975efd8027bd75cc5b9ff6bc969e41186142a05af30c0030d94f",
1571
1572
  "assets/BlameJS_Logo.png": "sha256:3c65699753c771b48ef9ac7f45bb40815ec19a23afcdd0cd30ef4601bbbe293e",
1572
1573
  "assets/BlameJS_Logo.svg": "sha256:dda44f3fb1343d5de9db6b1fcdb75fc649c57e7a99a8e8239fcf852e3841e1a8",
1573
1574
  "bench/README.md": "sha256:74202f2507fd840bfc1ac6c681975d9273cf36cca6e0f72655f138337304033c",
@@ -2338,7 +2339,7 @@
2338
2339
  "oss-fuzz/projects/blamejs/README.md": "sha256:ae13b7bb79ed8d69b1b3276e5562807a0349fb6e6b7d11cf1f683aad1eafdb4b",
2339
2340
  "oss-fuzz/projects/blamejs/build.sh": "sha256:0ced1cf21782c97be7f8d74faf5e27a308b60b2f858836fb5ca3b8c4e939a8f7",
2340
2341
  "oss-fuzz/projects/blamejs/project.yaml": "sha256:59f2cb83aa622325a175b77416fe155be15b70a9c798bd1a78bba05763b1b03d",
2341
- "package.json": "sha256:f60598f0d5032b4095c52904551c1cb09a3d4e42113f102f5efb92f0de2aba61",
2342
+ "package.json": "sha256:018da0233b136be17f4b1919f6c03bcb474db6d62d53362639d47662aa756e4d",
2342
2343
  "release-notes/v0.0.x.json": "sha256:7a49819f30068ee119000cad7010194882bb8bfaa12acbdab4dfc066efb7982f",
2343
2344
  "release-notes/v0.1.x.json": "sha256:6742a8c17f947c5cb76f69dead7eea86b942d80621d914b774ba5488e09937e5",
2344
2345
  "release-notes/v0.10.x.json": "sha256:fe498045daf88337bd3d987e5964aa42c99a50e1685b6f09e51f698b8687726f",
@@ -2366,6 +2367,7 @@
2366
2367
  "release-notes/v0.15.24.json": "sha256:748b633f90de8f41b4f26f7ab8f3c31baaa0acd3d9ece2b44f0308724c8765a1",
2367
2368
  "release-notes/v0.15.25.json": "sha256:6f7c4eb0d57068317bfb68f739d93b5926f9723a57a0701c966b0f96eb46ed69",
2368
2369
  "release-notes/v0.15.26.json": "sha256:a96b1c7409f863b5a920f51a92b653d5f7a0b671e000bb5dfc2e9bf47e4120dc",
2370
+ "release-notes/v0.15.27.json": "sha256:f9e8309bc32c2245ec8d0682e6205417879f090403727d4780b42c0536125f02",
2369
2371
  "release-notes/v0.15.3.json": "sha256:19a0074c445545468ca3cc411b21ec8bdb27be2669ae1950347cc244f6aa348c",
2370
2372
  "release-notes/v0.15.4.json": "sha256:6ac7fa0ef1728c27e71b2050d1b07a810f9b4b1440ccddbf28ad56e2f54d8585",
2371
2373
  "release-notes/v0.15.5.json": "sha256:cca1d0edd5d6fc41b512d19d98be224b990dcab41478622c11962f0fcb1bb09a",
@@ -2407,12 +2409,12 @@
2407
2409
  "scripts/vendor-data-gen.js": "sha256:76b627bc6e19b4a122edfca6f514bcb8ca11af02902f0957e641f503337a8a0f",
2408
2410
  "scripts/vendor-data-keygen.js": "sha256:94eaa4d8f832b4aac9ccbcb2a07e6b99cd35cf7b044e1412079cebdefc1f4c0e",
2409
2411
  "scripts/vendor-update.sh": "sha256:373d7f024b7caa3345a3598fa3a586078dd842f0071e6fdad00a473f48a3a929",
2410
- "test/00-primitives.js": "sha256:a3b45127578d453428a2afc10724a0c7dc7cd4963b32dd2a965453ab517f481f",
2411
- "test/10-state.js": "sha256:7f286e00fda002ccf50ccc59d658b6dce9a4bccee304232984a932a2ceb31c49",
2412
+ "test/00-primitives.js": "sha256:99992ebfdf1bcd14a8ac20c71d96741c11b9ff793c788f220226261652b07152",
2413
+ "test/10-state.js": "sha256:45c177da8158bda413b783f68d9b01ef72796aec114c26acea084f92de874f57",
2412
2414
  "test/20-db.js": "sha256:241ef6b7ef305d077aeafb22ee3bcc75b6b549a8fa9b1a6b5d6d5fba43b48d7d",
2413
- "test/30-chain.js": "sha256:5aec1fb27d1a87ac74fb3613192b0d6b9cb8bb1dbf5cefdd0fc04eceb9fc3d35",
2414
- "test/40-consumers.js": "sha256:b652384ea357532ecec289dcf2c624caf1b50b8ac1600b11f5ed08acba3e1992",
2415
- "test/50-integration.js": "sha256:a016825cc3d954f9e2c65c8f74e9fc23e180c470645adefc8b8b17c2229aedca",
2415
+ "test/30-chain.js": "sha256:81d3615c276138d9b71136047ce80f03170951a7ce1e6aa6be117cb673cca4f8",
2416
+ "test/40-consumers.js": "sha256:9db410c8a1813d1bedb90c6a3450b6d11decb9467106c22afd67ef72d9a209cf",
2417
+ "test/50-integration.js": "sha256:a710c9a0d698ea3f3c2649508950851314619730d138aa455dfd4085410e633f",
2416
2418
  "test/_helpers.js": "sha256:9769ddf6d7eb1c4ef7d461036ba47835ef5ef2a86d64704ffd85d49b3ccbaf20",
2417
2419
  "test/_smoke-worker.js": "sha256:502a4cb77dcec473b44b60a28ddccce6a4e424de78f053293e75d1d6333eea7a",
2418
2420
  "test/fixtures/exploit-corpus/corpus.json": "sha256:83bf3878cbadd7097698fd6292f19b981d139d56eeafdff242292451ff7a4afc",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.27 (2026-06-25) — **Internal test-harness reliability only — the published library is byte-for-byte identical to 0.15.26.** The legacy single-layer smoke files used fixed-duration setTimeout sleeps to wait for asynchronous conditions (a job processed, a lease TTL lapsing, an audit flush completing). On a contended CI runner a fixed sleep is both flake-prone (too short under load) and slow (it always burns the full budget). Those condition-waits are converted to the harness's polling helpers — waitUntil for observable predicates, passiveObserve for deliberate real-time elapses, withTestTimeout for hang guards — which exit early on fast platforms and give contended platforms the full budget. No shipped framework code changed; this release is byte-for-byte identical to 0.15.26 for operators. **Fixed:** *Smoke layer files poll for conditions instead of fixed setTimeout sleeps* — The single-layer smoke files waited on asynchronous conditions with fixed-duration setTimeout sleeps, which flake under SMOKE_PARALLEL load and always burn their full budget. The condition-waits are converted to the polling helpers (waitUntil / passiveObserve / withTestTimeout) so they exit early on fast platforms and stay robust on contended ones; non-wait timers (abort triggers, child/socket watchdogs, simulated-latency mocks) are unchanged. This is test-harness reliability only — no shipped framework behavior changed.
12
+
11
13
  - v0.15.26 (2026-06-25) — **Internal test-harness correctness only — the published library is byte-for-byte identical to 0.15.25.** The smoke runner requires each test module and awaits its exported run(). Several tests were instead written as a top-level (async function () {...})() IIFE that runs detached at require-time, so the runner measured and reported the test's result before the IIFE's post-await assertions executed — those checks silently never ran (one parser test exercised 4 of its 26 assertions, and a failure after the first await would have gone unseen as a false pass). Those tests are converted to the exported-run form so the runner awaits their full assertion set, and a codebase-patterns detector now refuses a top-level async IIFE in a test file so the pattern cannot return. No shipped framework code changed; this release is byte-for-byte identical to 0.15.25 for operators. **Fixed:** *Detached-IIFE tests now run their full assertion set under the smoke runner* — A test written as a top-level (async function () {...})() IIFE runs detached when the runner requires it: the runner only awaits an exported run(), so it reported the file's result before the IIFE's awaited assertions executed, and every check after the first await silently did not count (one parsers test ran 4 of 26). Such tests are rewritten to define async function run(), export it, and invoke it under if (require.main === module), so the runner awaits the complete set; a detector refuses a re-introduced top-level async IIFE in a test file. This is test-harness correctness only — no shipped framework behavior changed.
12
14
 
13
15
  - v0.15.25 (2026-06-25) — **`b.sandbox.run` no longer leaks the worker thread's MessagePort — it now waits for the worker to terminate before resolving.** b.sandbox.run spawned a worker thread to execute untrusted code and called worker.terminate() on both the result and timeout paths, but it settled the caller's promise BEFORE the asynchronous terminate() completed — leaving the worker's MessagePort alive past the resolve. In a long-lived process that runs sandboxed code repeatedly, each call leaked a MessagePort handle, keeping the event loop populated and slowing graceful shutdown. The call now awaits worker.terminate() before settling, so the handle is released when the promise resolves. Behaviour and return values are unchanged. **Fixed:** *b.sandbox.run releases the worker MessagePort before resolving* — b.sandbox.run resolved (or rejected) the caller's promise as soon as the worker reported a result or the timeout fired, while worker.terminate() — which is asynchronous — was still in flight, so the worker thread's MessagePort outlived the call and lingered as an open handle. Repeated sandbox runs in a long-lived process accumulated leaked MessagePorts that kept the event loop alive and delayed shutdown. The result and timeout paths now defer settling until worker.terminate() resolves; the error and exit paths already imply the worker is gone. The return value, audit events, and timeout semantics are unchanged.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.15.26",
4
- "createdAt": "2026-06-25T20:58:27.525Z",
3
+ "frameworkVersion": "0.15.27",
4
+ "createdAt": "2026-06-26T01:18:53.911Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.26",
3
+ "version": "0.15.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.15.27",
4
+ "date": "2026-06-25",
5
+ "headline": "Internal test-harness reliability only — the published library is byte-for-byte identical to 0.15.26",
6
+ "summary": "The legacy single-layer smoke files used fixed-duration setTimeout sleeps to wait for asynchronous conditions (a job processed, a lease TTL lapsing, an audit flush completing). On a contended CI runner a fixed sleep is both flake-prone (too short under load) and slow (it always burns the full budget). Those condition-waits are converted to the harness's polling helpers — waitUntil for observable predicates, passiveObserve for deliberate real-time elapses, withTestTimeout for hang guards — which exit early on fast platforms and give contended platforms the full budget. No shipped framework code changed; this release is byte-for-byte identical to 0.15.26 for operators.",
7
+ "sections": [
8
+ {
9
+ "heading": "Fixed",
10
+ "items": [
11
+ {
12
+ "title": "Smoke layer files poll for conditions instead of fixed setTimeout sleeps",
13
+ "body": "The single-layer smoke files waited on asynchronous conditions with fixed-duration setTimeout sleeps, which flake under SMOKE_PARALLEL load and always burn their full budget. The condition-waits are converted to the polling helpers (waitUntil / passiveObserve / withTestTimeout) so they exit early on fast platforms and stay robust on contended ones; non-wait timers (abort triggers, child/socket watchdogs, simulated-latency mocks) are unchanged. This is test-harness reliability only — no shipped framework behavior changed."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -393,7 +393,7 @@ async function testAsyncSafeCircuitBreakerStateTransitions() {
393
393
  try { await br.wrap(async function () { return "ok"; }); }
394
394
  catch (e) { fastFail = e; }
395
395
  check("CircuitBreaker: open state fast-fails", fastFail && fastFail.code === "CIRCUIT_OPEN");
396
- await new Promise(function (r) { setTimeout(r, 50); });
396
+ await helpers.passiveObserve(50, "CircuitBreaker: cooldownMs(30) lapses before half-open probe");
397
397
  var probe = await br.wrap(async function () { return "ok"; });
398
398
  check("CircuitBreaker: half-open probe success", probe === "ok");
399
399
  check("CircuitBreaker: closes after success threshold", br.getState() === "closed");
@@ -528,7 +528,17 @@ function testAsyncSafeWithTimeoutSignalCases() {
528
528
 
529
529
  async function testAsyncSafeWithTimeoutSignalTimeoutFires() {
530
530
  var sig = b.safeAsync.withTimeoutSignal(null, 30);
531
- await new Promise(function (r) { setTimeout(r, 80); });
531
+ // Tight bound, not a loose 5s window: the 30ms timeout must fire well within
532
+ // 500ms even under SMOKE_PARALLEL contention (the prior fixed 80ms wait this
533
+ // replaced proved sub-80ms is the norm). waitUntil exits early on fast
534
+ // platforms and THROWS when the budget is exceeded, so a regression that
535
+ // stretches the 30ms deadline into hundreds / thousands of ms (or never)
536
+ // overruns the 500ms budget and fails the test — the "fires near its
537
+ // configured deadline" guarantee is preserved, not lost to a loose window.
538
+ await helpers.waitUntil(function () { return sig.aborted === true; }, {
539
+ timeoutMs: 500,
540
+ label: "withTimeoutSignal: timeout-only signal aborts within its 30ms deadline",
541
+ });
532
542
  check("withTimeoutSignal: timeout-only signal fires after ms", sig.aborted === true);
533
543
  }
534
544
 
@@ -5235,7 +5245,7 @@ async function testRestoreListRollbacksAndPurge() {
5235
5245
  });
5236
5246
  // Two restores to create two rollback points
5237
5247
  await restore.run({ bundleId: bundleId });
5238
- await new Promise(function (r) { setTimeout(r, 5); });
5248
+ await helpers.passiveObserve(5, "restore rollback: timestamps differ between two rollback points");
5239
5249
  await restore.run({ bundleId: bundleId });
5240
5250
 
5241
5251
  var rb = restore.listRollbacks();
@@ -5551,7 +5561,7 @@ async function testBackupRetentionPurgeOlder() {
5551
5561
  for (var i = 0; i < 4; i++) {
5552
5562
  var r = await backup.run();
5553
5563
  ids.push(r.bundleId);
5554
- await new Promise(function (rr) { setTimeout(rr, 5); });
5564
+ await helpers.passiveObserve(5, "backup retention: timestamps differ between sequential backups");
5555
5565
  }
5556
5566
  var listed = await backup.list();
5557
5567
  check("4 bundles before purge", listed.length === 4);
@@ -5575,7 +5585,7 @@ async function testBackupRetentionAutoSweepOnRun() {
5575
5585
  for (var i = 0; i < 4; i++) {
5576
5586
  var r = await backup.run();
5577
5587
  ids.push(r.bundleId);
5578
- await new Promise(function (rr) { setTimeout(rr, 5); });
5588
+ await helpers.passiveObserve(5, "backup auto-sweep: timestamps differ between sequential backups");
5579
5589
  }
5580
5590
  // After 4 runs with retention=2, only the 2 newest should remain
5581
5591
  var listed = await backup.list();
@@ -8560,14 +8570,11 @@ async function testCompressionPipedStreamLargerThanHighWaterMarkCompletes() {
8560
8570
  var payload = Buffer.alloc(256 * 1024, "A");
8561
8571
  var source = stream.Readable.from([payload]);
8562
8572
 
8563
- // Race the pipe completion against a timeout. Pre-fix this hangs
8564
- // indefinitely; with the drain forward it completes promptly.
8573
+ // Bound the pipe completion with a wall-clock ceiling. Pre-fix this
8574
+ // hangs indefinitely; with the drain forward it completes promptly.
8565
8575
  var done = new Promise(function (resolve) {
8566
8576
  res.on("finish", function () { resolve("ok"); });
8567
8577
  });
8568
- var timeout = new Promise(function (resolve) {
8569
- setTimeout(function () { resolve("timeout"); }, 3000);
8570
- });
8571
8578
 
8572
8579
  await new Promise(function (resolve) {
8573
8580
  compress(req, res, function () {
@@ -8577,7 +8584,11 @@ async function testCompressionPipedStreamLargerThanHighWaterMarkCompletes() {
8577
8584
  });
8578
8585
  });
8579
8586
 
8580
- var outcome = await Promise.race([done, timeout]);
8587
+ var outcome = await helpers.withTestTimeout(
8588
+ "piped stream completes (no stall on backpressure)",
8589
+ function () { return done; },
8590
+ { timeoutMs: 3000 }
8591
+ );
8581
8592
  check("piped stream completes (no stall on backpressure)", outcome === "ok");
8582
8593
  if (outcome !== "ok") return;
8583
8594
 
@@ -11784,7 +11795,7 @@ async function testSessionRotateExpiredReturnsNull() {
11784
11795
  try {
11785
11796
  await setupTestDb(tmpDir);
11786
11797
  var s = await b.session.create({ userId: "u-4", ttlMs: 1 }); // 1ms TTL
11787
- await new Promise(function (r) { setTimeout(r, 30); });
11798
+ await helpers.passiveObserve(30, "session rotate: 1ms TTL lapses before rotate");
11788
11799
  var rotated = await b.session.rotate(s.token);
11789
11800
  check("rotate: expired session returns null", rotated === null);
11790
11801
  } finally {
@@ -286,8 +286,9 @@ async function testClusterProviderTakeoverAfterExpiry() {
286
286
  check("node-A acquired short-TTL lease", leaseA !== null);
287
287
  check("first acquire fencingToken = 1", leaseA.fencingToken === 1);
288
288
 
289
- // Wait past expiry (50ms TTL + buffer).
290
- await new Promise(function (r) { setTimeout(r, 100); });
289
+ // Wait past expiry (50ms TTL + buffer) — a real-time elapse to let the
290
+ // DB-side lease TTL lapse, not a condition-wait, so passiveObserve is right.
291
+ await helpers.passiveObserve(100, "10-state: lease TTL expiry (50ms TTL + buffer)");
291
292
 
292
293
  var leaseB = await pB.acquireLease("node-B", b.constants.TIME.seconds(30));
293
294
  check("node-B steals expired lease", leaseB !== null);
@@ -317,7 +318,8 @@ async function testClusterProviderRenewalRace() {
317
318
  await pA.ensureSchema();
318
319
 
319
320
  var leaseA = await pA.acquireLease("node-A", 50);
320
- await new Promise(function (r) { setTimeout(r, 100); });
321
+ // Real-time elapse to let the 50ms lease TTL lapse before the takeover race.
322
+ await helpers.passiveObserve(100, "10-state: lease TTL expiry before takeover-race");
321
323
  var leaseB = await pB.acquireLease("node-B", b.constants.TIME.seconds(30));
322
324
  check("takeover succeeded for race test", leaseB !== null);
323
325
 
@@ -1079,17 +1079,14 @@ async function testClusterAuditFlushNoRecursionHang() {
1079
1079
  actor: { userId: "rec-test" },
1080
1080
  });
1081
1081
 
1082
- // The Promise.race against a 5s timeout is the no-hang regression
1083
- // guard if flush() truly hangs it never resolves, raced lands as
1084
- // "TIMEOUT". Don't add a separate elapsed-< 5000 check: it's
1085
- // tautologically true when raced === "done" (the timeout would have
1086
- // fired first otherwise) and produces a Windows boundary flake when
1087
- // event-loop drift puts the post-await Date.now() at 5001ms.
1088
- var raced = await Promise.race([
1089
- b.audit.flush().then(function () { return "done"; }),
1090
- new Promise(function (r) { setTimeout(function () { r("TIMEOUT"); }, 5000); }),
1091
- ]);
1092
- check("audit.flush returns (no recursion hang)", raced === "done");
1082
+ // withTestTimeout is the no-hang regression guard — if flush() truly
1083
+ // hangs it never resolves and the wrapper rejects with "test timed out"
1084
+ // after 5s, failing loudly rather than wedging the runner. It clears its
1085
+ // own guard timer on settle (no leaked Timeout), and there is no separate
1086
+ // elapsed check to drift into a Windows boundary flake.
1087
+ await helpers.withTestTimeout("audit.flush returns (no recursion hang)",
1088
+ function () { return b.audit.flush(); }, { timeoutMs: 5000 });
1089
+ check("audit.flush returns (no recursion hang)", true);
1093
1090
  } finally {
1094
1091
  try { await b.cluster.shutdown(); } catch (_e) {}
1095
1092
  try { await b.externalDb.shutdown(); } catch (_e) {}
@@ -68,8 +68,8 @@ async function testSession() {
68
68
  // touch
69
69
  var beforeTouch = await b.session.verify(s1.token);
70
70
  var t0 = beforeTouch.lastActivity;
71
- // Sleep briefly to ensure lastActivity changes
72
- await new Promise(function (r) { setTimeout(r, 10); });
71
+ // Let real time elapse so lastActivity (a Date.now() stamp) advances
72
+ await helpers.passiveObserve(10, "session.touch: lastActivity timestamp advances");
73
73
  var ok = await b.session.touch(s1.token);
74
74
  check("touch returns true", ok === true);
75
75
  var afterTouch = await b.session.verify(s1.token);
@@ -108,13 +108,13 @@ async function testSession() {
108
108
 
109
109
  // Expired session auto-cleans on verify
110
110
  var sExp = await b.session.create({ userId: "u-3", ttlMs: 50 });
111
- await new Promise(function (r) { setTimeout(r, 100); });
111
+ await helpers.passiveObserve(100, "session: 50ms TTL lapses before verify");
112
112
  check("verify on expired session returns null", (await b.session.verify(sExp.token)) === null);
113
113
 
114
114
  // purgeExpired
115
115
  var sExp2 = await b.session.create({ userId: "u-4", ttlMs: 50 });
116
116
  void sExp2;
117
- await new Promise(function (r) { setTimeout(r, 100); });
117
+ await helpers.passiveObserve(100, "session: 50ms TTL lapses before purgeExpired");
118
118
  var purged = await b.session.purgeExpired();
119
119
  check("purgeExpired returns count", purged >= 1);
120
120
 
@@ -438,8 +438,9 @@ async function testRetryAndBreaker() {
438
438
  catch (e) { fastFail = e.code === "CIRCUIT_OPEN"; }
439
439
  check("open breaker fails fast", fastFail);
440
440
 
441
- // Wait for cooldown then half-open + success closes
442
- await new Promise(function (r) { setTimeout(r, 60); });
441
+ // Let the 50ms cooldown lapse so the breaker goes half-open, then
442
+ // a successful probe closes it.
443
+ await helpers.passiveObserve(60, "breaker: 50ms cooldown lapses before half-open probe");
443
444
  await breaker.wrap(function () { return Promise.resolve("ok"); });
444
445
  check("breaker closes after successful probe", breaker.getState() === "closed");
445
446
  }
@@ -959,11 +960,11 @@ async function testQueueConsume() {
959
960
  await b.queue.enqueue("test-job", { msg: "hello-2" });
960
961
  await b.queue.enqueue("test-job", { msg: "hello-3" });
961
962
 
962
- // Wait for processing (poll up to 3s)
963
- var deadline = Date.now() + 3000;
964
- while (processed.length < 3 && Date.now() < deadline) {
965
- await new Promise(function (r) { setTimeout(r, 50); });
966
- }
963
+ // Wait for processing
964
+ await helpers.waitUntil(function () { return processed.length >= 3; }, {
965
+ timeoutMs: 5000,
966
+ label: "queue.consume: all 3 jobs processed",
967
+ });
967
968
  check("consume processed all 3 jobs", processed.length === 3);
968
969
  check("payloads decoded correctly", processed.some(p => p.msg === "hello-1"));
969
970
  check("queue size 0 after consume", (await b.queue.size("test-job")) === 0);
@@ -1002,14 +1003,15 @@ async function testQueueRetryAndFail() {
1002
1003
  await b.queue.enqueue("fail-job", { x: 1 }, { maxAttempts: 3 });
1003
1004
 
1004
1005
  // Wait until job is finally failed (3 attempts × ~exponential backoff = up to ~7s)
1005
- var deadline = Date.now() + 12000;
1006
1006
  var lastStatus;
1007
- while (Date.now() < deadline) {
1007
+ await helpers.waitUntil(function () {
1008
1008
  var row = b.db.prepare("SELECT status FROM _blamejs_jobs WHERE queueName = ?").get("fail-job");
1009
1009
  lastStatus = row && row.status;
1010
- if (lastStatus === "failed") break;
1011
- await new Promise(function (r) { setTimeout(r, 100); });
1012
- }
1010
+ return lastStatus === "failed";
1011
+ }, {
1012
+ timeoutMs: 12000,
1013
+ label: "queue: job reaches 'failed' status after maxAttempts",
1014
+ });
1013
1015
  check("job ends up in 'failed' status after maxAttempts", lastStatus === "failed");
1014
1016
  check("handler invoked maxAttempts times", attempts === 3);
1015
1017
 
@@ -1041,8 +1043,8 @@ async function testQueueLeaseExpiry() {
1041
1043
  check("after lease, job status is inflight",
1042
1044
  b.db.prepare("SELECT status FROM _blamejs_jobs WHERE queueName = ?").get("orphan-job").status === "inflight");
1043
1045
 
1044
- // Wait for lease to expire, then sweep
1045
- await new Promise(function (r) { setTimeout(r, 200); });
1046
+ // Let the 100ms lease lapse, then sweep
1047
+ await helpers.passiveObserve(200, "queue: 100ms lease lapses before sweepExpired");
1046
1048
  var swept = await localBackend.sweepExpired();
1047
1049
  check("sweepExpired returned 1 unstuck job", swept === 1);
1048
1050
  check("unstuck job is back to pending",
@@ -1067,7 +1069,14 @@ async function testQueueShutdown() {
1067
1069
  }, { concurrency: 2, pollIntervalMs: 30, fastPollMs: 10, leaseDurationMs: 5000 });
1068
1070
 
1069
1071
  for (var i = 0; i < 3; i++) await b.queue.enqueue("shutdown-job", { i: i });
1070
- await new Promise(function (r) { setTimeout(r, 100) }); // let some lease
1072
+ // Wait until the consumer leases at least one job (status inflight) so
1073
+ // shutdown has an in-flight handler to drain.
1074
+ await helpers.waitUntil(function () {
1075
+ return b.db.prepare("SELECT COUNT(*) AS n FROM _blamejs_jobs WHERE queueName = ? AND status = ?").get("shutdown-job", "inflight").n >= 1;
1076
+ }, {
1077
+ timeoutMs: 5000,
1078
+ label: "queue.shutdown: a job is leased (inflight) before shutdown",
1079
+ });
1071
1080
 
1072
1081
  var t0 = Date.now();
1073
1082
  await b.queue.shutdown({ timeoutMs: 5000 });
@@ -1107,10 +1116,10 @@ async function testJobsDefineAndEnqueue() {
1107
1116
  check("jobs.stats: started=true after start", jobs.stats().started === true);
1108
1117
 
1109
1118
  // Wait for the consumer to drain
1110
- var t0 = Date.now();
1111
- while (processed.length === 0 && Date.now() - t0 < 5000) {
1112
- await new Promise(function (r) { setTimeout(r, 50); });
1113
- }
1119
+ await helpers.waitUntil(function () { return processed.length >= 1; }, {
1120
+ timeoutMs: 5000,
1121
+ label: "jobs: handler ran for enqueued job",
1122
+ });
1114
1123
  check("jobs: handler ran for enqueued job", processed.length === 1 && processed[0] === "u-1");
1115
1124
 
1116
1125
  await jobs.shutdown({ timeoutMs: 2000 });
@@ -1184,10 +1193,10 @@ async function testJobsMultipleHandlers() {
1184
1193
  await jobs.enqueue("send-email", { to: "bob@example.com" });
1185
1194
  await jobs.enqueue("rebuild-index", { what: "users" });
1186
1195
 
1187
- var t0 = Date.now();
1188
- while ((emails.length < 2 || rebuilds.length < 1) && Date.now() - t0 < 5000) {
1189
- await new Promise(function (r) { setTimeout(r, 50); });
1190
- }
1196
+ await helpers.waitUntil(function () { return emails.length >= 2 && rebuilds.length >= 1; }, {
1197
+ timeoutMs: 5000,
1198
+ label: "jobs: both email handlers + rebuild handler ran",
1199
+ });
1191
1200
  check("jobs: both email handlers ran", emails.length === 2);
1192
1201
  check("jobs: rebuild handler ran", rebuilds.length === 1);
1193
1202
  // Cross-handler isolation
@@ -1222,8 +1231,8 @@ async function testLogStreamLocal() {
1222
1231
  b.logStream.warn("watch out", { password: "should-be-redacted" });
1223
1232
  b.logStream.error("kaboom", { jwt: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaaa.bbbb" });
1224
1233
 
1225
- // Allow async writes to complete
1226
- await new Promise(function (r) { setTimeout(r, 50); });
1234
+ // Let the fire-and-forget async writes settle before shutdown flushes
1235
+ await helpers.passiveObserve(50, "logStream.local: async writes settle before shutdown");
1227
1236
  await b.logStream.shutdown();
1228
1237
 
1229
1238
  var logPath = path.join(tmpDir, "logs", "blamejs.log");
@@ -1291,7 +1300,10 @@ async function testLogStreamWebhook() {
1291
1300
  b.logStream.info("first", { x: 1 });
1292
1301
  b.logStream.info("second", { x: 2 });
1293
1302
  // batchSize=2 should trigger immediate flush
1294
- await new Promise(function (r) { setTimeout(r, 200); });
1303
+ await helpers.waitUntil(function () { return received.length >= 2; }, {
1304
+ timeoutMs: 5000,
1305
+ label: "logStream.webhook: batch of 2 flushed to webhook",
1306
+ });
1295
1307
 
1296
1308
  check("webhook received 2 events", received.length === 2);
1297
1309
  check("first event message", received[0].message === "first");
@@ -1299,7 +1311,10 @@ async function testLogStreamWebhook() {
1299
1311
 
1300
1312
  // Auth failure path: send another event after server stops accepting
1301
1313
  b.logStream.info("third", { x: 3 });
1302
- await new Promise(function (r) { setTimeout(r, 200); });
1314
+ await helpers.waitUntil(function () { return received.length >= 3; }, {
1315
+ timeoutMs: 5000,
1316
+ label: "logStream.webhook: under-batch-size event flushed by maxBatchAgeMs",
1317
+ });
1303
1318
  check("third event delivered (under batch size, flushed by age)", received.length >= 3);
1304
1319
 
1305
1320
  await b.logStream.shutdown();
@@ -573,10 +573,8 @@ async function testCreateAppWithJobs() {
573
573
  });
574
574
  check("route enqueues + responds", posted.statusCode === 200);
575
575
 
576
- var t0 = Date.now();
577
- while (processed.length === 0 && Date.now() - t0 < 5000) {
578
- await new Promise(function (r) { setTimeout(r, 50); });
579
- }
576
+ await helpers.waitUntil(function () { return processed.length >= 1; },
577
+ { timeoutMs: 5000, label: "createApp+jobs: route-enqueued job processed" });
580
578
  check("createApp+jobs: handler fired for the route-enqueued job",
581
579
  processed.length === 1 && processed[0] === "u-from-route");
582
580
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.106",
3
+ "version": "0.4.107",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {