capacity-attest 0.1.1 → 0.2.0

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/dist/index.js CHANGED
@@ -27,7 +27,7 @@ server.registerTool("record_delivery", {
27
27
  "other agents read it back.",
28
28
  inputSchema: DeliveryClaimSchema.shape,
29
29
  }, async (args) => {
30
- const result = recordDelivery(args);
30
+ const result = await recordDelivery(args);
31
31
  if (!result.ok)
32
32
  return errorResult(result.reason);
33
33
  return textResult({ ok: true, claimId: result.claimId });
@@ -42,7 +42,7 @@ server.registerTool("get_delivery_history", {
42
42
  sellerAddress: ClaimContentSchema.shape.sellerAddress,
43
43
  },
44
44
  }, async ({ sellerAddress }) => {
45
- return textResult(getDeliveryHistory(sellerAddress));
45
+ return textResult(await getDeliveryHistory(sellerAddress));
46
46
  });
47
47
  // Re-exported so callers embedding this package can reference the same enums
48
48
  // / schema the tools validate against without duplicating them.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,yEAAyE;AACzE,2EAA2E;AAC3E,oDAAoD;AACpD,uDAAuD;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACrG,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,iBAAiB;IACvB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAC;AAEH,SAAS,UAAU,CAAC,KAAc;IAChC,MAAM,IAAI,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAChF,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED,SAAS,WAAW,CAAC,OAAe;IAClC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,OAAO,EAAE,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACnF,CAAC;AAED,MAAM,CAAC,YAAY,CACjB,iBAAiB,EACjB;IACE,KAAK,EAAE,yBAAyB;IAChC,WAAW,EACT,oHAAoH;QACpH,qGAAqG;QACrG,4GAA4G;QAC5G,2GAA2G;QAC3G,4BAA4B;IAC9B,WAAW,EAAE,mBAAmB,CAAC,KAAK;CACvC,EACD,KAAK,EAAE,IAAI,EAAE,EAAE;IACb,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAClD,OAAO,UAAU,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;AAC3D,CAAC,CACF,CAAC;AAEF,MAAM,CAAC,YAAY,CACjB,sBAAsB,EACtB;IACE,KAAK,EAAE,iCAAiC;IACxC,WAAW,EACT,8GAA8G;QAC9G,gHAAgH;QAChH,4GAA4G;QAC5G,mBAAmB;IACrB,WAAW,EAAE;QACX,aAAa,EAAE,kBAAkB,CAAC,KAAK,CAAC,aAAa;KACtD;CACF,EACD,KAAK,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE;IAC1B,OAAO,UAAU,CAAC,kBAAkB,CAAC,aAAa,CAAC,CAAC,CAAC;AACvD,CAAC,CACF,CAAC;AAEF,6EAA6E;AAC7E,gEAAgE;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,CAAC;AAEzC,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;IACjB,OAAO,CAAC,KAAK,CAAC,6CAA6C,EAAE,CAAC,CAAC,CAAC;IAChE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,yEAAyE;AACzE,2EAA2E;AAC3E,oDAAoD;AACpD,uDAAuD;AAEvD,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACrG,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,IAAI,EAAE,iBAAiB;IACvB,OAAO,EAAE,OAAO;CACjB,CAAC,CAAC;AAEH,SAAS,UAAU,CAAC,KAAc;IAChC,MAAM,IAAI,GAAG,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAChF,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED,SAAS,WAAW,CAAC,OAAe;IAClC,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,OAAO,EAAE,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AACnF,CAAC;AAED,MAAM,CAAC,YAAY,CACjB,iBAAiB,EACjB;IACE,KAAK,EAAE,yBAAyB;IAChC,WAAW,EACT,oHAAoH;QACpH,qGAAqG;QACrG,4GAA4G;QAC5G,2GAA2G;QAC3G,4BAA4B;IAC9B,WAAW,EAAE,mBAAmB,CAAC,KAAK;CACvC,EACD,KAAK,EAAE,IAAI,EAAE,EAAE;IACb,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,CAAC;IAC1C,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,OAAO,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAClD,OAAO,UAAU,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;AAC3D,CAAC,CACF,CAAC;AAEF,MAAM,CAAC,YAAY,CACjB,sBAAsB,EACtB;IACE,KAAK,EAAE,iCAAiC;IACxC,WAAW,EACT,8GAA8G;QAC9G,gHAAgH;QAChH,4GAA4G;QAC5G,mBAAmB;IACrB,WAAW,EAAE;QACX,aAAa,EAAE,kBAAkB,CAAC,KAAK,CAAC,aAAa;KACtD;CACF,EACD,KAAK,EAAE,EAAE,aAAa,EAAE,EAAE,EAAE;IAC1B,OAAO,UAAU,CAAC,MAAM,kBAAkB,CAAC,aAAa,CAAC,CAAC,CAAC;AAC7D,CAAC,CACF,CAAC;AAEF,6EAA6E;AAC7E,gEAAgE;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,CAAC;AAEzC,KAAK,UAAU,IAAI;IACjB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE;IACjB,OAAO,CAAC,KAAK,CAAC,6CAA6C,EAAE,CAAC,CAAC,CAAC;IAChE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
package/dist/ledger.d.ts CHANGED
@@ -3,9 +3,9 @@ import { type DeliveryClaim } from "./schema.js";
3
3
  * Append one already-verified claim to the ledger. Throws if a claim with
4
4
  * the same claimId was already recorded.
5
5
  */
6
- export declare function appendClaim(claim: DeliveryClaim): DeliveryClaim;
6
+ export declare function appendClaim(claim: DeliveryClaim): Promise<DeliveryClaim>;
7
7
  /** All claims recorded against one seller, oldest first. */
8
- export declare function claimsForSeller(sellerAddress: string): DeliveryClaim[];
8
+ export declare function claimsForSeller(sellerAddress: string): Promise<DeliveryClaim[]>;
9
9
  /** Every claim in the ledger, oldest first. Mainly useful for tests/inspection. */
10
- export declare function allClaims(): DeliveryClaim[];
10
+ export declare function allClaims(): Promise<DeliveryClaim[]>;
11
11
  //# sourceMappingURL=ledger.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAYA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAqFtE;;;GAGG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,aAAa,CAc/D;AAED,4DAA4D;AAC5D,wBAAgB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,aAAa,EAAE,CAItE;AAED,mFAAmF;AACnF,wBAAgB,SAAS,IAAI,aAAa,EAAE,CAE3C"}
1
+ {"version":3,"file":"ledger.d.ts","sourceRoot":"","sources":["../src/ledger.ts"],"names":[],"mappings":"AAYA,OAAO,EAAuB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAoftE;;;GAGG;AACH,wBAAsB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CA0D9E;AAED,4DAA4D;AAC5D,wBAAsB,eAAe,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAQrF;AAED,mFAAmF;AACnF,wBAAsB,SAAS,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAG1D"}
package/dist/ledger.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // history. A second claim with the same content-addressed claimId (i.e. an
7
7
  // attempt to re-record the exact same claim) is rejected rather than
8
8
  // silently duplicated.
9
- import { existsSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
9
+ import { readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { dataDir, ensureDataDir } from "./config.js";
12
12
  import { DeliveryClaimSchema } from "./schema.js";
@@ -24,9 +24,27 @@ function claimsFile() {
24
24
  // Fix: a lockfile-based mutex around the whole read+check+append sequence,
25
25
  // using O_EXCL ("wx") as the atomic primitive — only one process can
26
26
  // successfully create the lock file at a time, so the race window closes.
27
- // This module is deliberately fully synchronous throughout (see the header
28
- // comment above), so the wait loop is a bounded synchronous retry rather
29
- // than an async queue.
27
+ //
28
+ // SECOND FIX (2026-08-31, same day): the mutex's first version retried a
29
+ // contended lock with a synchronous `while (Date.now() < until) {}` spin
30
+ // loop. That busy-wait blocks the entire Node.js event loop for however
31
+ // long it spins (up to LOCK_TIMEOUT_MS = 5000ms) — in a live server this
32
+ // package is designed to run inside (a buyer agent recording a delivery
33
+ // claim right after a paid x402 call), that stalls every other in-flight
34
+ // request on the process, not just the caller. The retry now awaits a real
35
+ // `setTimeout`-based delay instead, which yields control back to the event
36
+ // loop between attempts. This makes acquireLock() / appendClaim() (and,
37
+ // for a consistent async surface across the module, readClaims() /
38
+ // claimsForSeller() / allClaims()) Promise-returning. Every call site
39
+ // (tools.ts, index.ts's tool handlers, examples/demo.ts, the test suite,
40
+ // the bench/ scripts) was updated to await them — a half-converted fix
41
+ // (async internals with a caller that forgot to await) would silently
42
+ // reorder the read-check-write sequence and could reintroduce the exact
43
+ // duplicate-claim race the lock exists to prevent. Verified after the fix:
44
+ // bench/ledger-concurrency.mjs still shows 0% duplicates under real
45
+ // multi-process contention (same as right after the first fix), and a new
46
+ // bench/event-loop-non-blocking.mjs proves a concurrent setInterval no
47
+ // longer stalls while the lock is contended.
30
48
  const LOCK_RETRY_DELAY_MS = 5;
31
49
  const LOCK_TIMEOUT_MS = 5_000;
32
50
  // If a process crashes while holding the lock, the lock file would
@@ -36,7 +54,31 @@ const STALE_LOCK_MS = 30_000;
36
54
  function lockFile() {
37
55
  return claimsFile() + ".lock";
38
56
  }
39
- function acquireLock(path) {
57
+ /** Resolves after `ms` real milliseconds — yields control back to the event loop, unlike a spin loop. */
58
+ function delay(ms) {
59
+ return new Promise((resolve) => setTimeout(resolve, ms));
60
+ }
61
+ // Errno codes that mean "could not create the lock file right now because
62
+ // someone else is touching it" and are therefore worth retrying, rather
63
+ // than "something is actually wrong" and worth throwing immediately.
64
+ // EEXIST is the expected, documented case (another process holds the
65
+ // lock). EPERM is included too: on Windows/NTFS, deleting a file (as
66
+ // releaseLock() does) can leave it briefly in a "pending delete" state, and
67
+ // a `wx`-mode open racing against that transition can surface as EPERM
68
+ // rather than either a clean success or a clean EEXIST. Found empirically
69
+ // (2026-08-31) while writing bench/event-loop-non-blocking.mjs: 25
70
+ // concurrent in-process callers all racing to re-acquire a just-released
71
+ // lock intermittently hit this, and — before this was added — it threw
72
+ // past the retry loop entirely, turning ordinary contention into a hard
73
+ // failure instead of a bounded wait. Reproduces identically on the
74
+ // pre-async-fix synchronous code too, so this is not something the
75
+ // busy-wait -> async change introduced; it is a pre-existing Windows-only
76
+ // gap in what counted as "retryable" that this same fix pass closed.
77
+ function isRetryableLockError(e) {
78
+ const code = e.code;
79
+ return code === "EEXIST" || code === "EPERM";
80
+ }
81
+ async function acquireLock(path) {
40
82
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
41
83
  for (;;) {
42
84
  try {
@@ -44,7 +86,7 @@ function acquireLock(path) {
44
86
  return;
45
87
  }
46
88
  catch (e) {
47
- if (e.code !== "EEXIST")
89
+ if (!isRetryableLockError(e))
48
90
  throw e;
49
91
  try {
50
92
  if (Date.now() - statSync(path).mtimeMs > STALE_LOCK_MS) {
@@ -60,10 +102,10 @@ function acquireLock(path) {
60
102
  if (Date.now() > deadline) {
61
103
  throw new Error(`ledger_lock_timeout: could not acquire lock at ${path}`);
62
104
  }
63
- const until = Date.now() + LOCK_RETRY_DELAY_MS;
64
- while (Date.now() < until) {
65
- /* bounded synchronous backoff */
66
- }
105
+ // Non-blocking backoff: await a real timer instead of spinning, so the
106
+ // event loop stays free to process other work (timers, other
107
+ // requests) while this call is waiting for the lock.
108
+ await delay(LOCK_RETRY_DELAY_MS);
67
109
  }
68
110
  }
69
111
  }
@@ -75,38 +117,434 @@ function releaseLock(path) {
75
117
  // Already gone (e.g. reclaimed as stale by another process) — fine.
76
118
  }
77
119
  }
78
- function readClaims() {
79
- const file = claimsFile();
80
- if (!existsSync(file))
81
- return [];
82
- return readFileSync(file, "utf8")
83
- .split("\n")
84
- .filter(Boolean)
85
- .flatMap((line) => {
120
+ // THIRD FIX (2026-08-31, same day, found by an adversarial verifier): the
121
+ // function this comment used to describe (readClaims()) re-read and
122
+ // re-validated the ENTIRE ledger file, through zod, on every single call —
123
+ // appendClaim() (for its duplicate-claimId check) AND claimsForSeller()/
124
+ // allClaims(). Cost is linear in TOTAL ledger size, and since this ledger is
125
+ // explicitly append-only, permanent history (nothing is ever deleted — see
126
+ // this file's header comment), that cost only grows over the service's
127
+ // lifetime, never shrinks. Measured empirically before this fix: 903ms of
128
+ // unbroken event-loop freeze at 40,000 lines for a single call with zero
129
+ // contention; under real lock contention against a 5,000-line ledger with 10
130
+ // concurrent callers, a single continuous 526ms stretch where the event loop
131
+ // processed nothing at all. In this package's real deployment shape (a
132
+ // single long-lived Node process, no clustering — see x402-server.mjs), that
133
+ // is every other in-flight request on the process freezing for over half a
134
+ // second, every time anyone records a delivery, and it gets worse forever.
135
+ //
136
+ // Fix: an in-memory cache, per resolved ledger file path (NOT a single
137
+ // global — CAPACITY_ATTEST_DATA_DIR differs between tests/instances, so
138
+ // keying by path is what makes this correct for more than one ledger file
139
+ // per process), holding the already-parsed-and-validated claims plus two
140
+ // indexes built for the two things this module actually needs to answer
141
+ // fast: a claimId Set (appendClaim()'s duplicate check) and a
142
+ // sellerAddress -> claims[] Map (claimsForSeller()). Each cache entry also
143
+ // carries the ledger file's byte size AT THE MOMENT it was built.
144
+ //
145
+ // On every call, a cheap `statSync` (no parsing) compares the file's CURRENT
146
+ // byte size against the cached size. Byte size, not mtime: filesystem mtime
147
+ // resolution can be coarse enough on some platforms that two real writes
148
+ // within the same tick look unchanged to an mtime check — that would be a
149
+ // silent-stale-read correctness bug, not just a missed optimization.
150
+ // Unchanged size -> the cache is used as-is, no disk read, no re-parse, no
151
+ // re-validation: this is the fast path this fix exists to add. Changed size
152
+ // -> the file was written since our cache was built (by another process, or
153
+ // by our own appendClaim() before it started updating the cache in-place —
154
+ // see below), so the cache is stale and gets rebuilt from disk.
155
+ //
156
+ // LOCK-VS-STALENESS-CHECK ORDERING (the part that is easy to get backwards
157
+ // and silently reintroduce the original duplicate-claim race): appendClaim()
158
+ // does its staleness check/resync AFTER acquireLock(), never before. If it
159
+ // checked staleness first and only acquired the lock afterward, there would
160
+ // be a TOCTOU gap between "we decided the cache is fresh" and "we hold the
161
+ // lock" during which another process could append — our now-actually-stale
162
+ // cache would then pass the duplicate check against a claimId set that is
163
+ // missing that concurrent write, and two processes could both believe they
164
+ // won the race, exactly the bug the lock was built to close in the first
165
+ // fix. Doing the freshness check (and, if needed, the resync) INSIDE the
166
+ // locked section instead means: by the time appendClaim() looks at the
167
+ // cache, no other process can be mid-write against this file (the lock
168
+ // serializes that), so the cache, once confirmed fresh (or just rebuilt),
169
+ // stays a true reflection of on-disk state for the rest of the critical
170
+ // section — there is no gap left for a concurrent writer to hide in. The
171
+ // cache is therefore a performance layer INSIDE the lock's critical section,
172
+ // never a substitute for it; duplicate-claimId safety still comes 100% from
173
+ // the lock, exactly as before this fix.
174
+ //
175
+ // claimsForSeller()/allClaims() do NOT take the lock (same as before this
176
+ // fix — see the original comment this one replaces), because their
177
+ // correctness need is different: they don't need atomicity with a
178
+ // concurrent write, they just need to reflect a recent, self-consistent
179
+ // on-disk state at the time they're called. The same statSync-based
180
+ // staleness check gives them that: right after another process (or our own
181
+ // appendClaim()) writes, the file's size changes, so the very next
182
+ // claimsForSeller()/allClaims() call resyncs instead of trusting a now-stale
183
+ // cache. This is exactly the "process A warm cache, process B writes
184
+ // directly, process A must see it" scenario the new
185
+ // cross-process-cache-invalidation test in ledger.test.ts exercises.
186
+ //
187
+ // The resync-from-disk path is still fundamentally O(n) — unavoidable if the
188
+ // true on-disk state must be validated — but two things change: (1) it is
189
+ // now the EXCEPTION (only when the file actually changed underneath us),
190
+ // not the norm (every call, regardless); (2) it no longer holds the event
191
+ // loop hostage for its full duration in one stretch — resyncFromDisk()
192
+ // processes lines in RESYNC_BATCH_SIZE-sized batches and `await`s a real
193
+ // setImmediate between batches, bounding any single blocking stretch to
194
+ // roughly one batch's cost rather than the whole file's, the same
195
+ // non-blocking-yield pattern the previous fix in this file applied to lock
196
+ // contention.
197
+ //
198
+ // Concurrent-resync safety within one process: resyncFromDisk() builds its
199
+ // claims array / claimId Set / sellerAddress Map into a brand-new object and
200
+ // only publishes it (a single synchronous Map.set on cacheByPath) once
201
+ // fully built — so a reader that hits the fast path mid-resync sees either
202
+ // the old (stale but internally consistent) cache or the new one, never a
203
+ // half-populated one. If two callers in the same process both observe
204
+ // staleness at once (e.g. a concurrent appendClaim() and claimsForSeller()),
205
+ // they share ONE in-flight resync via pendingResyncs rather than each
206
+ // starting their own redundant O(n) pass.
207
+ //
208
+ // Verified after this fix: bench/ledger-scale.mjs (rewritten for this fix)
209
+ // shows append/history calls after the first one at a given N running in
210
+ // well under a millisecond instead of growing linearly with ledger size;
211
+ // bench/ledger-concurrency.mjs still shows 0% duplicates and 0 lost writes
212
+ // under real multi-process contention (unchanged from the previous fix);
213
+ // bench/event-loop-non-blocking.mjs (the previous fix's proof) still passes
214
+ // unmodified, confirming this fix did not reintroduce event-loop blocking on
215
+ // the lock-wait path it already fixed.
216
+ const RESYNC_BATCH_SIZE = 500;
217
+ function emptyCache() {
218
+ return { size: 0, claims: [], claimIdSet: new Set(), bySeller: new Map() };
219
+ }
220
+ // Keyed by resolved ledger file path, not a single global — tests (and any
221
+ // future multi-tenant use) point CAPACITY_ATTEST_DATA_DIR at different
222
+ // directories within the same process, and a single shared cache variable
223
+ // would silently serve one ledger's data for another's calls.
224
+ const cacheByPath = new Map();
225
+ // De-duplicates concurrent resyncs of the SAME path within one process: if
226
+ // two callers both observe staleness before either resync finishes, the
227
+ // second joins the first's in-flight Promise instead of redoing the O(n)
228
+ // work itself.
229
+ const pendingResyncs = new Map();
230
+ /** Yields control back to the event loop — used between resync batches. */
231
+ function yieldToEventLoop() {
232
+ return new Promise((resolve) => setImmediate(resolve));
233
+ }
234
+ function timestampMs(claim) {
235
+ return Date.parse(claim.timestamp);
236
+ }
237
+ // WHY THE CACHE KEEPS claims/bySeller PRE-SORTED, INSTEAD OF SORTING AT READ
238
+ // TIME (which is what the pre-cache readClaims()-based code did, and what an
239
+ // earlier draft of THIS fix also did): claimsForSeller()/allClaims() are
240
+ // exactly the calls this whole fix is about making cheap on the warm path.
241
+ // Re-running `.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))`
242
+ // on every single warm call is itself O(n log n) with an expensive
243
+ // comparator (Date.parse() is a real date-string parse, not a cheap number
244
+ // compare) — at N=40,000 that measured at ~13-14ms PER WARM CALL in this
245
+ // fix's own benchmark (bench/ledger-cache-scale.mjs), 450x the N=0 floor,
246
+ // which is not "amortized O(1) or close to it" by any reasonable reading,
247
+ // even though it correctly skipped the disk read. So: claims/bySeller are
248
+ // sorted ONCE per resync (still inside the O(n) resync's cost, effectively
249
+ // free to add), and appendClaim() keeps that invariant on the warm
250
+ // incremental-update path too, via a binary-search insert (sortedInsertIndex
251
+ // below) instead of a plain push — O(log n) to find the position, O(n) for
252
+ // the underlying array shift (a plain memmove of object references, not
253
+ // re-parsing/re-validating anything), which is what turns the warm-call
254
+ // benchmark's claimsForSeller()/allClaims() numbers into a plain array copy:
255
+ // no comparator, no Date.parse, cost proportional only to the RESULT size,
256
+ // not the ledger's total size.
257
+ function sortedInsertIndex(sorted, ts) {
258
+ let lo = 0;
259
+ let hi = sorted.length;
260
+ while (lo < hi) {
261
+ const mid = (lo + hi) >>> 1;
262
+ // <= keeps insertion AFTER any existing entries with an equal
263
+ // timestamp, so a newly appended claim with a tied timestamp lands
264
+ // after previously-recorded ones — the same tie-break a stable
265
+ // file-order sort would have produced, since this is an append-only
266
+ // log and the new claim is, by definition, the most recently arrived.
267
+ if (timestampMs(sorted[mid]) <= ts)
268
+ lo = mid + 1;
269
+ else
270
+ hi = mid;
271
+ }
272
+ return lo;
273
+ }
274
+ // SIXTH FIX (2026-08-31, same day, found by adversarial verification): the
275
+ // parse loop above correctly yields every RESYNC_BATCH_SIZE lines, but the
276
+ // code that used to follow it — a single `decorated.sort(...)` over the
277
+ // WHOLE array, then a single unyielded `for` loop over the WHOLE sorted
278
+ // array to build `bySeller` — both ran as one uninterrupted synchronous
279
+ // stretch after the batched parse loop finished. Measured: at N=50,000 that
280
+ // unyielded tail alone was ~75ms (26ms sort + 47ms bySeller-build); at a
281
+ // plausible future N=200,000 it was ~438ms — approaching the magnitude of
282
+ // the original pre-cache bug (903ms at N=40,000) this entire fix exists to
283
+ // eliminate. A plain per-batch merge (merge each newly-parsed batch into the
284
+ // accumulated sorted result, yield between batches) would NOT actually fix
285
+ // this: the merge step itself is O(accumulated size), so the LAST merge in a
286
+ // large ledger would still be one unyielded O(n) stretch — the exact same
287
+ // shape of bug, just moved earlier. What actually bounds every unyielded
288
+ // stretch to ~one batch's cost, all the way through an O(n log n) sort, is
289
+ // counting WORK DONE (elements written to the merge output), not batch
290
+ // boundaries, and yielding whenever that counter crosses RESYNC_BATCH_SIZE —
291
+ // this is what yieldingMergeSort() below does: a standard iterative
292
+ // bottom-up merge sort (stable, like the native Array.prototype.sort() it
293
+ // replaces — see its own comment for why <= on ties matters), except every
294
+ // single element written into the merge output increments a shared counter
295
+ // that triggers a real yield once it reaches RESYNC_BATCH_SIZE, regardless
296
+ // of which pass or which pair-merge that element happened to fall in. Total
297
+ // work is still the same O(n log n) a native sort would do; what changes is
298
+ // that it is never done in one unbroken stretch bigger than one batch.
299
+ async function yieldingMergeSort(items) {
300
+ const n = items.length;
301
+ if (n < 2)
302
+ return;
303
+ let src = items;
304
+ let dst = new Array(n);
305
+ let writtenSinceYield = 0;
306
+ for (let width = 1; width < n; width *= 2) {
307
+ for (let lo = 0; lo < n; lo += width * 2) {
308
+ const mid = Math.min(lo + width, n);
309
+ const hi = Math.min(lo + width * 2, n);
310
+ let i = lo;
311
+ let j = mid;
312
+ let k = lo;
313
+ while (i < mid || j < hi) {
314
+ // <= (not <) is what makes this stable: on a tie, the left run
315
+ // (which, at every level of a bottom-up merge sort, always holds
316
+ // elements that were earlier in the pre-sort/file order than the
317
+ // right run) is drained first — identical tie-break to the native
318
+ // stable `.sort()` this replaces.
319
+ if (j >= hi || (i < mid && src[i].ts <= src[j].ts)) {
320
+ dst[k++] = src[i++];
321
+ }
322
+ else {
323
+ dst[k++] = src[j++];
324
+ }
325
+ if (++writtenSinceYield >= RESYNC_BATCH_SIZE) {
326
+ writtenSinceYield = 0;
327
+ await yieldToEventLoop();
328
+ }
329
+ }
330
+ }
331
+ [src, dst] = [dst, src];
332
+ }
333
+ if (src !== items) {
334
+ // The last swap left the fully-sorted array in `src`, which is the
335
+ // scratch buffer, not the caller's `items` array — copy it back in
336
+ // place, itself batched/yielded for the same reason as everything else
337
+ // in this function: a plain unyielded `for` here over all n elements
338
+ // would just reintroduce the exact bug this function exists to close.
339
+ for (let i = 0; i < n; i++) {
340
+ items[i] = src[i];
341
+ if ((i + 1) % RESYNC_BATCH_SIZE === 0 && i + 1 < n) {
342
+ await yieldToEventLoop();
343
+ }
344
+ }
345
+ }
346
+ }
347
+ /**
348
+ * Re-reads and re-validates the entire ledger file from disk, in batches
349
+ * with a real yield between them so this never holds the event loop for the
350
+ * whole file's cost in one uninterrupted stretch. Builds a brand-new cache
351
+ * object and only publishes it once complete (see the header comment above
352
+ * for why that matters for in-process concurrent readers).
353
+ */
354
+ async function resyncFromDisk(file) {
355
+ let content;
356
+ try {
357
+ content = readFileSync(file, "utf8");
358
+ }
359
+ catch (e) {
360
+ // FIFTH FIX (2026-08-31, same day, found by adversarial verification):
361
+ // this bare `catch` used to treat EVERY readFileSync error identically
362
+ // to "file doesn't exist yet" — including a transient EACCES, EBUSY,
363
+ // EMFILE, or a Windows sharing-violation, none of which mean the ledger
364
+ // is empty. Silently downgrading a real error to "empty cache" can wipe
365
+ // a warm, correct in-memory cache down to zero known claimIds, after
366
+ // which a genuine duplicate claimId would be wrongly accepted — the
367
+ // exact guarantee this whole module exists to protect. Only ENOENT
368
+ // (mirrors isRetryableLockError()'s explicit-code-check style above)
369
+ // means "does not exist yet, empty is the correct read"; every other
370
+ // error now propagates as a real thrown error instead of being eaten.
371
+ if (e.code !== "ENOENT")
372
+ throw e;
373
+ // Doesn't exist (or vanished) — a fresh, empty, but still validly-sized
374
+ // (0 bytes) cache. Matches the pre-fix behavior of readClaims()
375
+ // returning [] when the file doesn't exist yet.
376
+ const empty = emptyCache();
377
+ cacheByPath.set(file, empty);
378
+ return empty;
379
+ }
380
+ const lines = content.split("\n").filter(Boolean);
381
+ // Decorated with each claim's parsed timestamp so the sort below compares
382
+ // plain numbers instead of re-parsing every timestamp string on every
383
+ // comparison (an O(n log n)-times cost vs. this O(n)-times one).
384
+ const decorated = [];
385
+ const claimIdSet = new Set();
386
+ for (let i = 0; i < lines.length; i++) {
387
+ const line = lines[i];
86
388
  try {
87
- return [DeliveryClaimSchema.parse(JSON.parse(line))];
389
+ const claim = DeliveryClaimSchema.parse(JSON.parse(line));
390
+ decorated.push({ claim, ts: timestampMs(claim) });
391
+ claimIdSet.add(claim.claimId.toLowerCase());
88
392
  }
89
393
  catch {
90
394
  // A corrupt/partial line (e.g. a truncated write) must not take down
91
- // reads of the rest of the ledger.
92
- return [];
395
+ // reads of the rest of the ledger — same tolerance as before this fix.
396
+ }
397
+ if ((i + 1) % RESYNC_BATCH_SIZE === 0 && i + 1 < lines.length) {
398
+ await yieldToEventLoop();
93
399
  }
400
+ }
401
+ // Stable sort, same tie-break behavior (ties keep original file/append
402
+ // order) as the pre-fix code's native `.sort()` — but via
403
+ // yieldingMergeSort() instead, which bounds every unyielded stretch to
404
+ // ~RESYNC_BATCH_SIZE elements instead of running as one uninterrupted
405
+ // O(n log n) block. See yieldingMergeSort()'s own comment (SIXTH FIX) for
406
+ // why a naive per-batch merge would not actually have achieved this.
407
+ await yieldingMergeSort(decorated);
408
+ const claims = decorated.map((d) => d.claim);
409
+ // sellerAddress is already lower-cased by ClaimContentSchema's own
410
+ // .transform() (see schema.ts), so no extra normalization needed here.
411
+ // Iterating the now-sorted `claims` means every bucket comes out sorted
412
+ // too, with no separate per-seller sort needed. Batched with a yield every
413
+ // RESYNC_BATCH_SIZE claims (same SIXTH FIX as the sort above) instead of
414
+ // one unyielded pass over the whole array — building this map is itself
415
+ // O(n) and was part of the same measured 47ms-at-N=50,000 unyielded tail.
416
+ const bySeller = new Map();
417
+ for (let i = 0; i < claims.length; i++) {
418
+ const claim = claims[i];
419
+ const bucket = bySeller.get(claim.sellerAddress);
420
+ if (bucket)
421
+ bucket.push(claim);
422
+ else
423
+ bySeller.set(claim.sellerAddress, [claim]);
424
+ if ((i + 1) % RESYNC_BATCH_SIZE === 0 && i + 1 < claims.length) {
425
+ await yieldToEventLoop();
426
+ }
427
+ }
428
+ // size is derived from the exact content string we just validated
429
+ // (Buffer.byteLength), not a separate statSync call — that would open a
430
+ // gap where a write between the read and the stat could make the marker
431
+ // disagree with what we actually parsed. This way the two can never
432
+ // disagree by construction.
433
+ const entry = { size: Buffer.byteLength(content, "utf8"), claims, claimIdSet, bySeller };
434
+ cacheByPath.set(file, entry);
435
+ return entry;
436
+ }
437
+ /**
438
+ * Returns a cache guaranteed fresh as of the moment this is called: if the
439
+ * on-disk file's byte size still matches what the cache was built from,
440
+ * returns it as-is (no disk read at all beyond the one cheap statSync).
441
+ * Otherwise resyncs from disk first (see resyncFromDisk()).
442
+ */
443
+ async function getFreshCache(file) {
444
+ let diskSize;
445
+ try {
446
+ diskSize = statSync(file).size;
447
+ }
448
+ catch (e) {
449
+ // Same explicit-ENOENT-only reasoning as resyncFromDisk()'s catch above
450
+ // (FIFTH FIX) — a non-ENOENT statSync error is a real problem, not an
451
+ // empty ledger, and must not be swallowed into diskSize=0.
452
+ if (e.code !== "ENOENT")
453
+ throw e;
454
+ diskSize = 0; // file does not exist yet
455
+ }
456
+ const cached = cacheByPath.get(file);
457
+ if (cached && cached.size === diskSize) {
458
+ return cached;
459
+ }
460
+ // KNOWN LIMITATION (documented 2026-08-31, not fixed): this join does not
461
+ // re-check the in-flight resync's eventual `.size` against the `diskSize`
462
+ // measured above. If that resync was already snapshotting disk (via
463
+ // resyncFromDisk()'s own readFileSync) before an external, lock-bypassing
464
+ // writer appended a claim, and this call's own statSync happened to run
465
+ // after that external write, the joined resync can resolve to a cache
466
+ // that is already stale relative to what this caller just observed on
467
+ // disk. In appendClaim()'s case that means the duplicate-claimId check
468
+ // could pass against a claimId the external writer just added, producing
469
+ // a real duplicate line in the ledger. Reproduced 3/3 in adversarial
470
+ // testing. Low real-world severity today: this package's actual
471
+ // deployment (a single long-lived process holding the lockfile for every
472
+ // write) never has a second, lock-bypassing writer racing a resync in
473
+ // this exact window; the gap only opens for a future deployment shape
474
+ // (e.g. multiple processes/hosts sharing one CAPACITY_ATTEST_DATA_DIR,
475
+ // with something other than this package's own appendClaim() writing to
476
+ // the file directly). Fix direction if this is ever revisited: re-check
477
+ // `inFlight`'s resolved `.size` against `diskSize` after joining, and
478
+ // loop to a fresh resyncFromDisk() call if they still disagree.
479
+ const inFlight = pendingResyncs.get(file);
480
+ if (inFlight)
481
+ return inFlight;
482
+ const resync = resyncFromDisk(file).finally(() => {
483
+ pendingResyncs.delete(file);
94
484
  });
485
+ pendingResyncs.set(file, resync);
486
+ return resync;
95
487
  }
96
488
  /**
97
489
  * Append one already-verified claim to the ledger. Throws if a claim with
98
490
  * the same claimId was already recorded.
99
491
  */
100
- export function appendClaim(claim) {
492
+ export async function appendClaim(claim) {
101
493
  ensureDataDir();
494
+ const file = claimsFile();
102
495
  const lock = lockFile();
103
- acquireLock(lock);
496
+ await acquireLock(lock);
104
497
  try {
105
- const existing = readClaims();
106
- if (existing.some((c) => c.claimId.toLowerCase() === claim.claimId.toLowerCase())) {
498
+ // Freshness check happens AFTER acquiring the lock — see this file's
499
+ // header comment for why that ordering, not the reverse, is what keeps
500
+ // the duplicate-claimId guarantee intact across processes.
501
+ const cache = await getFreshCache(file);
502
+ const claimIdLower = claim.claimId.toLowerCase();
503
+ if (cache.claimIdSet.has(claimIdLower)) {
107
504
  throw new Error(`claim_already_recorded: ${claim.claimId}`);
108
505
  }
109
- appendFileSync(claimsFile(), JSON.stringify(claim) + "\n");
506
+ // FOURTH FIX (2026-08-31, same day, found by adversarial verification of
507
+ // the THIRD FIX above): this used to re-`statSync(file).size` right
508
+ // after the write below, on the theory that "exact by construction" was
509
+ // safer than hand-computing a delta. That reasoning was backwards: a
510
+ // statSync reads the file's CURRENT combined size, so if any OTHER
511
+ // writer — even one that doesn't respect this module's own lockfile,
512
+ // e.g. a second independent process doing a raw fs.appendFileSync, which
513
+ // this whole lock exists to defend against for the on-disk data but was
514
+ // never guarding OUR in-memory size marker — wrote to the same file in
515
+ // the narrow window between our appendFileSync and that statSync, the
516
+ // cache's `size` would end up matching the file's new COMBINED size
517
+ // while cache.claims/claimIdSet only reflect OUR write. The next
518
+ // getFreshCache() would then see size-matches-disk and trust a cache
519
+ // that is silently missing the other writer's claim — permanently,
520
+ // until some unrelated write happens to invalidate it again. Reproduced
521
+ // 4/4 runs: a duplicate claimId got accepted (should have been
522
+ // rejected), and claimsForSeller()/allClaims() silently omitted the
523
+ // externally-written claim. Fixed by computing the new size as a DELTA —
524
+ // old cached size + exactly the bytes of the line we ourselves just
525
+ // wrote — which by construction cannot be perturbed by anyone else's
526
+ // write landing in that window, matching resyncFromDisk()'s own
527
+ // Buffer.byteLength(content, "utf8") convention for what "size" means.
528
+ const serializedLine = JSON.stringify(claim) + "\n";
529
+ appendFileSync(file, serializedLine);
530
+ // Update the cache in place to reflect our own write, synchronously and
531
+ // with no `await` anywhere in this block — that's what guarantees no
532
+ // other same-process caller can observe a half-updated cache (JS never
533
+ // preempts a synchronous stretch of code). This keeps OUR process's
534
+ // cache warm for the next call without forcing an immediate resync, and
535
+ // is why repeated appendClaim()/claimsForSeller() calls from the same
536
+ // long-lived process stay cheap even as the ledger grows. Insertion
537
+ // (not push) keeps claims/bySeller sorted by timestamp at all times —
538
+ // see sortedInsertIndex()'s comment for why that matters.
539
+ const ts = timestampMs(claim);
540
+ cache.claims.splice(sortedInsertIndex(cache.claims, ts), 0, claim);
541
+ cache.claimIdSet.add(claimIdLower);
542
+ const bucket = cache.bySeller.get(claim.sellerAddress);
543
+ if (bucket)
544
+ bucket.splice(sortedInsertIndex(bucket, ts), 0, claim);
545
+ else
546
+ cache.bySeller.set(claim.sellerAddress, [claim]);
547
+ cache.size = cache.size + Buffer.byteLength(serializedLine, "utf8");
110
548
  return claim;
111
549
  }
112
550
  finally {
@@ -114,13 +552,18 @@ export function appendClaim(claim) {
114
552
  }
115
553
  }
116
554
  /** All claims recorded against one seller, oldest first. */
117
- export function claimsForSeller(sellerAddress) {
118
- return readClaims()
119
- .filter((c) => c.sellerAddress.toLowerCase() === sellerAddress.toLowerCase())
120
- .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
555
+ export async function claimsForSeller(sellerAddress) {
556
+ const cache = await getFreshCache(claimsFile());
557
+ const bucket = cache.bySeller.get(sellerAddress.toLowerCase()) ?? [];
558
+ // bucket is already sorted (see LedgerCache's invariant) just copy it,
559
+ // so a caller mutating the returned array can't corrupt the shared cache.
560
+ // No sort, no Date.parse, at call time: this is the whole point of the
561
+ // fix — cost proportional to the RESULT size, not the ledger's total size.
562
+ return [...bucket];
121
563
  }
122
564
  /** Every claim in the ledger, oldest first. Mainly useful for tests/inspection. */
123
- export function allClaims() {
124
- return readClaims().sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
565
+ export async function allClaims() {
566
+ const cache = await getFreshCache(claimsFile());
567
+ return [...cache.claims];
125
568
  }
126
569
  //# sourceMappingURL=ledger.js.map