@aldus-runtime/file-store 0.2.0-next.6 → 0.2.0-next.60

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/errors.d.ts CHANGED
@@ -37,6 +37,12 @@ export declare const FileStoreErrorCodes: {
37
37
  readonly LOCK_TIMEOUT: "ALDUS_LOCK_TIMEOUT";
38
38
  /** A lock was released or renewed by something that no longer holds it. */
39
39
  readonly LOCK_LOST: "ALDUS_LOCK_LOST";
40
+ /** A reservation transition does not satisfy its schema (ADR-0044). */
41
+ readonly RESERVATION_TRANSITION_INVALID: "ALDUS_RESERVATION_TRANSITION_INVALID";
42
+ /** One transition id was reused for a different fact (ADR-0044). */
43
+ readonly RESERVATION_TRANSITION_CONFLICT: "ALDUS_RESERVATION_TRANSITION_CONFLICT";
44
+ /** A reservation stream has a gap, which no correct writer can produce (ADR-0044). */
45
+ readonly RESERVATION_STREAM_CORRUPT: "ALDUS_RESERVATION_STREAM_CORRUPT";
40
46
  /**
41
47
  * A lock was re-acquired inside a scope that already holds it.
42
48
  *
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAErE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;IAC9B,8EAA8E;aAC9E,mBAAmB,EAAE,2BAA2B;IAChD,0DAA0D;aAC1D,gBAAgB,EAAE,wBAAwB;IAC1C,wDAAwD;aACxD,gBAAgB,EAAE,wBAAwB;IAC1C;;;;;OAKG;aACH,iBAAiB,EAAE,yBAAyB;IAC5C;;;;;OAKG;aACH,mBAAmB,EAAE,2BAA2B;IAChD,8DAA8D;aAC9D,qBAAqB,EAAE,6BAA6B;IACpD,uEAAuE;aACvE,eAAe,EAAE,uBAAuB;IACxC,iEAAiE;aACjE,YAAY,EAAE,oBAAoB;IAClC,2EAA2E;aAC3E,SAAS,EAAE,iBAAiB;IAC5B;;;;;;;OAOG;aACH,cAAc,EAAE,sBAAsB;IACtC,yFAAyF;aACzF,wBAAwB,EAAE,gCAAgC;CAClD,CAAC;AAEX,+BAA+B;AAC/B,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAEhG,8DAA8D;AAC9D,wBAAgB,cAAc,CAC5B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,QAAQ,EAAE,aAAa,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC3F,UAAU,CAEZ"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAErE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;IAC9B,8EAA8E;aAC9E,mBAAmB,EAAE,2BAA2B;IAChD,0DAA0D;aAC1D,gBAAgB,EAAE,wBAAwB;IAC1C,wDAAwD;aACxD,gBAAgB,EAAE,wBAAwB;IAC1C;;;;;OAKG;aACH,iBAAiB,EAAE,yBAAyB;IAC5C;;;;;OAKG;aACH,mBAAmB,EAAE,2BAA2B;IAChD,8DAA8D;aAC9D,qBAAqB,EAAE,6BAA6B;IACpD,uEAAuE;aACvE,eAAe,EAAE,uBAAuB;IACxC,iEAAiE;aACjE,YAAY,EAAE,oBAAoB;IAClC,2EAA2E;aAC3E,SAAS,EAAE,iBAAiB;IAC5B,uEAAuE;aACvE,8BAA8B,EAAE,sCAAsC;IACtE,oEAAoE;aACpE,+BAA+B,EAAE,uCAAuC;IACxE,sFAAsF;aACtF,0BAA0B,EAAE,kCAAkC;IAC9D;;;;;;;OAOG;aACH,cAAc,EAAE,sBAAsB;IACtC,yFAAyF;aACzF,wBAAwB,EAAE,gCAAgC;CAClD,CAAC;AAEX,+BAA+B;AAC/B,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,OAAO,mBAAmB,CAAC,CAAC;AAEhG,8DAA8D;AAC9D,wBAAgB,cAAc,CAC5B,IAAI,EAAE,kBAAkB,EACxB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IAAE,QAAQ,EAAE,aAAa,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAC3F,UAAU,CAEZ"}
package/dist/errors.js CHANGED
@@ -37,6 +37,12 @@ export const FileStoreErrorCodes = {
37
37
  LOCK_TIMEOUT: "ALDUS_LOCK_TIMEOUT",
38
38
  /** A lock was released or renewed by something that no longer holds it. */
39
39
  LOCK_LOST: "ALDUS_LOCK_LOST",
40
+ /** A reservation transition does not satisfy its schema (ADR-0044). */
41
+ RESERVATION_TRANSITION_INVALID: "ALDUS_RESERVATION_TRANSITION_INVALID",
42
+ /** One transition id was reused for a different fact (ADR-0044). */
43
+ RESERVATION_TRANSITION_CONFLICT: "ALDUS_RESERVATION_TRANSITION_CONFLICT",
44
+ /** A reservation stream has a gap, which no correct writer can produce (ADR-0044). */
45
+ RESERVATION_STREAM_CORRUPT: "ALDUS_RESERVATION_STREAM_CORRUPT",
40
46
  /**
41
47
  * A lock was re-acquired inside a scope that already holds it.
42
48
  *
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAsB,MAAM,qBAAqB,CAAC;AAErE,4CAA4C;AAC5C,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,8EAA8E;IAC9E,mBAAmB,EAAE,2BAA2B;IAChD,0DAA0D;IAC1D,gBAAgB,EAAE,wBAAwB;IAC1C,wDAAwD;IACxD,gBAAgB,EAAE,wBAAwB;IAC1C;;;;;OAKG;IACH,iBAAiB,EAAE,yBAAyB;IAC5C;;;;;OAKG;IACH,mBAAmB,EAAE,2BAA2B;IAChD,8DAA8D;IAC9D,qBAAqB,EAAE,6BAA6B;IACpD,uEAAuE;IACvE,eAAe,EAAE,uBAAuB;IACxC,iEAAiE;IACjE,YAAY,EAAE,oBAAoB;IAClC,2EAA2E;IAC3E,SAAS,EAAE,iBAAiB;IAC5B;;;;;;;OAOG;IACH,cAAc,EAAE,sBAAsB;IACtC,yFAAyF;IACzF,wBAAwB,EAAE,gCAAgC;CAClD,CAAC;AAKX,8DAA8D;AAC9D,MAAM,UAAU,cAAc,CAC5B,IAAwB,EACxB,OAAe,EACf,OAA4F;IAE5F,OAAO,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAChD,CAAC"}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAsB,MAAM,qBAAqB,CAAC;AAErE,4CAA4C;AAC5C,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,8EAA8E;IAC9E,mBAAmB,EAAE,2BAA2B;IAChD,0DAA0D;IAC1D,gBAAgB,EAAE,wBAAwB;IAC1C,wDAAwD;IACxD,gBAAgB,EAAE,wBAAwB;IAC1C;;;;;OAKG;IACH,iBAAiB,EAAE,yBAAyB;IAC5C;;;;;OAKG;IACH,mBAAmB,EAAE,2BAA2B;IAChD,8DAA8D;IAC9D,qBAAqB,EAAE,6BAA6B;IACpD,uEAAuE;IACvE,eAAe,EAAE,uBAAuB;IACxC,iEAAiE;IACjE,YAAY,EAAE,oBAAoB;IAClC,2EAA2E;IAC3E,SAAS,EAAE,iBAAiB;IAC5B,uEAAuE;IACvE,8BAA8B,EAAE,sCAAsC;IACtE,oEAAoE;IACpE,+BAA+B,EAAE,uCAAuC;IACxE,sFAAsF;IACtF,0BAA0B,EAAE,kCAAkC;IAC9D;;;;;;;OAOG;IACH,cAAc,EAAE,sBAAsB;IACtC,yFAAyF;IACzF,wBAAwB,EAAE,gCAAgC;CAClD,CAAC;AAKX,8DAA8D;AAC9D,MAAM,UAAU,cAAc,CAC5B,IAAwB,EACxB,OAAe,EACf,OAA4F;IAE5F,OAAO,IAAI,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAChD,CAAC"}
package/dist/index.d.ts CHANGED
@@ -25,4 +25,5 @@ export { DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_TTL_MS, Fi
25
25
  export type { EpisodeStore, EventReadOptions, EventReadResult, EventStore, RunCollectionName, RunCollectionTypes, RunStore, } from "./ports.js";
26
26
  export { FileEpisodeStore, FileEventStore, FileRunStore, RUN_COLLECTION_SCHEMAS, nextSequenceOf, } from "./stores.js";
27
27
  export { FileWorkspace, initWorkspace, openWorkspace, type OpenWorkspaceOptions, } from "./workspace.js";
28
+ export { FileSpendReservationStore, type CompareAndAppendResult, type FileSpendReservationStoreOptions, type GrantReservationStream, type SpendReservationStore, } from "./reservation-store.js";
28
29
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,UAAU,EACV,SAAS,EACT,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAE7F,OAAO,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,EACb,KAAK,cAAc,GACpB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAE3F,OAAO,EACL,cAAc,EACd,aAAa,EACb,UAAU,EACV,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,SAAS,EACT,eAAe,EACf,eAAe,EACf,KAAK,WAAW,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,KAAK,EACV,KAAK,WAAW,GACjB,MAAM,WAAW,CAAC;AAEnB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,UAAU,EACV,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,sBAAsB,EACtB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,KAAK,oBAAoB,GAC1B,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,UAAU,EACV,SAAS,EACT,mBAAmB,EACnB,eAAe,EACf,eAAe,EACf,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAE7F,OAAO,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,EACb,KAAK,cAAc,GACpB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAE3F,OAAO,EACL,cAAc,EACd,aAAa,EACb,UAAU,EACV,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,SAAS,EACT,eAAe,EACf,eAAe,EACf,KAAK,WAAW,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,sBAAsB,EAC3B,KAAK,KAAK,EACV,KAAK,WAAW,GACjB,MAAM,WAAW,CAAC;AAEnB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,UAAU,EACV,iBAAiB,EACjB,kBAAkB,EAClB,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,sBAAsB,EACtB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,EACb,KAAK,oBAAoB,GAC1B,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,yBAAyB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,gCAAgC,EACrC,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,GAC3B,MAAM,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -24,4 +24,5 @@ export { ALDUS_DIRECTORY, EPISODE_LOCK_RESOURCE, RUN_FILES, WorkspaceLayout, run
24
24
  export { DEFAULT_LOCK_RETRY_MS, DEFAULT_LOCK_TIMEOUT_MS, DEFAULT_LOCK_TTL_MS, FileLockManager, } from "./lock.js";
25
25
  export { FileEpisodeStore, FileEventStore, FileRunStore, RUN_COLLECTION_SCHEMAS, nextSequenceOf, } from "./stores.js";
26
26
  export { FileWorkspace, initWorkspace, openWorkspace, } from "./workspace.js";
27
+ export { FileSpendReservationStore, } from "./reservation-store.js";
27
28
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,UAAU,EACV,SAAS,EACT,mBAAmB,EACnB,eAAe,EACf,eAAe,GAGhB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAyB,MAAM,kBAAkB,CAAC;AAE7F,OAAO,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,GAEd,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAA2B,MAAM,aAAa,CAAC;AAE3F,OAAO,EACL,cAAc,EACd,aAAa,EACb,UAAU,GAGX,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,SAAS,EACT,eAAe,EACf,eAAe,GAEhB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,eAAe,GAKhB,MAAM,WAAW,CAAC;AAYnB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,sBAAsB,EACtB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,GAEd,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,UAAU,EACV,SAAS,EACT,mBAAmB,EACnB,eAAe,EACf,eAAe,GAGhB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAyB,MAAM,kBAAkB,CAAC;AAE7F,OAAO,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,GAEd,MAAM,eAAe,CAAC;AAEvB,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAA2B,MAAM,aAAa,CAAC;AAE3F,OAAO,EACL,cAAc,EACd,aAAa,EACb,UAAU,GAGX,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,eAAe,EACf,qBAAqB,EACrB,SAAS,EACT,eAAe,EACf,eAAe,GAEhB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,qBAAqB,EACrB,uBAAuB,EACvB,mBAAmB,EACnB,eAAe,GAKhB,MAAM,WAAW,CAAC;AAYnB,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,YAAY,EACZ,sBAAsB,EACtB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,aAAa,EACb,aAAa,EACb,aAAa,GAEd,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,yBAAyB,GAK1B,MAAM,wBAAwB,CAAC"}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Durable storage for spend reservation transitions (ADR-0044; #155 step 2).
3
+ *
4
+ * The store owns **durability**: schema validation, transition-identity uniqueness,
5
+ * expected-revision atomicity, the durable commit. It does not own the reservation lifecycle —
6
+ * a store that knew the state machine would be a store making policy, and `SpendService` owns that.
7
+ *
8
+ * ## Why there is no `append()`
9
+ *
10
+ * An unconditional append beside a conditional one is an unsafe API a future caller reaches for,
11
+ * so the unsafe path is unrepresentable rather than discouraged.
12
+ *
13
+ * ## Why the lease is not the safety property
14
+ *
15
+ * `LockManager` **detects** lease loss; it does not fence. `withLock` reports `LOCK_LOST` after the
16
+ * body has run, so a holder that lost its lease can still write in the interval before it finds
17
+ * out. A revision comparison followed by an ordinary write is therefore still check-then-act: two
18
+ * writers can both pass the check at revision N, and a replacing write lets the slower one destroy
19
+ * the faster one's committed transition.
20
+ *
21
+ * The linearization point is `link()`, which refuses to replace an existing target. One commit file
22
+ * **is** one revision, so exactly one writer can create a given name.
23
+ */
24
+ import { type SpendReservation, type SpendReservationTransition } from "@aldus-runtime/core";
25
+ import type { LockManager } from "./lock.js";
26
+ /** A grant's committed history, and the revision a writer must expect to still be current. */
27
+ export interface GrantReservationStream {
28
+ grantId: string;
29
+ /**
30
+ * Number of **commits**, not of transitions.
31
+ *
32
+ * One commit may carry several transitions — a reconciliation must land atomically with the
33
+ * settlement it justifies. Defining this as `transitions.length` would make the revision depend
34
+ * on batch shapes, so two writers proposing different batches could compute the same expected
35
+ * revision from different histories.
36
+ */
37
+ revision: number;
38
+ /** Every transition, flattened across commits, in commit order. For reduction. */
39
+ transitions: readonly SpendReservationTransition[];
40
+ }
41
+ /** @see SpendReservationStore.compareAndAppend */
42
+ export type CompareAndAppendResult =
43
+ /** This call created a new durable fact. */
44
+ {
45
+ kind: "appended";
46
+ revision: number;
47
+ }
48
+ /** These exact transitions were already committed, by an earlier attempt of this same call. */
49
+ | {
50
+ kind: "already_present";
51
+ revision: number;
52
+ }
53
+ /** Another commit won the successor of the expected revision. Re-read and recompute. */
54
+ | {
55
+ kind: "conflict";
56
+ currentRevision: number;
57
+ };
58
+ /** Durable transition storage with atomic conditional commit (ADR-0044). */
59
+ export interface SpendReservationStore {
60
+ readGrant(grantId: string): Promise<GrantReservationStream>;
61
+ /**
62
+ * Commit iff the stream is still at `expectedRevision`.
63
+ *
64
+ * Decision order, so a retry is deterministic:
65
+ *
66
+ * 1. every supplied identity present with byte-identical contents → `already_present`;
67
+ * 2. any supplied identity present with different contents → refuse;
68
+ * 3. `expectedRevision` stale → `conflict`;
69
+ * 4. otherwise attempt the conditional commit.
70
+ *
71
+ * Rule 1 outranks rule 3 so a caller that committed and lost the response terminates instead of
72
+ * looping to rediscover its own success.
73
+ */
74
+ compareAndAppend(input: {
75
+ grantId: string;
76
+ expectedRevision: number;
77
+ transitions: readonly SpendReservationTransition[];
78
+ }): Promise<CompareAndAppendResult>;
79
+ get(reservationId: string): Promise<SpendReservation | undefined>;
80
+ listByRun(runId: string): Promise<readonly SpendReservation[]>;
81
+ }
82
+ /** Wiring for {@link FileSpendReservationStore}. */
83
+ export interface FileSpendReservationStoreOptions {
84
+ root: string;
85
+ /** Reduces contention. **Not** the correctness mechanism — see the module docstring. */
86
+ locks?: LockManager;
87
+ }
88
+ export declare class FileSpendReservationStore implements SpendReservationStore {
89
+ #private;
90
+ constructor(options: FileSpendReservationStoreOptions);
91
+ readGrant(grantId: string): Promise<GrantReservationStream>;
92
+ compareAndAppend(input: {
93
+ grantId: string;
94
+ expectedRevision: number;
95
+ transitions: readonly SpendReservationTransition[];
96
+ }): Promise<CompareAndAppendResult>;
97
+ get(reservationId: string): Promise<SpendReservation | undefined>;
98
+ listByRun(runId: string): Promise<readonly SpendReservation[]>;
99
+ /**
100
+ * Where this store looks. Exposed so a refusal can name the path it searched: "no reservation
101
+ * here" and "I could not look" read identically to an operator otherwise (§19.2).
102
+ */
103
+ get root(): string;
104
+ }
105
+ //# sourceMappingURL=reservation-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reservation-store.d.ts","sourceRoot":"","sources":["../src/reservation-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAG7B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAE7C,8FAA8F;AAC9F,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,WAAW,EAAE,SAAS,0BAA0B,EAAE,CAAC;CACpD;AAED,kDAAkD;AAClD,MAAM,MAAM,sBAAsB;AAChC,4CAA4C;AAC1C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AACxC,+FAA+F;GAC7F;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE;AAC/C,wFAAwF;GACtF;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,CAAC;AAElD,4EAA4E;AAC5E,MAAM,WAAW,qBAAqB;IACpC,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC5D;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,KAAK,EAAE;QACtB,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,EAAE,MAAM,CAAC;QACzB,WAAW,EAAE,SAAS,0BAA0B,EAAE,CAAC;KACpD,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACpC,GAAG,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAAC;IAClE,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CAAC;CAChE;AAQD,oDAAoD;AACpD,MAAM,WAAW,gCAAgC;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,wFAAwF;IACxF,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAOD,qBAAa,yBAA0B,YAAW,qBAAqB;;IAIrE,YAAY,OAAO,EAAE,gCAAgC,EAGpD;IAEK,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAOhE;IAEK,gBAAgB,CAAC,KAAK,EAAE;QAC5B,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,EAAE,MAAM,CAAC;QACzB,WAAW,EAAE,SAAS,0BAA0B,EAAE,CAAC;KACpD,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA0ClC;IAEK,GAAG,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAYtE;IAEK,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,gBAAgB,EAAE,CAAC,CAanE;IAuFD;;;OAGG;IACH,IAAI,IAAI,IAAI,MAAM,CAEjB;CAgEF"}
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Durable storage for spend reservation transitions (ADR-0044; #155 step 2).
3
+ *
4
+ * The store owns **durability**: schema validation, transition-identity uniqueness,
5
+ * expected-revision atomicity, the durable commit. It does not own the reservation lifecycle —
6
+ * a store that knew the state machine would be a store making policy, and `SpendService` owns that.
7
+ *
8
+ * ## Why there is no `append()`
9
+ *
10
+ * An unconditional append beside a conditional one is an unsafe API a future caller reaches for,
11
+ * so the unsafe path is unrepresentable rather than discouraged.
12
+ *
13
+ * ## Why the lease is not the safety property
14
+ *
15
+ * `LockManager` **detects** lease loss; it does not fence. `withLock` reports `LOCK_LOST` after the
16
+ * body has run, so a holder that lost its lease can still write in the interval before it finds
17
+ * out. A revision comparison followed by an ordinary write is therefore still check-then-act: two
18
+ * writers can both pass the check at revision N, and a replacing write lets the slower one destroy
19
+ * the faster one's committed transition.
20
+ *
21
+ * The linearization point is `link()`, which refuses to replace an existing target. One commit file
22
+ * **is** one revision, so exactly one writer can create a given name.
23
+ */
24
+ import { link, mkdir, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
25
+ import { join } from "node:path";
26
+ import { spendReservationTransitionSchema, reduceReservations, } from "@aldus-runtime/core";
27
+ import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
28
+ // The store has no retry policy on purpose. A conflict is returned, not absorbed: the caller's
29
+ // availability answer was computed against a stream that no longer exists, and only the caller can
30
+ // recompute it. A store that retried internally would hide that from the caller — the same reason
31
+ // ADR-0044 rejects a callback transaction.
32
+ export class FileSpendReservationStore {
33
+ #root;
34
+ #locks;
35
+ constructor(options) {
36
+ this.#root = options.root;
37
+ this.#locks = options.locks;
38
+ }
39
+ async readGrant(grantId) {
40
+ const commits = await this.#readCommits(grantId);
41
+ return {
42
+ grantId,
43
+ revision: commits.length,
44
+ transitions: commits.flatMap((commit) => commit.transitions),
45
+ };
46
+ }
47
+ async compareAndAppend(input) {
48
+ for (const transition of input.transitions) {
49
+ const parsed = spendReservationTransitionSchema.safeParse(transition);
50
+ if (!parsed.success) {
51
+ throw fileStoreError(FileStoreErrorCodes.RESERVATION_TRANSITION_INVALID, "A reservation transition does not satisfy its schema, so it must not become durable.", {
52
+ category: "validation",
53
+ retryable: false,
54
+ details: { grantId: input.grantId, issue: parsed.error.issues[0]?.path.join(".") },
55
+ });
56
+ }
57
+ }
58
+ const commit = async () => {
59
+ const commits = await this.#readCommits(input.grantId);
60
+ const identity = this.#checkIdentities(commits, input.transitions, input.grantId);
61
+ if (identity !== undefined)
62
+ return identity;
63
+ if (commits.length !== input.expectedRevision) {
64
+ return { kind: "conflict", currentRevision: commits.length };
65
+ }
66
+ return this.#install(input.grantId, commits.length + 1, [...input.transitions]);
67
+ };
68
+ // The lease reduces contention and nothing more: `#install` is safe without it, because
69
+ // `link()` refuses to replace a winner. If the lease is lost, the commit's own identity is
70
+ // what answers whether it landed — see `#install`.
71
+ const locks = this.#locks;
72
+ if (locks === undefined)
73
+ return commit();
74
+ try {
75
+ return await locks.withLock(`spend-reservation:${input.grantId}`, commit);
76
+ }
77
+ catch (error) {
78
+ // `LOCK_LOST` after a successful commit is not a failure: the commit is immutable and ours.
79
+ if (error.code === FileStoreErrorCodes.LOCK_LOST) {
80
+ const commits = await this.#readCommits(input.grantId);
81
+ const identity = this.#checkIdentities(commits, input.transitions, input.grantId);
82
+ if (identity !== undefined)
83
+ return identity;
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+ async get(reservationId) {
89
+ // An index would be a hint, and a hint cannot establish absence: a syntactically valid but
90
+ // stale index is missing the newest reservation, and nothing about it looks damaged. So a miss
91
+ // scans the canonical streams rather than returning `undefined` (ADR-0044).
92
+ for (const grantId of await this.#grantIds()) {
93
+ const stream = await this.readGrant(grantId);
94
+ const found = reduceReservations(stream.transitions).find((reservation) => reservation.reservationId === reservationId);
95
+ if (found !== undefined)
96
+ return found;
97
+ }
98
+ return undefined;
99
+ }
100
+ async listByRun(runId) {
101
+ // Scans for the same reason: completeness is a claim about everything, and an index is a claim
102
+ // about what it happened to record.
103
+ const found = [];
104
+ for (const grantId of await this.#grantIds()) {
105
+ const stream = await this.readGrant(grantId);
106
+ found.push(...reduceReservations(stream.transitions).filter((reservation) => reservation.runId === runId));
107
+ }
108
+ return found;
109
+ }
110
+ /** Rule 1 and rule 2 of the decision order. */
111
+ #checkIdentities(commits, proposed, grantId) {
112
+ const present = new Map();
113
+ for (const commit of commits) {
114
+ for (const transition of commit.transitions)
115
+ present.set(transition.transitionId, transition);
116
+ }
117
+ let allPresent = proposed.length > 0;
118
+ for (const transition of proposed) {
119
+ const existing = present.get(transition.transitionId);
120
+ if (existing === undefined) {
121
+ allPresent = false;
122
+ continue;
123
+ }
124
+ if (JSON.stringify(existing) !== JSON.stringify(transition)) {
125
+ throw fileStoreError(FileStoreErrorCodes.RESERVATION_TRANSITION_CONFLICT, `Transition "${transition.transitionId}" already exists with different contents. Two ` +
126
+ "different facts under one identity cannot both be true, so this is refused rather " +
127
+ "than resolved (ADR-0044).", {
128
+ category: "conflict",
129
+ retryable: false,
130
+ details: { grantId, transitionId: transition.transitionId },
131
+ });
132
+ }
133
+ }
134
+ return allPresent ? { kind: "already_present", revision: commits.length } : undefined;
135
+ }
136
+ /**
137
+ * The linearization point, and the durability step after it.
138
+ *
139
+ * `link()` wins the revision; the directory `fsync` is what permits acknowledging it. Between the
140
+ * two the writer has won and cannot prove it survived a power loss, so the commit is not
141
+ * reported as appended — the caller retries with the same stable identities and rule 1 resolves
142
+ * it either way.
143
+ */
144
+ async #install(grantId, revision, transitions) {
145
+ const dir = join(this.#root, grantId, "commits");
146
+ await mkdir(dir, { recursive: true });
147
+ const final = join(dir, `${String(revision).padStart(6, "0")}.json`);
148
+ const temp = `${final}.${Math.random().toString(36).slice(2)}.tmp`;
149
+ const payload = { revision, transitions };
150
+ // Complete before visible: a partial file that *exists* would block the true winner from this
151
+ // revision forever, which is why this is not `open(final, "wx")` and a direct write.
152
+ await writeFile(temp, JSON.stringify(payload), "utf8");
153
+ await this.#fsync(temp);
154
+ try {
155
+ await link(temp, final);
156
+ }
157
+ catch (error) {
158
+ await unlink(temp).catch(() => undefined);
159
+ if (error.code === "EEXIST") {
160
+ const commits = await this.#readCommits(grantId);
161
+ return { kind: "conflict", currentRevision: commits.length };
162
+ }
163
+ throw error;
164
+ }
165
+ await unlink(temp).catch(() => undefined);
166
+ // Durability, separate from linearization. Only after this may the commit be acknowledged.
167
+ await this.#fsync(dir);
168
+ return { kind: "appended", revision };
169
+ }
170
+ async #fsync(path) {
171
+ const handle = await open(path, "r").catch(() => undefined);
172
+ if (handle === undefined)
173
+ return;
174
+ try {
175
+ await handle.sync();
176
+ }
177
+ finally {
178
+ await handle.close();
179
+ }
180
+ }
181
+ /**
182
+ * Where this store looks. Exposed so a refusal can name the path it searched: "no reservation
183
+ * here" and "I could not look" read identically to an operator otherwise (§19.2).
184
+ */
185
+ get root() {
186
+ return this.#root;
187
+ }
188
+ async #grantIds() {
189
+ try {
190
+ return (await readdir(this.#root, { withFileTypes: true }))
191
+ .filter((entry) => entry.isDirectory())
192
+ .map((entry) => entry.name);
193
+ }
194
+ catch (error) {
195
+ // A root that does not exist yet is a workspace that has reserved nothing — an ordinary
196
+ // empty answer. Anything else is the instrument failing to look: a permission error, a file
197
+ // where the directory should be, a root pointed at a composition's idea of the layout rather
198
+ // than this one's. Swallowing those returned `[]`, and `[]` is indistinguishable from a
199
+ // truthful "no reservations" — so `costs` reported an empty ledger for a workspace holding
200
+ // real money and `settle` answered "holds no reservation", both stating a fact about the
201
+ // world when the instrument had never reached it.
202
+ //
203
+ // The distinction is the point: an empty answer must come from an empty store, never from a
204
+ // failure to read one.
205
+ if (error.code === "ENOENT")
206
+ return [];
207
+ throw error;
208
+ }
209
+ }
210
+ async #readCommits(grantId) {
211
+ const dir = join(this.#root, grantId, "commits");
212
+ let names;
213
+ try {
214
+ names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
215
+ }
216
+ catch (error) {
217
+ // The same distinction `#grantIds` makes, one level down, where it was still missing.
218
+ // A grant directory that does not exist is a grant that has committed nothing — an ordinary
219
+ // empty answer. A permission error, a file where the directory should be, an I/O failure:
220
+ // those are the instrument failing to look, and returning `[]` for them made a grant holding
221
+ // a live reservation read as "nothing reserved" to `costs`, to `settle`, and to every caller
222
+ // that asks what authorization is committed.
223
+ //
224
+ // An empty answer must come from an empty store, never from a failure to read one.
225
+ if (error.code === "ENOENT")
226
+ return [];
227
+ throw error;
228
+ }
229
+ const commits = [];
230
+ for (const [index, name] of names.entries()) {
231
+ const parsed = JSON.parse(await readFile(join(dir, name), "utf8"));
232
+ // A gap is not producible by a correct writer — a commit may only claim N+1 having read N as
233
+ // current — so a gap is corruption. Refusing beats reducing a shorter stream and reporting a
234
+ // smaller balance as though it were the answer.
235
+ if (parsed.revision !== index + 1) {
236
+ throw fileStoreError(FileStoreErrorCodes.RESERVATION_STREAM_CORRUPT, `The reservation stream for grant "${grantId}" is missing revision ${index + 1}. A gap ` +
237
+ "cannot be produced by a correct writer, so this stream is not projected: a shorter " +
238
+ "history would understate committed authorization.", {
239
+ category: "conflict",
240
+ retryable: false,
241
+ details: { grantId, expectedRevision: index + 1, found: parsed.revision },
242
+ });
243
+ }
244
+ commits.push(parsed);
245
+ }
246
+ return commits;
247
+ }
248
+ }
249
+ //# sourceMappingURL=reservation-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reservation-store.js","sourceRoot":"","sources":["../src/reservation-store.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aldus-runtime/file-store",
3
- "version": "0.2.0-next.6",
3
+ "version": "0.2.0-next.60",
4
4
  "type": "module",
5
5
  "description": "Aldus File Store — file-backed Episode, Run, and Event stores with atomic writes and crash recovery.",
6
6
  "license": "Apache-2.0",
@@ -33,10 +33,10 @@
33
33
  "typecheck:test": "tsc -p tsconfig.test.json"
34
34
  },
35
35
  "dependencies": {
36
- "@aldus-runtime/core": "0.2.0-next.6"
36
+ "@aldus-runtime/core": "0.2.0-next.60"
37
37
  },
38
38
  "devDependencies": {
39
- "@aldus-runtime/testkit": "0.2.0-next.6",
39
+ "@aldus-runtime/testkit": "0.2.0-next.60",
40
40
  "@types/node": "^26.2.0",
41
41
  "typescript": "^7.0.2",
42
42
  "vitest": "^4.1.10"
package/src/errors.ts CHANGED
@@ -39,6 +39,12 @@ export const FileStoreErrorCodes = {
39
39
  LOCK_TIMEOUT: "ALDUS_LOCK_TIMEOUT",
40
40
  /** A lock was released or renewed by something that no longer holds it. */
41
41
  LOCK_LOST: "ALDUS_LOCK_LOST",
42
+ /** A reservation transition does not satisfy its schema (ADR-0044). */
43
+ RESERVATION_TRANSITION_INVALID: "ALDUS_RESERVATION_TRANSITION_INVALID",
44
+ /** One transition id was reused for a different fact (ADR-0044). */
45
+ RESERVATION_TRANSITION_CONFLICT: "ALDUS_RESERVATION_TRANSITION_CONFLICT",
46
+ /** A reservation stream has a gap, which no correct writer can produce (ADR-0044). */
47
+ RESERVATION_STREAM_CORRUPT: "ALDUS_RESERVATION_STREAM_CORRUPT",
42
48
  /**
43
49
  * A lock was re-acquired inside a scope that already holds it.
44
50
  *
package/src/index.ts CHANGED
@@ -94,3 +94,11 @@ export {
94
94
  openWorkspace,
95
95
  type OpenWorkspaceOptions,
96
96
  } from "./workspace.js";
97
+
98
+ export {
99
+ FileSpendReservationStore,
100
+ type CompareAndAppendResult,
101
+ type FileSpendReservationStoreOptions,
102
+ type GrantReservationStream,
103
+ type SpendReservationStore,
104
+ } from "./reservation-store.js";
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Durable storage for spend reservation transitions (ADR-0044; #155 step 2).
3
+ *
4
+ * The store owns **durability**: schema validation, transition-identity uniqueness,
5
+ * expected-revision atomicity, the durable commit. It does not own the reservation lifecycle —
6
+ * a store that knew the state machine would be a store making policy, and `SpendService` owns that.
7
+ *
8
+ * ## Why there is no `append()`
9
+ *
10
+ * An unconditional append beside a conditional one is an unsafe API a future caller reaches for,
11
+ * so the unsafe path is unrepresentable rather than discouraged.
12
+ *
13
+ * ## Why the lease is not the safety property
14
+ *
15
+ * `LockManager` **detects** lease loss; it does not fence. `withLock` reports `LOCK_LOST` after the
16
+ * body has run, so a holder that lost its lease can still write in the interval before it finds
17
+ * out. A revision comparison followed by an ordinary write is therefore still check-then-act: two
18
+ * writers can both pass the check at revision N, and a replacing write lets the slower one destroy
19
+ * the faster one's committed transition.
20
+ *
21
+ * The linearization point is `link()`, which refuses to replace an existing target. One commit file
22
+ * **is** one revision, so exactly one writer can create a given name.
23
+ */
24
+
25
+ import { link, mkdir, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+
28
+ import {
29
+ spendReservationTransitionSchema,
30
+ reduceReservations,
31
+ type SpendReservation,
32
+ type SpendReservationTransition,
33
+ } from "@aldus-runtime/core";
34
+
35
+ import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
36
+ import type { LockManager } from "./lock.js";
37
+
38
+ /** A grant's committed history, and the revision a writer must expect to still be current. */
39
+ export interface GrantReservationStream {
40
+ grantId: string;
41
+ /**
42
+ * Number of **commits**, not of transitions.
43
+ *
44
+ * One commit may carry several transitions — a reconciliation must land atomically with the
45
+ * settlement it justifies. Defining this as `transitions.length` would make the revision depend
46
+ * on batch shapes, so two writers proposing different batches could compute the same expected
47
+ * revision from different histories.
48
+ */
49
+ revision: number;
50
+ /** Every transition, flattened across commits, in commit order. For reduction. */
51
+ transitions: readonly SpendReservationTransition[];
52
+ }
53
+
54
+ /** @see SpendReservationStore.compareAndAppend */
55
+ export type CompareAndAppendResult =
56
+ /** This call created a new durable fact. */
57
+ | { kind: "appended"; revision: number }
58
+ /** These exact transitions were already committed, by an earlier attempt of this same call. */
59
+ | { kind: "already_present"; revision: number }
60
+ /** Another commit won the successor of the expected revision. Re-read and recompute. */
61
+ | { kind: "conflict"; currentRevision: number };
62
+
63
+ /** Durable transition storage with atomic conditional commit (ADR-0044). */
64
+ export interface SpendReservationStore {
65
+ readGrant(grantId: string): Promise<GrantReservationStream>;
66
+ /**
67
+ * Commit iff the stream is still at `expectedRevision`.
68
+ *
69
+ * Decision order, so a retry is deterministic:
70
+ *
71
+ * 1. every supplied identity present with byte-identical contents → `already_present`;
72
+ * 2. any supplied identity present with different contents → refuse;
73
+ * 3. `expectedRevision` stale → `conflict`;
74
+ * 4. otherwise attempt the conditional commit.
75
+ *
76
+ * Rule 1 outranks rule 3 so a caller that committed and lost the response terminates instead of
77
+ * looping to rediscover its own success.
78
+ */
79
+ compareAndAppend(input: {
80
+ grantId: string;
81
+ expectedRevision: number;
82
+ transitions: readonly SpendReservationTransition[];
83
+ }): Promise<CompareAndAppendResult>;
84
+ get(reservationId: string): Promise<SpendReservation | undefined>;
85
+ listByRun(runId: string): Promise<readonly SpendReservation[]>;
86
+ }
87
+
88
+ /** One committed batch, as stored. */
89
+ interface CommitFile {
90
+ revision: number;
91
+ transitions: SpendReservationTransition[];
92
+ }
93
+
94
+ /** Wiring for {@link FileSpendReservationStore}. */
95
+ export interface FileSpendReservationStoreOptions {
96
+ root: string;
97
+ /** Reduces contention. **Not** the correctness mechanism — see the module docstring. */
98
+ locks?: LockManager;
99
+ }
100
+
101
+ // The store has no retry policy on purpose. A conflict is returned, not absorbed: the caller's
102
+ // availability answer was computed against a stream that no longer exists, and only the caller can
103
+ // recompute it. A store that retried internally would hide that from the caller — the same reason
104
+ // ADR-0044 rejects a callback transaction.
105
+
106
+ export class FileSpendReservationStore implements SpendReservationStore {
107
+ readonly #root: string;
108
+ readonly #locks: LockManager | undefined;
109
+
110
+ constructor(options: FileSpendReservationStoreOptions) {
111
+ this.#root = options.root;
112
+ this.#locks = options.locks;
113
+ }
114
+
115
+ async readGrant(grantId: string): Promise<GrantReservationStream> {
116
+ const commits = await this.#readCommits(grantId);
117
+ return {
118
+ grantId,
119
+ revision: commits.length,
120
+ transitions: commits.flatMap((commit) => commit.transitions),
121
+ };
122
+ }
123
+
124
+ async compareAndAppend(input: {
125
+ grantId: string;
126
+ expectedRevision: number;
127
+ transitions: readonly SpendReservationTransition[];
128
+ }): Promise<CompareAndAppendResult> {
129
+ for (const transition of input.transitions) {
130
+ const parsed = spendReservationTransitionSchema.safeParse(transition);
131
+ if (!parsed.success) {
132
+ throw fileStoreError(
133
+ FileStoreErrorCodes.RESERVATION_TRANSITION_INVALID,
134
+ "A reservation transition does not satisfy its schema, so it must not become durable.",
135
+ {
136
+ category: "validation",
137
+ retryable: false,
138
+ details: { grantId: input.grantId, issue: parsed.error.issues[0]?.path.join(".") },
139
+ },
140
+ );
141
+ }
142
+ }
143
+
144
+ const commit = async (): Promise<CompareAndAppendResult> => {
145
+ const commits = await this.#readCommits(input.grantId);
146
+ const identity = this.#checkIdentities(commits, input.transitions, input.grantId);
147
+ if (identity !== undefined) return identity;
148
+ if (commits.length !== input.expectedRevision) {
149
+ return { kind: "conflict", currentRevision: commits.length };
150
+ }
151
+ return this.#install(input.grantId, commits.length + 1, [...input.transitions]);
152
+ };
153
+
154
+ // The lease reduces contention and nothing more: `#install` is safe without it, because
155
+ // `link()` refuses to replace a winner. If the lease is lost, the commit's own identity is
156
+ // what answers whether it landed — see `#install`.
157
+ const locks = this.#locks;
158
+ if (locks === undefined) return commit();
159
+ try {
160
+ return await locks.withLock(`spend-reservation:${input.grantId}`, commit);
161
+ } catch (error) {
162
+ // `LOCK_LOST` after a successful commit is not a failure: the commit is immutable and ours.
163
+ if ((error as { code?: string }).code === FileStoreErrorCodes.LOCK_LOST) {
164
+ const commits = await this.#readCommits(input.grantId);
165
+ const identity = this.#checkIdentities(commits, input.transitions, input.grantId);
166
+ if (identity !== undefined) return identity;
167
+ }
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ async get(reservationId: string): Promise<SpendReservation | undefined> {
173
+ // An index would be a hint, and a hint cannot establish absence: a syntactically valid but
174
+ // stale index is missing the newest reservation, and nothing about it looks damaged. So a miss
175
+ // scans the canonical streams rather than returning `undefined` (ADR-0044).
176
+ for (const grantId of await this.#grantIds()) {
177
+ const stream = await this.readGrant(grantId);
178
+ const found = reduceReservations(stream.transitions).find(
179
+ (reservation) => reservation.reservationId === reservationId,
180
+ );
181
+ if (found !== undefined) return found;
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ async listByRun(runId: string): Promise<readonly SpendReservation[]> {
187
+ // Scans for the same reason: completeness is a claim about everything, and an index is a claim
188
+ // about what it happened to record.
189
+ const found: SpendReservation[] = [];
190
+ for (const grantId of await this.#grantIds()) {
191
+ const stream = await this.readGrant(grantId);
192
+ found.push(
193
+ ...reduceReservations(stream.transitions).filter(
194
+ (reservation) => reservation.runId === runId,
195
+ ),
196
+ );
197
+ }
198
+ return found;
199
+ }
200
+
201
+ /** Rule 1 and rule 2 of the decision order. */
202
+ #checkIdentities(
203
+ commits: readonly CommitFile[],
204
+ proposed: readonly SpendReservationTransition[],
205
+ grantId: string,
206
+ ): CompareAndAppendResult | undefined {
207
+ const present = new Map<string, SpendReservationTransition>();
208
+ for (const commit of commits) {
209
+ for (const transition of commit.transitions) present.set(transition.transitionId, transition);
210
+ }
211
+
212
+ let allPresent = proposed.length > 0;
213
+ for (const transition of proposed) {
214
+ const existing = present.get(transition.transitionId);
215
+ if (existing === undefined) {
216
+ allPresent = false;
217
+ continue;
218
+ }
219
+ if (JSON.stringify(existing) !== JSON.stringify(transition)) {
220
+ throw fileStoreError(
221
+ FileStoreErrorCodes.RESERVATION_TRANSITION_CONFLICT,
222
+ `Transition "${transition.transitionId}" already exists with different contents. Two ` +
223
+ "different facts under one identity cannot both be true, so this is refused rather " +
224
+ "than resolved (ADR-0044).",
225
+ {
226
+ category: "conflict",
227
+ retryable: false,
228
+ details: { grantId, transitionId: transition.transitionId },
229
+ },
230
+ );
231
+ }
232
+ }
233
+ return allPresent ? { kind: "already_present", revision: commits.length } : undefined;
234
+ }
235
+
236
+ /**
237
+ * The linearization point, and the durability step after it.
238
+ *
239
+ * `link()` wins the revision; the directory `fsync` is what permits acknowledging it. Between the
240
+ * two the writer has won and cannot prove it survived a power loss, so the commit is not
241
+ * reported as appended — the caller retries with the same stable identities and rule 1 resolves
242
+ * it either way.
243
+ */
244
+ async #install(
245
+ grantId: string,
246
+ revision: number,
247
+ transitions: SpendReservationTransition[],
248
+ ): Promise<CompareAndAppendResult> {
249
+ const dir = join(this.#root, grantId, "commits");
250
+ await mkdir(dir, { recursive: true });
251
+ const final = join(dir, `${String(revision).padStart(6, "0")}.json`);
252
+ const temp = `${final}.${Math.random().toString(36).slice(2)}.tmp`;
253
+
254
+ const payload: CommitFile = { revision, transitions };
255
+ // Complete before visible: a partial file that *exists* would block the true winner from this
256
+ // revision forever, which is why this is not `open(final, "wx")` and a direct write.
257
+ await writeFile(temp, JSON.stringify(payload), "utf8");
258
+ await this.#fsync(temp);
259
+
260
+ try {
261
+ await link(temp, final);
262
+ } catch (error) {
263
+ await unlink(temp).catch(() => undefined);
264
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
265
+ const commits = await this.#readCommits(grantId);
266
+ return { kind: "conflict", currentRevision: commits.length };
267
+ }
268
+ throw error;
269
+ }
270
+ await unlink(temp).catch(() => undefined);
271
+ // Durability, separate from linearization. Only after this may the commit be acknowledged.
272
+ await this.#fsync(dir);
273
+ return { kind: "appended", revision };
274
+ }
275
+
276
+ async #fsync(path: string): Promise<void> {
277
+ const handle = await open(path, "r").catch(() => undefined);
278
+ if (handle === undefined) return;
279
+ try {
280
+ await handle.sync();
281
+ } finally {
282
+ await handle.close();
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Where this store looks. Exposed so a refusal can name the path it searched: "no reservation
288
+ * here" and "I could not look" read identically to an operator otherwise (§19.2).
289
+ */
290
+ get root(): string {
291
+ return this.#root;
292
+ }
293
+
294
+ async #grantIds(): Promise<string[]> {
295
+ try {
296
+ return (await readdir(this.#root, { withFileTypes: true }))
297
+ .filter((entry) => entry.isDirectory())
298
+ .map((entry) => entry.name);
299
+ } catch (error) {
300
+ // A root that does not exist yet is a workspace that has reserved nothing — an ordinary
301
+ // empty answer. Anything else is the instrument failing to look: a permission error, a file
302
+ // where the directory should be, a root pointed at a composition's idea of the layout rather
303
+ // than this one's. Swallowing those returned `[]`, and `[]` is indistinguishable from a
304
+ // truthful "no reservations" — so `costs` reported an empty ledger for a workspace holding
305
+ // real money and `settle` answered "holds no reservation", both stating a fact about the
306
+ // world when the instrument had never reached it.
307
+ //
308
+ // The distinction is the point: an empty answer must come from an empty store, never from a
309
+ // failure to read one.
310
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ async #readCommits(grantId: string): Promise<CommitFile[]> {
316
+ const dir = join(this.#root, grantId, "commits");
317
+ let names: string[];
318
+ try {
319
+ names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
320
+ } catch (error) {
321
+ // The same distinction `#grantIds` makes, one level down, where it was still missing.
322
+ // A grant directory that does not exist is a grant that has committed nothing — an ordinary
323
+ // empty answer. A permission error, a file where the directory should be, an I/O failure:
324
+ // those are the instrument failing to look, and returning `[]` for them made a grant holding
325
+ // a live reservation read as "nothing reserved" to `costs`, to `settle`, and to every caller
326
+ // that asks what authorization is committed.
327
+ //
328
+ // An empty answer must come from an empty store, never from a failure to read one.
329
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
330
+ throw error;
331
+ }
332
+
333
+ const commits: CommitFile[] = [];
334
+ for (const [index, name] of names.entries()) {
335
+ const parsed = JSON.parse(await readFile(join(dir, name), "utf8")) as CommitFile;
336
+ // A gap is not producible by a correct writer — a commit may only claim N+1 having read N as
337
+ // current — so a gap is corruption. Refusing beats reducing a shorter stream and reporting a
338
+ // smaller balance as though it were the answer.
339
+ if (parsed.revision !== index + 1) {
340
+ throw fileStoreError(
341
+ FileStoreErrorCodes.RESERVATION_STREAM_CORRUPT,
342
+ `The reservation stream for grant "${grantId}" is missing revision ${index + 1}. A gap ` +
343
+ "cannot be produced by a correct writer, so this stream is not projected: a shorter " +
344
+ "history would understate committed authorization.",
345
+ {
346
+ category: "conflict",
347
+ retryable: false,
348
+ details: { grantId, expectedRevision: index + 1, found: parsed.revision },
349
+ },
350
+ );
351
+ }
352
+ commits.push(parsed);
353
+ }
354
+ return commits;
355
+ }
356
+ }