@blamejs/blamejs-shop 0.4.88 → 0.4.90

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.
@@ -89,6 +89,12 @@ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
89
89
  var MAX_BULK_IDS = 500;
90
90
  var MS_PER_DAY = C.TIME.days(1);
91
91
 
92
+ var SHA3_512_HEX_LEN = 128;
93
+ // Genesis anchor for a customer's first ledger row. Each customer wallet
94
+ // is its own independent chain — the first row links to ZERO, every
95
+ // subsequent row links to the prior row's row_hash for the SAME customer.
96
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
97
+
92
98
  // ---- validators ---------------------------------------------------------
93
99
 
94
100
  function _uuid(s, label) {
@@ -137,6 +143,39 @@ function _epochMs(ts, label) {
137
143
 
138
144
  function _now() { return Date.now(); }
139
145
 
146
+ // ---- chain hashing ------------------------------------------------------
147
+ //
148
+ // row_hash = SHA3-512(prev_hash || canonical-json(row-fields)), mirroring
149
+ // shop.giftCardLedger / operator-audit-log. `b.auditChain.canonicalize`
150
+ // (RFC 8785 walker, sorted keys, hash columns excluded) makes the preimage
151
+ // byte-identical across deployments + node versions. Every persisted column
152
+ // except the two hash columns participates, normalized so the write side
153
+ // and `verifyChain` produce identical preimages for the same logical row.
154
+
155
+ function _rowFieldsForHash(row) {
156
+ return {
157
+ id: row.id,
158
+ customer_id: row.customer_id,
159
+ kind: row.kind,
160
+ amount_minor: Number(row.amount_minor),
161
+ source: row.source == null ? null : row.source,
162
+ source_ref: row.source_ref == null ? null : row.source_ref,
163
+ order_id: row.order_id == null ? null : row.order_id,
164
+ balance_after_minor: Number(row.balance_after_minor),
165
+ expires_at: row.expires_at == null ? null : Number(row.expires_at),
166
+ occurred_at: Number(row.occurred_at),
167
+ };
168
+ }
169
+
170
+ function _computeRowHash(prevHash, rowFields) {
171
+ var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
172
+ var preimage = Buffer.concat([
173
+ Buffer.from(prevHash, "hex"),
174
+ Buffer.from(canonical, "utf8"),
175
+ ]);
176
+ return b.crypto.sha3Hash(preimage);
177
+ }
178
+
140
179
  // ---- factory ------------------------------------------------------------
141
180
 
142
181
  function create(opts) {
@@ -151,16 +190,24 @@ function create(opts) {
151
190
  // aggregation at read time. Falls through to 0 when no rows exist
152
191
  // (a customer that has never had a ledger row has zero credit). The id
153
192
  // tie-break keeps any legacy same-millisecond rows deterministic; new
154
- // writes can't tie — _writeRowAtomic computes a strictly-monotonic
155
- // per-customer occurred_at inside the INSERT itself.
193
+ // writes can't tie — _resolveOccurredAt bumps a colliding timestamp to
194
+ // prior+1 app-side before the fenced INSERT. Also returns the tip's
195
+ // row_hash so the write path can chain off it (see _attemptChainedWrite).
156
196
  async function _readLatest(customerId) {
157
197
  var r = await query(
158
- "SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
198
+ "SELECT balance_after_minor, occurred_at, row_hash FROM store_credit_ledger " +
159
199
  "WHERE customer_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
160
200
  [customerId],
161
201
  );
162
- if (!r.rows.length) return { balance: 0, occurred_at: null };
163
- return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
202
+ if (!r.rows.length) return { balance: 0, occurred_at: null, row_hash: ZERO_HASH };
203
+ return {
204
+ balance: r.rows[0].balance_after_minor,
205
+ occurred_at: r.rows[0].occurred_at,
206
+ // A legacy pre-chain row carries NULL row_hash — chain forward from
207
+ // ZERO so the first hashed row anchors the chain; verifyChain treats
208
+ // the unhashed legacy prefix as unverifiable rather than trusting it.
209
+ row_hash: r.rows[0].row_hash == null ? ZERO_HASH : r.rows[0].row_hash,
210
+ };
164
211
  }
165
212
 
166
213
  async function _currentBalance(customerId) {
@@ -168,84 +215,101 @@ function create(opts) {
168
215
  return latest.balance;
169
216
  }
170
217
 
171
- // Single-statement guarded write the concurrency spine of the wallet.
172
- // The live balance AND the strictly-monotonic per-customer occurred_at
173
- // are computed by correlated subqueries INSIDE the INSERT, so two
174
- // concurrent writes can never base off the same stale snapshot: the
175
- // statements serialize at the database and the second sees the first's
176
- // row. (A JS-side read-then-write here double-fulfilled concurrent
177
- // debits and silently dropped one of two same-millisecond credits.)
178
- // `kind` selects the guard + arithmetic:
179
- // credit — balance_after = live + amount, no balance gate (the in-SQL
180
- // occurred_at is what closes the same-millisecond tie);
181
- // debit — balance_after = live - amount, gated on live >= amount
182
- // (zero rows = insufficient, or lost the race same refusal);
183
- // expire burns MIN(amount, live), gated on live > 0 (zero rows =
184
- // wallet already empty; callers degrade gracefullyby
185
- // design, never a throw).
186
- // sweep the scheduled expiry burn. `amount` carries the
187
- // customer's expired-credit total; the still-unburned
188
- // remainder (total minus the live SUM of prior
189
- // SWEEP_SOURCE_REF expire rows) is recomputed INSIDE the
190
- // insert and capped at the live balance, so two concurrent
191
- // sweeps can't both burn the same expired pool. The second
192
- // sweep serializes behind the first's committed row, sees
193
- // zero pending, and matches no rows never burning the
194
- // still-valid (non-expired) credit left in the wallet.
195
- // Stored as an `expire` row stamped SWEEP_SOURCE_REF.
196
- // Returns the written row's resolved values, or null when the guard
197
- // refused the write.
198
- async function _writeRowAtomic(kind, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs) {
218
+ // Strictly-monotonic per-customer occurred_at: a write at or before the
219
+ // prior row's timestamp is bumped to prior+1, so the denormalized
220
+ // balance_after snapshot is unambiguous on read. A backdated operator
221
+ // write still lands at the requested time when there's no collision.
222
+ function _resolveOccurredAt(requestedTs, latestTs) {
223
+ if (latestTs == null) return requestedTs;
224
+ if (requestedTs > latestTs) return requestedTs;
225
+ return latestTs + 1;
226
+ }
227
+
228
+ // How many times a writer re-derives the chain tip when the parent fence
229
+ // refuses its INSERT (another write landed first). Each fence round lets
230
+ // exactly ONE racing writer win, so a burst of N concurrent writes to the
231
+ // SAME customer needs up to N rounds the cap is sized well beyond any
232
+ // realistic same-wallet fan-in (an admin grant + the scheduled expiry
233
+ // sweep + a checkout debit racing at once) so a legitimate burst is never
234
+ // dropped with STORE_CREDIT_CONTENTION. A genuine non-collision insert
235
+ // error re-throws on the first attempt (see _attemptChainedWrite), so a
236
+ // high cap never spins on a real failure.
237
+ var CHAIN_WRITE_ATTEMPTS = 64;
238
+
239
+ // One fenced write against the tip the caller just read. The live balance
240
+ // AND the strictly-monotonic occurred_at are computed APP-SIDE and bound
241
+ // explicitly, together with prev_hash + row_hash so every row participates
242
+ // in the per-customer hash chain. What makes the app-computed values safe
243
+ // is the chain-parent fence UNIQUE(customer_id, prev_hash) (migration
244
+ // 0236): a write derived from a STALE tip collides at the constraint
245
+ // instead of forking the chain or persisting a balance computed off a
246
+ // stale snapshot — the caller re-reads and retries. The debit overdraft
247
+ // gate stays INSIDE the statement (the correlated subquery, never a
248
+ // JS-side check): with the fence it is defense-in-depth, and it is what
249
+ // turns a genuine overdraft into a zero-row refusal rather than a retry
250
+ // loop.
251
+ //
252
+ // Collision detection is STATE-AGNOSTIC, never error-message matching:
253
+ // the production D1 service-binding redacts the SQLite "UNIQUE constraint
254
+ // failed" text to a generic "HTTP 500", so on ANY insert error we re-read
255
+ // the tip — if it advanced past the parent we tried to chain off, a
256
+ // competing write claimed our slot (a genuine fence collision) and we
257
+ // retry; if the tip is unchanged the insert failed for another reason, so
258
+ // we re-throw. Returns one of { written }, { refused } (the guard said no
259
+ // against the live tip), or { collided } (tip moved — retry).
260
+ async function _attemptChainedWrite(customerId, kind, latest, d) {
199
261
  var id = b.uuid.v7();
200
262
  var storedKind = kind === "sweep" ? "expire" : kind;
263
+ var rowHash = _computeRowHash(latest.row_hash, _rowFieldsForHash({
264
+ id: id,
265
+ customer_id: customerId,
266
+ kind: storedKind,
267
+ amount_minor: d.amount,
268
+ source: d.source,
269
+ source_ref: d.sourceRef,
270
+ order_id: d.orderId,
271
+ balance_after_minor: d.after,
272
+ expires_at: d.expiresAt,
273
+ occurred_at: d.ts,
274
+ }));
201
275
  var balSub = "COALESCE((SELECT balance_after_minor FROM store_credit_ledger " +
202
276
  "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
203
- var tsSub = "COALESCE((SELECT occurred_at FROM store_credit_ledger " +
204
- "WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
205
- var tsExpr = "CASE WHEN ?8 > " + tsSub + " THEN ?8 ELSE " + tsSub + " + 1 END";
206
- var amountExpr, afterExpr, guard;
207
- if (kind === "credit") {
208
- amountExpr = "?3";
209
- afterExpr = balSub + " + ?3";
210
- guard = "1";
211
- } else if (kind === "debit") {
212
- amountExpr = "?3";
213
- afterExpr = balSub + " - ?3";
214
- guard = balSub + " >= ?3";
215
- } else if (kind === "sweep") {
216
- // `?3` is this customer's expired-credit total. Subtract the live
217
- // sum of prior sweep-stamped expire rows to get the still-pending
218
- // burn, then cap at the live balance — both recomputed at INSERT
219
- // time so a racing second sweep sees the first's committed row.
220
- var sweptSub = "COALESCE((SELECT SUM(amount_minor) FROM store_credit_ledger " +
221
- "WHERE customer_id = ?2 AND kind = 'expire' AND source_ref = ?5), 0)";
222
- var pending = "(?3 - " + sweptSub + ")";
223
- amountExpr = "CASE WHEN " + balSub + " < " + pending + " THEN " + balSub + " ELSE " + pending + " END";
224
- afterExpr = "CASE WHEN " + balSub + " < " + pending + " THEN 0 ELSE " + balSub + " - " + pending + " END";
225
- guard = pending + " > 0 AND " + balSub + " > 0";
226
- } else { // expire — burn MIN(amount, live balance)
227
- amountExpr = "CASE WHEN " + balSub + " < ?3 THEN " + balSub + " ELSE ?3 END";
228
- afterExpr = "CASE WHEN " + balSub + " < ?3 THEN 0 ELSE " + balSub + " - ?3 END";
229
- guard = balSub + " > 0";
277
+ try {
278
+ var res = await query(
279
+ "INSERT INTO store_credit_ledger " +
280
+ "(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at, prev_hash, row_hash) " +
281
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12 " +
282
+ "WHERE " + (d.guarded ? balSub + " >= ?4" : "1"),
283
+ [id, customerId, storedKind, d.amount, d.source, d.sourceRef, d.orderId, d.after, d.expiresAt, d.ts, latest.row_hash, rowHash],
284
+ );
285
+ if (Number(res.rowCount || 0) === 0) return { refused: true };
286
+ return { written: { id: id, amount_minor: d.amount, balance_after_minor: d.after, occurred_at: d.ts } };
287
+ } catch (e) {
288
+ var after = await _readLatest(customerId);
289
+ if (after.row_hash !== latest.row_hash) return { collided: true };
290
+ throw e;
230
291
  }
231
- var res = await query(
232
- "INSERT INTO store_credit_ledger " +
233
- "(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
234
- "SELECT ?1, ?2, ?9, " + amountExpr + ", ?4, ?5, ?6, " + afterExpr + ", ?7, " + tsExpr + " " +
235
- "WHERE " + guard,
236
- [id, customerId, amount, source, sourceRef, orderId, expiresAt, requestedTs, storedKind],
237
- );
238
- if (Number(res.rowCount || 0) === 0) return null;
239
- var row = (await query(
240
- "SELECT amount_minor, balance_after_minor, occurred_at FROM store_credit_ledger WHERE id = ?1",
241
- [id],
242
- )).rows[0];
243
- return {
244
- id: id,
245
- amount_minor: row.amount_minor,
246
- balance_after_minor: row.balance_after_minor,
247
- occurred_at: row.occurred_at,
248
- };
292
+ }
293
+
294
+ // Run a chained write with bounded tip-contention retries. `derive` maps
295
+ // the freshly-read tip to the attempt's concrete fields (it may be async
296
+ // the sweep reads its prior-burn sum inside), or returns { noop: true }
297
+ // for a write that degrades gracefully on an empty / already-settled
298
+ // wallet. Each fence collision re-reads the tip and re-derives, so the
299
+ // values that land are always consistent with the parent they chain off.
300
+ async function _writeChained(customerId, kind, derive) {
301
+ for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
302
+ var latest = await _readLatest(customerId);
303
+ var d = await derive(latest);
304
+ if (d.noop) return { noop: true, balance: latest.balance };
305
+ var r = await _attemptChainedWrite(customerId, kind, latest, d);
306
+ if (r.collided) continue;
307
+ if (r.refused) return { refused: true };
308
+ return r.written;
309
+ }
310
+ var contention = new Error("storeCredit." + kind + ": persistent chain-tip contention — retry the write");
311
+ contention.code = "STORE_CREDIT_CONTENTION";
312
+ throw contention;
249
313
  }
250
314
 
251
315
  return {
@@ -264,7 +328,18 @@ function create(opts) {
264
328
  var requested = _epochMs(input.occurred_at, "occurred_at");
265
329
  if (requested == null) requested = _now();
266
330
 
267
- var w = await _writeRowAtomic("credit", customerId, amount, source, sourceRef, null, expiresAt, requested);
331
+ var w = await _writeChained(customerId, "credit", function (latest) {
332
+ return {
333
+ amount: amount,
334
+ source: source,
335
+ sourceRef: sourceRef,
336
+ orderId: null,
337
+ expiresAt: expiresAt,
338
+ after: latest.balance + amount,
339
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
340
+ guarded: false,
341
+ };
342
+ });
268
343
 
269
344
  return {
270
345
  id: w.id,
@@ -289,11 +364,23 @@ function create(opts) {
289
364
  var requested = _epochMs(input.occurred_at, "occurred_at");
290
365
  if (requested == null) requested = _now();
291
366
 
292
- // The balance gate lives INSIDE the insert a refused write covers
293
- // both "always insufficient" and "a concurrent debit drained it
294
- // first", with no window between check and write.
295
- var w = await _writeRowAtomic("debit", customerId, amount, null, null, orderId, null, requested);
296
- if (!w) {
367
+ // The balance gate lives INSIDE the insert (defense-in-depth behind
368
+ // the chain-parent fence) a refused write covers both "always
369
+ // insufficient" and "a concurrent debit drained it first", with no
370
+ // window between check and write.
371
+ var w = await _writeChained(customerId, "debit", function (latest) {
372
+ return {
373
+ amount: amount,
374
+ source: null,
375
+ sourceRef: null,
376
+ orderId: orderId,
377
+ expiresAt: null,
378
+ after: latest.balance - amount,
379
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
380
+ guarded: true,
381
+ };
382
+ });
383
+ if (w.refused) {
297
384
  var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
298
385
  insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
299
386
  throw insufficient;
@@ -334,8 +421,24 @@ function create(opts) {
334
421
  // already at zero) is the structured no-op below, never a throw —
335
422
  // by design: a no-op expire is a valid outcome of a bulk sweep,
336
423
  // and a zero-amount row would violate CHECK(amount_minor > 0).
337
- var w = await _writeRowAtomic("expire", customerId, amount, null, reason, null, null, requested);
338
- if (!w) {
424
+ var w = await _writeChained(customerId, "expire", function (latest) {
425
+ var burn = amount > latest.balance ? latest.balance : amount;
426
+ // An empty wallet is the structured no-op below, never a throw —
427
+ // a zero-amount row would violate CHECK(amount_minor > 0), and a
428
+ // no-op expire is a valid outcome of a bulk sweep.
429
+ if (burn === 0) return { noop: true };
430
+ return {
431
+ amount: burn,
432
+ source: null,
433
+ sourceRef: reason,
434
+ orderId: null,
435
+ expiresAt: null,
436
+ after: latest.balance - burn,
437
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
438
+ guarded: false,
439
+ };
440
+ });
441
+ if (w.noop) {
339
442
  return {
340
443
  id: null,
341
444
  customer_id: customerId,
@@ -343,7 +446,7 @@ function create(opts) {
343
446
  amount_minor: 0,
344
447
  requested_minor: amount,
345
448
  reason: reason,
346
- balance_after_minor: await _currentBalance(customerId),
449
+ balance_after_minor: w.balance,
347
450
  occurred_at: requested,
348
451
  noop: true,
349
452
  };
@@ -368,6 +471,124 @@ function create(opts) {
368
471
  return { customer_id: customerId, balance_minor: bal };
369
472
  },
370
473
 
474
+ // Recompute one customer's hash chain and flag the first divergence —
475
+ // the tamper-evidence READ side of the chain the writers maintain.
476
+ // Walks the wallet's rows in (occurred_at, id) order; rows from before
477
+ // the chain columns existed (NULL row_hash) are tolerated as an
478
+ // unverifiable legacy PREFIX, but once a hashed row anchors, every
479
+ // later row must link — an unhashed row after the anchor, a prev_hash
480
+ // that doesn't name its parent, or a row_hash that doesn't recompute is
481
+ // a break. A populated ledger that never anchors (every row NULL-hashed
482
+ // — the shape a full-ledger rewrite leaves behind) fails as unanchored;
483
+ // an empty ledger is the only no-anchor case that passes. O(n) per
484
+ // customer; operator-audit use, not hot-path.
485
+ //
486
+ // Optional trusted anchor (opts.anchor = { count, head }) — the ONLY
487
+ // way to detect a TAIL TRUNCATION (deleting the most-recent rows) or a
488
+ // whole-chain replacement, since an append-only chain stays internally
489
+ // consistent after its tail is removed (a deleted latest debit makes
490
+ // balance() revert while the remaining prefix still links). The caller
491
+ // supplies a count + head captured from a trusted prior snapshot of this
492
+ // same result (count = total_rows, head = last_hash) — a periodic
493
+ // operator checkpoint or an out-of-band monitor. The chain row at index
494
+ // count-1 must still carry that head hash, so a truncation BELOW the
495
+ // snapshot is caught even after later valid appends. Without an anchor
496
+ // this verifies INTERNAL consistency only and reports
497
+ // anchor_checked:false so the caller knows truncation was not ruled out
498
+ // (operator-audit-log closes the same gap with signed checkpoints).
499
+ verifyChain: async function (customerId, opts) {
500
+ _uuid(customerId, "customer_id");
501
+ opts = opts || {};
502
+ var anchor = null;
503
+ if (opts.anchor != null) {
504
+ if (typeof opts.anchor !== "object"
505
+ || typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
506
+ || typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
507
+ throw new TypeError("storeCredit.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
508
+ }
509
+ anchor = opts.anchor;
510
+ }
511
+ var r = await query(
512
+ "SELECT * FROM store_credit_ledger WHERE customer_id = ?1 " +
513
+ "ORDER BY occurred_at ASC, id ASC",
514
+ [customerId],
515
+ );
516
+ var rows = r.rows;
517
+ var legacyPrefix = 0;
518
+ var anchored = false;
519
+ var prevHash = ZERO_HASH;
520
+ for (var i = 0; i < rows.length; i += 1) {
521
+ var row = rows[i];
522
+ if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
523
+ anchored = true;
524
+ var breakBase = {
525
+ ok: false,
526
+ rows_verified: i - legacyPrefix,
527
+ legacy_prefix: legacyPrefix,
528
+ break_at: i,
529
+ break_row_id: row.id,
530
+ };
531
+ if (row.row_hash == null) {
532
+ return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
533
+ }
534
+ if (row.prev_hash !== prevHash) {
535
+ return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
536
+ }
537
+ var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
538
+ if (computed !== row.row_hash) {
539
+ return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
540
+ }
541
+ prevHash = row.row_hash;
542
+ }
543
+ if (!anchored && rows.length > 0) {
544
+ return {
545
+ ok: false,
546
+ rows_verified: 0,
547
+ legacy_prefix: legacyPrefix,
548
+ break_at: 0,
549
+ break_row_id: rows[0].id,
550
+ reason: "unanchored chain (no hashed row in a populated ledger)",
551
+ };
552
+ }
553
+ // Internal walk is clean. With a trusted anchor, also rule out a tail
554
+ // truncation below the snapshot: the row at the snapshot's position
555
+ // must still carry the snapshot's head hash (it survives later valid
556
+ // appends), and the chain must not be shorter than the snapshot.
557
+ if (anchor) {
558
+ if (rows.length < anchor.count) {
559
+ return {
560
+ ok: false,
561
+ rows_verified: rows.length - legacyPrefix,
562
+ legacy_prefix: legacyPrefix,
563
+ anchor_checked: true,
564
+ reason: "row count below anchor (possible tail truncation)",
565
+ expected_count: anchor.count,
566
+ actual_count: rows.length,
567
+ };
568
+ }
569
+ var anchorRow = rows[anchor.count - 1];
570
+ if (!anchorRow || anchorRow.row_hash !== anchor.head) {
571
+ return {
572
+ ok: false,
573
+ rows_verified: rows.length - legacyPrefix,
574
+ legacy_prefix: legacyPrefix,
575
+ anchor_checked: true,
576
+ reason: "anchor row hash mismatch (possible tail truncation or divergence)",
577
+ expected: anchor.head,
578
+ actual: anchorRow ? anchorRow.row_hash : null,
579
+ };
580
+ }
581
+ }
582
+ return {
583
+ ok: true,
584
+ rows_verified: rows.length - legacyPrefix,
585
+ legacy_prefix: legacyPrefix,
586
+ total_rows: rows.length,
587
+ last_hash: prevHash,
588
+ anchor_checked: anchor != null,
589
+ };
590
+ },
591
+
371
592
  history: async function (input) {
372
593
  if (!input || typeof input !== "object") {
373
594
  throw new TypeError("storeCredit.history: input object required");
@@ -639,8 +860,38 @@ function create(opts) {
639
860
  // expired credits are "first-out" from the operator's POV — the
640
861
  // schema doesn't track FIFO at row level, so the audit trail
641
862
  // reflects what was actually burned.
642
- var w = await _writeRowAtomic("sweep", customerId, expiredTotal, null, SWEEP_SOURCE_REF, null, null, now);
643
- if (!w) continue;
863
+ var w = await _writeChained(customerId, "sweep", async function (latest) {
864
+ // Pending burn = this customer's expired-credit total minus the
865
+ // sweep's OWN prior output (expire rows stamped SWEEP_SOURCE_REF),
866
+ // capped at the live balance. The prior-burn sum is read INSIDE
867
+ // the derive so a fence retry recomputes it against the committed
868
+ // tip: a racing second sweep collides, re-reads a now-nonzero
869
+ // swept sum, sees zero pending, and no-ops — never burning the
870
+ // still-valid (non-expired) credit the first sweep left behind.
871
+ // Operator expires + debits already reduced the balance; the
872
+ // balance cap is what keeps the sweep from over-burning a partly
873
+ // drained wallet, so they must not also be netted out of the
874
+ // expired pool (only this sweep's own output is).
875
+ var sweptRow = (await query(
876
+ "SELECT COALESCE(SUM(amount_minor), 0) AS swept FROM store_credit_ledger " +
877
+ "WHERE customer_id = ?1 AND kind = 'expire' AND source_ref = ?2",
878
+ [customerId, SWEEP_SOURCE_REF],
879
+ )).rows[0];
880
+ var pending = expiredTotal - Number(sweptRow.swept || 0);
881
+ if (pending <= 0 || latest.balance <= 0) return { noop: true };
882
+ var burn = pending > latest.balance ? latest.balance : pending;
883
+ return {
884
+ amount: burn,
885
+ source: null,
886
+ sourceRef: SWEEP_SOURCE_REF,
887
+ orderId: null,
888
+ expiresAt: null,
889
+ after: latest.balance - burn,
890
+ ts: _resolveOccurredAt(now, latest.occurred_at),
891
+ guarded: false,
892
+ };
893
+ });
894
+ if (!w || w.noop || w.refused) continue;
644
895
  processed.push({
645
896
  id: w.id,
646
897
  customer_id: customerId,
@@ -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.19",
7
- "tag": "v0.15.19",
6
+ "version": "0.15.20",
7
+ "tag": "v0.15.20",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -841,6 +841,7 @@
841
841
  "release-notes/v0.15.18.json": "lib/vendor/blamejs/release-notes/v0.15.18.json",
842
842
  "release-notes/v0.15.19.json": "lib/vendor/blamejs/release-notes/v0.15.19.json",
843
843
  "release-notes/v0.15.2.json": "lib/vendor/blamejs/release-notes/v0.15.2.json",
844
+ "release-notes/v0.15.20.json": "lib/vendor/blamejs/release-notes/v0.15.20.json",
844
845
  "release-notes/v0.15.3.json": "lib/vendor/blamejs/release-notes/v0.15.3.json",
845
846
  "release-notes/v0.15.4.json": "lib/vendor/blamejs/release-notes/v0.15.4.json",
846
847
  "release-notes/v0.15.5.json": "lib/vendor/blamejs/release-notes/v0.15.5.json",
@@ -1547,7 +1548,7 @@
1547
1548
  ".npmrc": "sha256:66f104e7d07c496d2d0409988225e8c0e4ceb8d247dbcac3be75b2128d20ce66",
1548
1549
  ".pinact.yaml": "sha256:0213ffda55961dc49b64c0a5dfa3c0567419633b1499d57eaf7c8d842d7da6c7",
1549
1550
  "ARCHITECTURE.md": "sha256:9b1c8d2b1b7a41838eb348b0a008e4b4369718fd72bfe2974b37155f7536d35b",
1550
- "CHANGELOG.md": "sha256:ebd9e3a79a0dce5518430a52c9c6e24cbf7bad0406bbed2c4e7ad5eb1ab297c6",
1551
+ "CHANGELOG.md": "sha256:4642c9fe7ab6aacfbf5b95e3780834b6b1fb4f485139571171d8a30eb702cde7",
1551
1552
  "CODE_OF_CONDUCT.md": "sha256:148a281960fff7c2fe6554dab66da572c72245ddeb00b0d14811558397bff386",
1552
1553
  "CONTRIBUTING.md": "sha256:bb4dbdbc8598da31dbce653a8ed322e08ff46560173f2eb67a4d684653948332",
1553
1554
  "GOVERNANCE.md": "sha256:906df6afb1f552b27b9acb50f7f96c47b917a2f1021cd4e987dbf4ee0e0a821b",
@@ -1557,7 +1558,7 @@
1557
1558
  "NOTICE": "sha256:f487fa47a11aca0f89e2615cdd3c713e9842abf7a30d8d328eeeae1c864aa774",
1558
1559
  "README.md": "sha256:3ddcc197b003da0b02db8bdd1aef1e943c94f7eab613c633d6a45bb11d0a80e9",
1559
1560
  "SECURITY.md": "sha256:23f7ee4a44f21e433ed1d3c6f414575eb3e30f66a328422973a1109a276c537b",
1560
- "api-snapshot.json": "sha256:719da0f2483fb5672ee09e398f4d85304c875f6f9df96f21ad32cdc3aa1cc3b5",
1561
+ "api-snapshot.json": "sha256:7ca3b73f197b253072a2210417668acf336bb36a8407556e7e3663b0b1543aa9",
1561
1562
  "assets/BlameJS_Logo.png": "sha256:3c65699753c771b48ef9ac7f45bb40815ec19a23afcdd0cd30ef4601bbbe293e",
1562
1563
  "assets/BlameJS_Logo.svg": "sha256:dda44f3fb1343d5de9db6b1fcdb75fc649c57e7a99a8e8239fcf852e3841e1a8",
1563
1564
  "bench/README.md": "sha256:74202f2507fd840bfc1ac6c681975d9273cf36cca6e0f72655f138337304033c",
@@ -2298,7 +2299,7 @@
2298
2299
  "lib/vc.js": "sha256:554cf29ea712b231256e1eaa75f6b1f1d39c392aac86b33e818d52e956ea83ae",
2299
2300
  "lib/vendor-data.js": "sha256:ddb77b33d89a2c5345deca98eacb45695d4c8a70014bc5df22c7393f52e11416",
2300
2301
  "lib/vendor/.vendor-data-pubkey": "sha256:73a935b8c72f55d821c22ddb8188453bab324f80d229d033a0a1549819a1f096",
2301
- "lib/vendor/MANIFEST.json": "sha256:09194d50f99999485fb525da118afd815c475be0ef4f6e58fbc3f41ffd91d1dd",
2302
+ "lib/vendor/MANIFEST.json": "sha256:c2b9e40fb993db0f92c49154a5ac078e01324ccd02a19985e38186da06dd7338",
2302
2303
  "lib/vendor/bimi-trust-anchors.data.js": "sha256:aa7a4d33b65a68422a2a2c1670177689f66fdcaa08bd2514d78798b827bd1608",
2303
2304
  "lib/vendor/bimi-trust-anchors.pem": "sha256:81ff9f5ab3c9774132c845684e783be95cf73146f8b670d964105f0a3765b4b4",
2304
2305
  "lib/vendor/common-passwords-top-10000.data.js": "sha256:87b223beca89f33d2c2c32a2cfda0bc187e58061de40e7127bb5ffc4258c6e2a",
@@ -2307,9 +2308,9 @@
2307
2308
  "lib/vendor/noble-curves.cjs": "sha256:ebf254d5eb56aef8705a1c4af9603f47987b4870a9bb5e657e06907b701e2731",
2308
2309
  "lib/vendor/noble-post-quantum.cjs": "sha256:f9190309daadca4c2e2cc2b76beaa6b96e463429cc3c390bd9f0ceaf7b588c68",
2309
2310
  "lib/vendor/pki.cjs": "sha256:9bbc191afaaa2b1e5757f00480457c08134cdc2c55d541df18d9155bba9cbf77",
2310
- "lib/vendor/public-suffix-list.dat": "sha256:0adddeb62057d8d40799dffb29fe14f65dd009259afe02eb2f0b4602b791aae6",
2311
- "lib/vendor/public-suffix-list.data.js": "sha256:82af512cacf0fd2c60925e63f877b69477b1b2f7bb5af698fd862af61369902e",
2312
- "lib/vendor/simplewebauthn-server.cjs": "sha256:f359a782ac57e3ff56ac71083d17f5c082f88ab49d645fc2bede398b47adebdb",
2311
+ "lib/vendor/public-suffix-list.dat": "sha256:42c4f3544726fa2feb4f304c889c5e391feb5d28080c9b6356ff82e267a93706",
2312
+ "lib/vendor/public-suffix-list.data.js": "sha256:c840d9ae5c6bf4a07967340a5c649df2c6a66a287db41e408461c46cc32348e6",
2313
+ "lib/vendor/simplewebauthn-server.cjs": "sha256:49411d893f5e9b0e2fcaa564b4ec7921f73a9a06229b5e53d49c1453ea1a365c",
2313
2314
  "lib/vendor/vendor-data-pubkey.js": "sha256:a12afa34cd7472e2eaebad2fcd44714102d3edd0601e45769404124a513926d0",
2314
2315
  "lib/vex.js": "sha256:b45c1c9729dfc69f2140478d46eec91f47da94b5f440be36fa825cb733a718fc",
2315
2316
  "lib/watcher.js": "sha256:8618da919affabbe4c4d33915647b3ee4b27e6ea091638f032d4bfead797baf8",
@@ -2328,7 +2329,7 @@
2328
2329
  "oss-fuzz/projects/blamejs/README.md": "sha256:ae13b7bb79ed8d69b1b3276e5562807a0349fb6e6b7d11cf1f683aad1eafdb4b",
2329
2330
  "oss-fuzz/projects/blamejs/build.sh": "sha256:0ced1cf21782c97be7f8d74faf5e27a308b60b2f858836fb5ca3b8c4e939a8f7",
2330
2331
  "oss-fuzz/projects/blamejs/project.yaml": "sha256:59f2cb83aa622325a175b77416fe155be15b70a9c798bd1a78bba05763b1b03d",
2331
- "package.json": "sha256:c625373887cfae872dde0fca9ea6955278d6fc721128c04bbd01a31986b9d409",
2332
+ "package.json": "sha256:7409a04e0673a297045ce2248e196cb4f856f2a037af73e837b0264b76984071",
2332
2333
  "release-notes/v0.0.x.json": "sha256:7a49819f30068ee119000cad7010194882bb8bfaa12acbdab4dfc066efb7982f",
2333
2334
  "release-notes/v0.1.x.json": "sha256:6742a8c17f947c5cb76f69dead7eea86b942d80621d914b774ba5488e09937e5",
2334
2335
  "release-notes/v0.10.x.json": "sha256:fe498045daf88337bd3d987e5964aa42c99a50e1685b6f09e51f698b8687726f",
@@ -2349,6 +2350,7 @@
2349
2350
  "release-notes/v0.15.18.json": "sha256:c8b3ce7af0f7863cdc3f5179f0525469331720a80b741b73236fdd6f4243bbaa",
2350
2351
  "release-notes/v0.15.19.json": "sha256:8c6a745accd2c551953464991bb23547e8cf87286d57938c57080755785e58b5",
2351
2352
  "release-notes/v0.15.2.json": "sha256:36e1423dda94ed4e55c660e0fae882019005d8de3ee5e3b6ac4f38ccf8e744a2",
2353
+ "release-notes/v0.15.20.json": "sha256:bc469d136c909f94d89d2816a5a4c7b08ae93380d812e837d4f1c554eae1ce2b",
2352
2354
  "release-notes/v0.15.3.json": "sha256:19a0074c445545468ca3cc411b21ec8bdb27be2669ae1950347cc244f6aa348c",
2353
2355
  "release-notes/v0.15.4.json": "sha256:6ac7fa0ef1728c27e71b2050d1b07a810f9b4b1440ccddbf28ad56e2f54d8585",
2354
2356
  "release-notes/v0.15.5.json": "sha256:cca1d0edd5d6fc41b512d19d98be224b990dcab41478622c11962f0fcb1bb09a",
@@ -2387,7 +2389,7 @@
2387
2389
  "scripts/validate-source-comment-blocks.js": "sha256:f3a8d9ef0dcf9612a694d5c005c8c05b3ffbd7e92a14a8efb4aa97f054528f7f",
2388
2390
  "scripts/vendor-data-gen.js": "sha256:76b627bc6e19b4a122edfca6f514bcb8ca11af02902f0957e641f503337a8a0f",
2389
2391
  "scripts/vendor-data-keygen.js": "sha256:94eaa4d8f832b4aac9ccbcb2a07e6b99cd35cf7b044e1412079cebdefc1f4c0e",
2390
- "scripts/vendor-update.sh": "sha256:c1c879ee620f064a06d776c1d330749b5128a35581352ef385fa8baf4a35f79a",
2392
+ "scripts/vendor-update.sh": "sha256:373d7f024b7caa3345a3598fa3a586078dd842f0071e6fdad00a473f48a3a929",
2391
2393
  "test/00-primitives.js": "sha256:64f3a0ec3678f8b3ab401ed59cdc1139f4e8720f5ed61443a718d8f480f64d5b",
2392
2394
  "test/10-state.js": "sha256:7f286e00fda002ccf50ccc59d658b6dce9a4bccee304232984a932a2ceb31c49",
2393
2395
  "test/20-db.js": "sha256:241ef6b7ef305d077aeafb22ee3bcc75b6b549a8fa9b1a6b5d6d5fba43b48d7d",
@@ -3002,7 +3004,7 @@
3002
3004
  "test/layer-0-primitives/vc.test.js": "sha256:504fadbce3c8210c7ecb74e927686bdec03d7581b1dae4150c2ebefc326c182b",
3003
3005
  "test/layer-0-primitives/vendor-currency-classify.test.js": "sha256:a56c08cce24a600fa827756d220159365a2c3cc30428ade49df97b19466125a6",
3004
3006
  "test/layer-0-primitives/vendor-data.test.js": "sha256:e216ae83b187c66f1002c419e4af2701573e5743666007b564a0257332bc0c9b",
3005
- "test/layer-0-primitives/vendor-manifest.test.js": "sha256:55685bd11686bf76495ec29769470de05a6aaf4648d2b5722d96f213d9e32587",
3007
+ "test/layer-0-primitives/vendor-manifest.test.js": "sha256:494f89868468618592390854903f301b4d675c1b46c11fe5117e475195731c4d",
3006
3008
  "test/layer-0-primitives/vex.test.js": "sha256:4a06e3e9a6ea8ffe3ab2c0af69cbe2835c1d50768c184308bb2e40638c8998cc",
3007
3009
  "test/layer-0-primitives/watcher.test.js": "sha256:346ef70ff89e9d4b6ba27f6a1859be0cd782b8224930c7c3d2ead5d495c08dd7",
3008
3010
  "test/layer-0-primitives/web-push-vapid.test.js": "sha256:4634dcf1c3fdae300b291d20473285a9d9fe2f49f9c54e4e3a2e149768d3fe32",