@aldus-runtime/file-store 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/dist/atomic.d.ts +65 -0
  4. package/dist/atomic.d.ts.map +1 -0
  5. package/dist/atomic.js +160 -0
  6. package/dist/atomic.js.map +1 -0
  7. package/dist/collections.d.ts +27 -0
  8. package/dist/collections.d.ts.map +1 -0
  9. package/dist/collections.js +58 -0
  10. package/dist/collections.js.map +1 -0
  11. package/dist/document.d.ts +66 -0
  12. package/dist/document.d.ts.map +1 -0
  13. package/dist/document.js +109 -0
  14. package/dist/document.js.map +1 -0
  15. package/dist/errors.d.ts +60 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +56 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts +28 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +27 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/jsonl.d.ts +62 -0
  24. package/dist/jsonl.d.ts.map +1 -0
  25. package/dist/jsonl.js +99 -0
  26. package/dist/jsonl.js.map +1 -0
  27. package/dist/layout.d.ts +54 -0
  28. package/dist/layout.d.ts.map +1 -0
  29. package/dist/layout.js +86 -0
  30. package/dist/layout.js.map +1 -0
  31. package/dist/lock.d.ts +80 -0
  32. package/dist/lock.d.ts.map +1 -0
  33. package/dist/lock.js +257 -0
  34. package/dist/lock.js.map +1 -0
  35. package/dist/ports.d.ts +104 -0
  36. package/dist/ports.d.ts.map +1 -0
  37. package/dist/ports.js +18 -0
  38. package/dist/ports.js.map +1 -0
  39. package/dist/stores.d.ts +56 -0
  40. package/dist/stores.d.ts.map +1 -0
  41. package/dist/stores.js +210 -0
  42. package/dist/stores.js.map +1 -0
  43. package/dist/workspace.d.ts +37 -0
  44. package/dist/workspace.d.ts.map +1 -0
  45. package/dist/workspace.js +48 -0
  46. package/dist/workspace.js.map +1 -0
  47. package/package.json +48 -0
  48. package/src/atomic.ts +185 -0
  49. package/src/collections.ts +94 -0
  50. package/src/document.ts +146 -0
  51. package/src/errors.ts +65 -0
  52. package/src/index.ts +96 -0
  53. package/src/jsonl.ts +149 -0
  54. package/src/layout.ts +105 -0
  55. package/src/lock.ts +359 -0
  56. package/src/ports.ts +126 -0
  57. package/src/stores.ts +295 -0
  58. package/src/workspace.ts +68 -0
package/dist/stores.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * File-backed implementations of the contract §7 storage ports.
3
+ *
4
+ * Every mutating operation runs under a lock (contract §19.1) and writes atomically, so an
5
+ * interrupted process leaves either the previous state or the new state, never a mixture. Every
6
+ * read-modify-write preserves properties written by a newer schema version (ADR-0004 decision 3).
7
+ *
8
+ * These stores store and retrieve. They do not decide whether a Run may advance (WP-04), whether
9
+ * a gate is satisfied (WP-05), or what an artifact's lineage is (WP-03).
10
+ */
11
+ import { readdir } from "node:fs/promises";
12
+ import { validateRecord, fromStructuredError, } from "@aldus-runtime/core";
13
+ import { appendLineSynced, isNotFound } from "./atomic.js";
14
+ import { appendToCollection, readCollection } from "./collections.js";
15
+ import { mergeForWrite, readDocument, writeDocument } from "./document.js";
16
+ import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
17
+ import { readJsonLines, toJsonLine } from "./jsonl.js";
18
+ import { EPISODE_LOCK_RESOURCE, WorkspaceLayout, runLockResource } from "./layout.js";
19
+ /** Schema backing each per-run collection file (contract §7). */
20
+ export const RUN_COLLECTION_SCHEMAS = {
21
+ artifacts: "ArtifactRef",
22
+ approvals: "GateDecision",
23
+ costs: "CostRecord",
24
+ release: "ReleaseReceipt",
25
+ };
26
+ /* -------------------------------------------------------------------------------------------
27
+ * Episode
28
+ * ---------------------------------------------------------------------------------------- */
29
+ /** `.aldus/episode.json` (contract §6.1, §7). */
30
+ export class FileEpisodeStore {
31
+ #layout;
32
+ #locks;
33
+ constructor(layout, locks) {
34
+ this.#layout = layout;
35
+ this.#locks = locks;
36
+ }
37
+ async get() {
38
+ const document = await readDocument(this.#layout.episodePath(), "EpisodeRef");
39
+ return document?.value;
40
+ }
41
+ async put(episode) {
42
+ await this.#locks.withLock(EPISODE_LOCK_RESOURCE, async () => {
43
+ await writeDocument(this.#layout.episodePath(), episode);
44
+ });
45
+ }
46
+ async update(mutate) {
47
+ return this.#locks.withLock(EPISODE_LOCK_RESOURCE, async () => {
48
+ const path = this.#layout.episodePath();
49
+ const document = await readDocument(path, "EpisodeRef");
50
+ if (document === undefined) {
51
+ throw fileStoreError(FileStoreErrorCodes.RECORD_NOT_FOUND, "This workspace has no Episode record, so there is nothing to update.", { category: "not_found", retryable: false, details: { path } });
52
+ }
53
+ const next = mutate(document.value);
54
+ assertValid("EpisodeRef", next);
55
+ await writeDocument(path, mergeForWrite(document, next));
56
+ return next;
57
+ });
58
+ }
59
+ }
60
+ /* -------------------------------------------------------------------------------------------
61
+ * Run
62
+ * ---------------------------------------------------------------------------------------- */
63
+ /** `.aldus/runs/{run-id}/` (contract §6.2, §7). */
64
+ export class FileRunStore {
65
+ #layout;
66
+ #locks;
67
+ constructor(layout, locks) {
68
+ this.#layout = layout;
69
+ this.#locks = locks;
70
+ }
71
+ async list() {
72
+ try {
73
+ const entries = await readdir(this.#layout.runsDirectory(), { withFileTypes: true });
74
+ return entries
75
+ .filter((entry) => entry.isDirectory())
76
+ .map((entry) => entry.name)
77
+ .sort();
78
+ }
79
+ catch (error) {
80
+ // A workspace with no runs yet is ordinary, not an error.
81
+ if (isNotFound(error))
82
+ return [];
83
+ throw error;
84
+ }
85
+ }
86
+ async get(runId) {
87
+ const document = await readDocument(this.#layout.runFilePath(runId, "manifest"), "RunManifest");
88
+ return document?.value;
89
+ }
90
+ async create(manifest) {
91
+ await this.#locks.withLock(runLockResource(manifest.runId), async () => {
92
+ const path = this.#layout.runFilePath(manifest.runId, "manifest");
93
+ const existing = await readDocument(path, "RunManifest");
94
+ if (existing !== undefined) {
95
+ throw fileStoreError(FileStoreErrorCodes.RECORD_IDENTITY_MISMATCH, `A Run manifest already exists for "${manifest.runId}". Creating it again would ` +
96
+ "overwrite an execution record, which contract §6.3 makes append-only.", { category: "conflict", retryable: false, details: { runId: manifest.runId } });
97
+ }
98
+ await writeDocument(path, manifest);
99
+ });
100
+ }
101
+ async update(runId, mutate) {
102
+ return this.#locks.withLock(runLockResource(runId), async () => {
103
+ const path = this.#layout.runFilePath(runId, "manifest");
104
+ const document = await readDocument(path, "RunManifest");
105
+ if (document === undefined) {
106
+ throw fileStoreError(FileStoreErrorCodes.RECORD_NOT_FOUND, `No Run manifest exists for "${runId}".`, { category: "not_found", retryable: false, details: { runId } });
107
+ }
108
+ const next = mutate(document.value);
109
+ if (next.runId !== runId) {
110
+ throw fileStoreError(FileStoreErrorCodes.RECORD_IDENTITY_MISMATCH, `An update to Run "${runId}" returned a manifest identifying itself as ` +
111
+ `"${next.runId}". Writing it would file one Run's state under another's identity.`, { category: "conflict", retryable: false, details: { runId, returnedRunId: next.runId } });
112
+ }
113
+ assertValid("RunManifest", next);
114
+ await writeDocument(path, mergeForWrite(document, next));
115
+ return next;
116
+ });
117
+ }
118
+ async listRecords(runId, collection) {
119
+ const schema = RUN_COLLECTION_SCHEMAS[collection];
120
+ const stored = await readCollection(this.#layout.runFilePath(runId, collection), schema);
121
+ return stored.values;
122
+ }
123
+ async addRecord(runId, collection, record) {
124
+ await this.#locks.withLock(runLockResource(runId), async () => {
125
+ const schema = RUN_COLLECTION_SCHEMAS[collection];
126
+ assertValid(schema, record);
127
+ await appendToCollection(this.#layout.runFilePath(runId, collection), schema, record);
128
+ });
129
+ }
130
+ }
131
+ /* -------------------------------------------------------------------------------------------
132
+ * Events
133
+ * ---------------------------------------------------------------------------------------- */
134
+ /** `.aldus/runs/{run-id}/events.jsonl` (contract §6.4, §7). */
135
+ export class FileEventStore {
136
+ #layout;
137
+ #locks;
138
+ constructor(layout, locks) {
139
+ this.#layout = layout;
140
+ this.#locks = locks;
141
+ }
142
+ async append(runId, event) {
143
+ return this.#locks.withLock(runLockResource(runId), async () => {
144
+ const path = this.#layout.runFilePath(runId, "events");
145
+ const existing = await this.#readValidated(path, {});
146
+ const expected = nextSequenceOf(existing.events);
147
+ if (event.sequence !== undefined && event.sequence !== expected) {
148
+ throw fileStoreError(FileStoreErrorCodes.EVENT_OUT_OF_SEQUENCE, `Event sequence ${event.sequence} does not follow the log, which expects ${expected}. ` +
149
+ "A per-run sequence is a total order (ADR-0005); a gap or a repeat would make the " +
150
+ "log unorderable across concurrent sessions.", {
151
+ category: "conflict",
152
+ retryable: false,
153
+ details: { runId, expected, received: event.sequence },
154
+ });
155
+ }
156
+ if (existing.events.some((stored) => stored.eventId === event.eventId)) {
157
+ throw fileStoreError(FileStoreErrorCodes.EVENT_DUPLICATE, `Event "${event.eventId}" is already in the log for Run "${runId}". Appending it again ` +
158
+ "would record one mutation twice (contract §6.4).", { category: "conflict", retryable: false, details: { runId, eventId: event.eventId } });
159
+ }
160
+ const stored = { ...event, sequence: expected };
161
+ assertValid("AldusEvent", stored);
162
+ await appendLineSynced(path, toJsonLine(stored));
163
+ return stored;
164
+ });
165
+ }
166
+ async read(runId, options = {}) {
167
+ return this.#readValidated(this.#layout.runFilePath(runId, "events"), options);
168
+ }
169
+ async nextSequence(runId) {
170
+ const result = await this.read(runId);
171
+ return nextSequenceOf(result.events);
172
+ }
173
+ async #readValidated(path, options) {
174
+ const lines = await readJsonLines(path, {
175
+ path,
176
+ ...(options.strictTail === undefined ? {} : { strictTail: options.strictTail }),
177
+ });
178
+ const events = [];
179
+ for (let index = 0; index < lines.values.length; index += 1) {
180
+ const result = validateRecord("AldusEvent", lines.values[index]);
181
+ if (!result.ok) {
182
+ const error = fromStructuredError(result.error);
183
+ throw fileStoreError(FileStoreErrorCodes.EVENT_LOG_CORRUPT, `Line ${index + 1} of the event log parsed as JSON but is not a valid AldusEvent: ${error.message}`, { category: "io", retryable: false, details: { path, line: index + 1 } });
184
+ }
185
+ events.push(result.value);
186
+ }
187
+ return lines.tornTail === undefined ? { events } : { events, tornTail: lines.tornTail };
188
+ }
189
+ }
190
+ /**
191
+ * The sequence the next event should carry.
192
+ *
193
+ * Derived from the highest stored sequence rather than the event count, so a log read after a
194
+ * torn-tail recovery still assigns a sequence strictly greater than anything already durable.
195
+ */
196
+ export function nextSequenceOf(events) {
197
+ let highest = -1;
198
+ for (const event of events) {
199
+ if (event.sequence !== undefined && event.sequence > highest)
200
+ highest = event.sequence;
201
+ }
202
+ return highest + 1;
203
+ }
204
+ /** Validate before writing, so a malformed record never reaches disk. */
205
+ function assertValid(schema, value) {
206
+ const result = validateRecord(schema, value);
207
+ if (!result.ok)
208
+ throw fromStructuredError(result.error);
209
+ }
210
+ //# sourceMappingURL=stores.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stores.js","sourceRoot":"","sources":["../src/stores.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,EACL,cAAc,EACd,mBAAmB,GAMpB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAYtF,iEAAiE;AACjE,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,SAAS,EAAE,aAAa;IACxB,SAAS,EAAE,cAAc;IACzB,KAAK,EAAE,YAAY;IACnB,OAAO,EAAE,gBAAgB;CACwC,CAAC;AAEpE;;8FAE8F;AAE9F,iDAAiD;AACjD,MAAM,OAAO,gBAAgB;IAClB,OAAO,CAAkB;IACzB,MAAM,CAAc;IAE7B,YAAY,MAAuB,EAAE,KAAkB;QACrD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,GAAG;QACP,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,YAAY,CAAC,CAAC;QAC9E,OAAO,QAAQ,EAAE,KAAK,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,OAAmB;QAC3B,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,EAAE,KAAK,IAAI,EAAE;YAC3D,MAAM,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,CAAC;QAC3D,CAAC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,MAA2C;QACtD,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,EAAE,KAAK,IAAI,EAAE;YAC5D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC;YACxC,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;YACxD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBAC3B,MAAM,cAAc,CAClB,mBAAmB,CAAC,gBAAgB,EACpC,sEAAsE,EACtE,EAAE,QAAQ,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,EAAE,CAC/D,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,WAAW,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC;YAChC,MAAM,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;YACzD,OAAO,IAAI,CAAC;QACd,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED;;8FAE8F;AAE9F,mDAAmD;AACnD,MAAM,OAAO,YAAY;IACd,OAAO,CAAkB;IACzB,MAAM,CAAc;IAE7B,YAAY,MAAuB,EAAE,KAAkB;QACrD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;YACrF,OAAO,OAAO;iBACX,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;iBACtC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;iBAC1B,IAAI,EAAE,CAAC;QACZ,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,0DAA0D;YAC1D,IAAI,UAAU,CAAC,KAAK,CAAC;gBAAE,OAAO,EAAE,CAAC;YACjC,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,KAAa;QACrB,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,EAAE,aAAa,CAAC,CAAC;QAChG,OAAO,QAAQ,EAAE,KAAK,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,QAAqB;QAChC,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE;YACrE,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;YAClE,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC;YACzD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBAC3B,MAAM,cAAc,CAClB,mBAAmB,CAAC,wBAAwB,EAC5C,sCAAsC,QAAQ,CAAC,KAAK,6BAA6B;oBAC/E,uEAAuE,EACzE,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,EAAE,CAC/E,CAAC;YACJ,CAAC;YACD,MAAM,aAAa,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QACtC,CAAC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,MAA6C;QACvE,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE;YAC7D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;YACzD,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC;YACzD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;gBAC3B,MAAM,cAAc,CAClB,mBAAmB,CAAC,gBAAgB,EACpC,+BAA+B,KAAK,IAAI,EACxC,EAAE,QAAQ,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,EAAE,CAChE,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;YACpC,IAAI,IAAI,CAAC,KAAK,KAAK,KAAK,EAAE,CAAC;gBACzB,MAAM,cAAc,CAClB,mBAAmB,CAAC,wBAAwB,EAC5C,qBAAqB,KAAK,8CAA8C;oBACtE,IAAI,IAAI,CAAC,KAAK,oEAAoE,EACpF,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,CAAC,KAAK,EAAE,EAAE,CAC1F,CAAC;YACJ,CAAC;YACD,WAAW,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;YACjC,MAAM,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;YACzD,OAAO,IAAI,CAAC;QACd,CAAC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,WAAW,CACf,KAAa,EACb,UAAa;QAEb,MAAM,MAAM,GAAG,sBAAsB,CAAC,UAAU,CAAC,CAAC;QAClD,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;QACzF,OAAO,MAAM,CAAC,MAAiC,CAAC;IAClD,CAAC;IAED,KAAK,CAAC,SAAS,CACb,KAAa,EACb,UAAa,EACb,MAA6B;QAE7B,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE;YAC5D,MAAM,MAAM,GAAG,sBAAsB,CAAC,UAAU,CAAC,CAAC;YAClD,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;YAC5B,MAAM,kBAAkB,CACtB,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,EAC3C,MAAM,EACN,MAAsC,CACvC,CAAC;QACJ,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED;;8FAE8F;AAE9F,+DAA+D;AAC/D,MAAM,OAAO,cAAc;IAChB,OAAO,CAAkB;IACzB,MAAM,CAAc;IAE7B,YAAY,MAAuB,EAAE,KAAkB;QACrD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,KAAiB;QAC3C,OAAO,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE;YAC7D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;YACvD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,cAAc,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YAErD,MAAM,QAAQ,GAAG,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACjD,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,IAAI,KAAK,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;gBAChE,MAAM,cAAc,CAClB,mBAAmB,CAAC,qBAAqB,EACzC,kBAAkB,KAAK,CAAC,QAAQ,2CAA2C,QAAQ,IAAI;oBACrF,mFAAmF;oBACnF,6CAA6C,EAC/C;oBACE,QAAQ,EAAE,UAAU;oBACpB,SAAS,EAAE,KAAK;oBAChB,OAAO,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE;iBACvD,CACF,CAAC;YACJ,CAAC;YAED,IAAI,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,KAAK,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;gBACvE,MAAM,cAAc,CAClB,mBAAmB,CAAC,eAAe,EACnC,UAAU,KAAK,CAAC,OAAO,oCAAoC,KAAK,wBAAwB;oBACtF,kDAAkD,EACpD,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,EAAE,CACvF,CAAC;YACJ,CAAC;YAED,MAAM,MAAM,GAAe,EAAE,GAAG,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;YAC5D,WAAW,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;YAClC,MAAM,gBAAgB,CAAC,IAAI,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC;YACjD,OAAO,MAAM,CAAC;QAChB,CAAC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,KAAa,EAAE,OAAO,GAAqB,EAAE;QACtD,OAAO,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;IACjF,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,KAAa;QAC9B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACtC,OAAO,cAAc,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACvC,CAAC;IAED,KAAK,CAAC,cAAc,CAAC,IAAY,EAAE,OAAyB;QAC1D,MAAM,KAAK,GAAG,MAAM,aAAa,CAAC,IAAI,EAAE;YACtC,IAAI;YACJ,GAAG,CAAC,OAAO,CAAC,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC;SAChF,CAAC,CAAC;QAEH,MAAM,MAAM,GAAiB,EAAE,CAAC;QAChC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;YAC5D,MAAM,MAAM,GAAG,cAAc,CAAC,YAAY,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;YACjE,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;gBACf,MAAM,KAAK,GAAG,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBAChD,MAAM,cAAc,CAClB,mBAAmB,CAAC,iBAAiB,EACrC,QAAQ,KAAK,GAAG,CAAC,mEAAmE,KAAK,CAAC,OAAO,EAAE,EACnG,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,GAAG,CAAC,EAAE,EAAE,CACzE,CAAC;YACJ,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QAC5B,CAAC;QAED,OAAO,KAAK,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC1F,CAAC;CACF;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,MAA6B;IAC1D,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC;IACjB,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,IAAI,KAAK,CAAC,QAAQ,GAAG,OAAO;YAAE,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC;IACzF,CAAC;IACD,OAAO,OAAO,GAAG,CAAC,CAAC;AACrB,CAAC;AAED,yEAAyE;AACzE,SAAS,WAAW,CAAgC,MAAS,EAAE,KAAc;IAC3E,MAAM,MAAM,GAAG,cAAc,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAC7C,IAAI,CAAC,MAAM,CAAC,EAAE;QAAE,MAAM,mBAAmB,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A file-backed Aldus workspace.
3
+ *
4
+ * Wires the contract §7 layout, the lock manager, and the three stores into one object, so a
5
+ * caller binds to a workspace once rather than threading paths through every call. Contract
6
+ * §19.2 requires workspace binding to be explicit; constructing this is that binding.
7
+ */
8
+ import { WorkspaceLayout } from "./layout.js";
9
+ import { type FileLockManagerOptions, type LockManager } from "./lock.js";
10
+ import { FileEpisodeStore, FileEventStore, FileRunStore } from "./stores.js";
11
+ /** Options for opening a workspace. */
12
+ export interface OpenWorkspaceOptions {
13
+ /** Replace the default file-based lock manager, e.g. with a distributed lease. */
14
+ locks?: LockManager;
15
+ /** Tuning for the default lock manager. Ignored when `locks` is supplied. */
16
+ lockOptions?: FileLockManagerOptions;
17
+ }
18
+ /** The file-backed stores for one workspace. */
19
+ export declare class FileWorkspace {
20
+ readonly layout: WorkspaceLayout;
21
+ readonly locks: LockManager;
22
+ readonly episodes: FileEpisodeStore;
23
+ readonly runs: FileRunStore;
24
+ readonly events: FileEventStore;
25
+ constructor(workspaceRoot: string, options?: OpenWorkspaceOptions);
26
+ }
27
+ /**
28
+ * Create the `.aldus` directory structure for a workspace.
29
+ *
30
+ * Idempotent. Writing a `.gitignore` inside `locks/` is deliberate: §7 recommends a Git-friendly
31
+ * layout, and a committed lockfile would carry another machine's PID into everyone's checkout and
32
+ * block the workspace until someone deleted it by hand.
33
+ */
34
+ export declare function initWorkspace(workspaceRoot: string): Promise<WorkspaceLayout>;
35
+ /** Open a workspace, creating its directory structure if absent. */
36
+ export declare function openWorkspace(workspaceRoot: string, options?: OpenWorkspaceOptions): Promise<FileWorkspace>;
37
+ //# sourceMappingURL=workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAmB,KAAK,sBAAsB,EAAE,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAC3F,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7E,uCAAuC;AACvC,MAAM,WAAW,oBAAoB;IACnC,kFAAkF;IAClF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,sBAAsB,CAAC;CACtC;AAED,gDAAgD;AAChD,qBAAa,aAAa;IACxB,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAEhC,YAAY,aAAa,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,EAOpE;CACF;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAUnF;AAED,oEAAoE;AACpE,wBAAsB,aAAa,CACjC,aAAa,EAAE,MAAM,EACrB,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,aAAa,CAAC,CAGxB"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A file-backed Aldus workspace.
3
+ *
4
+ * Wires the contract §7 layout, the lock manager, and the three stores into one object, so a
5
+ * caller binds to a workspace once rather than threading paths through every call. Contract
6
+ * §19.2 requires workspace binding to be explicit; constructing this is that binding.
7
+ */
8
+ import { mkdir, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { WorkspaceLayout } from "./layout.js";
11
+ import { FileLockManager } from "./lock.js";
12
+ import { FileEpisodeStore, FileEventStore, FileRunStore } from "./stores.js";
13
+ /** The file-backed stores for one workspace. */
14
+ export class FileWorkspace {
15
+ layout;
16
+ locks;
17
+ episodes;
18
+ runs;
19
+ events;
20
+ constructor(workspaceRoot, options = {}) {
21
+ this.layout = new WorkspaceLayout(workspaceRoot);
22
+ this.locks =
23
+ options.locks ?? new FileLockManager(this.layout.locksDirectory(), options.lockOptions ?? {});
24
+ this.episodes = new FileEpisodeStore(this.layout, this.locks);
25
+ this.runs = new FileRunStore(this.layout, this.locks);
26
+ this.events = new FileEventStore(this.layout, this.locks);
27
+ }
28
+ }
29
+ /**
30
+ * Create the `.aldus` directory structure for a workspace.
31
+ *
32
+ * Idempotent. Writing a `.gitignore` inside `locks/` is deliberate: §7 recommends a Git-friendly
33
+ * layout, and a committed lockfile would carry another machine's PID into everyone's checkout and
34
+ * block the workspace until someone deleted it by hand.
35
+ */
36
+ export async function initWorkspace(workspaceRoot) {
37
+ const layout = new WorkspaceLayout(workspaceRoot);
38
+ await mkdir(layout.runsDirectory(), { recursive: true });
39
+ await mkdir(layout.locksDirectory(), { recursive: true });
40
+ await writeFile(join(layout.locksDirectory(), ".gitignore"), "# Lockfiles are machine-local runtime state and must never be committed.\n*\n!.gitignore\n", "utf8");
41
+ return layout;
42
+ }
43
+ /** Open a workspace, creating its directory structure if absent. */
44
+ export async function openWorkspace(workspaceRoot, options = {}) {
45
+ await initWorkspace(workspaceRoot);
46
+ return new FileWorkspace(workspaceRoot, options);
47
+ }
48
+ //# sourceMappingURL=workspace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace.js","sourceRoot":"","sources":["../src/workspace.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAiD,MAAM,WAAW,CAAC;AAC3F,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAU7E,gDAAgD;AAChD,MAAM,OAAO,aAAa;IACf,MAAM,CAAkB;IACxB,KAAK,CAAc;IACnB,QAAQ,CAAmB;IAC3B,IAAI,CAAe;IACnB,MAAM,CAAiB;IAEhC,YAAY,aAAqB,EAAE,OAAO,GAAyB,EAAE;QACnE,IAAI,CAAC,MAAM,GAAG,IAAI,eAAe,CAAC,aAAa,CAAC,CAAC;QACjD,IAAI,CAAC,KAAK;YACR,OAAO,CAAC,KAAK,IAAI,IAAI,eAAe,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,EAAE,OAAO,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC;QAChG,IAAI,CAAC,QAAQ,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9D,IAAI,CAAC,IAAI,GAAG,IAAI,YAAY,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QACtD,IAAI,CAAC,MAAM,GAAG,IAAI,cAAc,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAC5D,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,aAAqB;IACvD,MAAM,MAAM,GAAG,IAAI,eAAe,CAAC,aAAa,CAAC,CAAC;IAClD,MAAM,KAAK,CAAC,MAAM,CAAC,aAAa,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACzD,MAAM,KAAK,CAAC,MAAM,CAAC,cAAc,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1D,MAAM,SAAS,CACb,IAAI,CAAC,MAAM,CAAC,cAAc,EAAE,EAAE,YAAY,CAAC,EAC3C,4FAA4F,EAC5F,MAAM,CACP,CAAC;IACF,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,oEAAoE;AACpE,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,aAAqB,EACrB,OAAO,GAAyB,EAAE;IAElC,MAAM,aAAa,CAAC,aAAa,CAAC,CAAC;IACnC,OAAO,IAAI,aAAa,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;AACnD,CAAC"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@aldus-runtime/file-store",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Aldus File Store \u2014 file-backed Episode, Run, and Event stores with atomic writes and crash recovery.",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jamchen/aldus",
10
+ "directory": "packages/aldus-file-store"
11
+ },
12
+ "homepage": "https://github.com/jamchen/aldus/tree/main/packages/aldus-file-store#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jamchen/aldus/issues"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -b",
30
+ "typecheck": "tsc -b --pretty",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "typecheck:test": "tsc -p tsconfig.test.json"
34
+ },
35
+ "dependencies": {
36
+ "@aldus-runtime/core": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@aldus-runtime/testkit": "0.1.0",
40
+ "@types/node": "^26.2.0",
41
+ "typescript": "^7.0.2",
42
+ "vitest": "^4.1.10"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org"
47
+ }
48
+ }
package/src/atomic.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Crash-safe file primitives.
3
+ *
4
+ * Architecture contract §19.1 requires "recovery from partial success" and "recovery from
5
+ * interrupted writes" (§22 WP-02). The failure this module exists to prevent is concrete: an
6
+ * operator's machine sleeps, a process is killed, or a container is evicted midway through
7
+ * rewriting `run.json`, and the next read finds a half-written file where the Run's state used
8
+ * to be. Contract §3.4 makes files authoritative, so a truncated manifest is not an
9
+ * inconvenience — it is the loss of the only authoritative record.
10
+ *
11
+ * The durability sequence is write-to-temp, fsync the file, rename, fsync the directory. Each
12
+ * step is load-bearing:
13
+ *
14
+ * - **Temp file in the same directory.** `rename` is only atomic within a filesystem. A temp
15
+ * file in `os.tmpdir()` may be on a different device, which silently degrades the rename into
16
+ * a copy — exactly the non-atomic write this is meant to avoid.
17
+ * - **fsync the file** before renaming, or the rename can be durable while the contents are not,
18
+ * leaving a correctly named empty file after a power loss.
19
+ * - **fsync the directory** after renaming, or the rename itself may not survive a crash.
20
+ */
21
+
22
+ import { constants as fsConstants } from "node:fs";
23
+ import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
24
+ import { dirname, join } from "node:path";
25
+
26
+ /** Injection point for the durability sequence, so tests can interrupt it at a chosen step. */
27
+ export interface AtomicWriteHooks {
28
+ /** Invoked after the temp file is written and synced, before the rename. */
29
+ beforeRename?: (temporaryPath: string) => void | Promise<void>;
30
+ }
31
+
32
+ /** Options for {@link writeFileAtomic}. */
33
+ export interface AtomicWriteOptions {
34
+ /** Test seam; unused in production paths. */
35
+ hooks?: AtomicWriteHooks;
36
+ }
37
+
38
+ let temporaryCounter = 0;
39
+
40
+ /**
41
+ * Write `contents` to `path` so that a reader sees either the previous bytes or the new bytes,
42
+ * never a mixture.
43
+ *
44
+ * Parent directories are created as needed. The temp file is removed if any step fails, so an
45
+ * interrupted write leaves no debris for the next reader to mistake for real state.
46
+ */
47
+ export async function writeFileAtomic(
48
+ path: string,
49
+ contents: string,
50
+ options: AtomicWriteOptions = {},
51
+ ): Promise<void> {
52
+ const directory = dirname(path);
53
+ await mkdir(directory, { recursive: true });
54
+
55
+ // Same directory, therefore same filesystem, therefore a genuinely atomic rename. The counter
56
+ // plus pid keeps two writers in one process from colliding on the temp name.
57
+ temporaryCounter += 1;
58
+ const temporaryPath = join(
59
+ directory,
60
+ `.${basenameOf(path)}.${process.pid}.${temporaryCounter}.tmp`,
61
+ );
62
+
63
+ try {
64
+ const handle = await open(temporaryPath, "w");
65
+ try {
66
+ await handle.writeFile(contents, "utf8");
67
+ await handle.sync();
68
+ } finally {
69
+ await handle.close();
70
+ }
71
+
72
+ await options.hooks?.beforeRename?.(temporaryPath);
73
+
74
+ await rename(temporaryPath, path);
75
+ await syncDirectory(directory);
76
+ } catch (error) {
77
+ await unlink(temporaryPath).catch(() => undefined);
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Append a single line to a file, creating it if absent, and sync before returning.
84
+ *
85
+ * Used for the append-only event log (contract §6.4). `O_APPEND` makes each write land at the
86
+ * current end of file even with concurrent writers, so an interleaved append can never overwrite
87
+ * another writer's bytes — it can only ever be interleaved *between* lines, or torn at the tail
88
+ * if the process dies mid-write. {@link readJsonLines} handles the torn tail.
89
+ */
90
+ export async function appendLineSynced(path: string, line: string): Promise<void> {
91
+ await mkdir(dirname(path), { recursive: true });
92
+ const handle = await open(path, "a");
93
+ try {
94
+ await handle.writeFile(`${line}\n`, "utf8");
95
+ await handle.sync();
96
+ } finally {
97
+ await handle.close();
98
+ }
99
+ }
100
+
101
+ /** Read a file, returning `undefined` rather than throwing when it does not exist. */
102
+ export async function readFileOrUndefined(path: string): Promise<string | undefined> {
103
+ try {
104
+ return await readFile(path, "utf8");
105
+ } catch (error) {
106
+ if (isNotFound(error)) return undefined;
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ /** True if `error` is a Node `ENOENT`. */
112
+ export function isNotFound(error: unknown): boolean {
113
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
114
+ }
115
+
116
+ /** True if `error` is a Node `EEXIST`, i.e. an `O_EXCL` create lost the race. */
117
+ export function isAlreadyExists(error: unknown): boolean {
118
+ return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
119
+ }
120
+
121
+ /**
122
+ * Create a file only if it does not already exist, returning `false` if it did.
123
+ *
124
+ * `O_CREAT | O_EXCL` is the primitive the lock is built on: the check and the create are one
125
+ * syscall, so two processes racing cannot both believe they created it.
126
+ */
127
+ export async function createExclusive(path: string, contents: string): Promise<boolean> {
128
+ await mkdir(dirname(path), { recursive: true });
129
+ try {
130
+ const handle = await open(
131
+ path,
132
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
133
+ );
134
+ try {
135
+ await handle.writeFile(contents, "utf8");
136
+ await handle.sync();
137
+ } finally {
138
+ await handle.close();
139
+ }
140
+ return true;
141
+ } catch (error) {
142
+ if (isAlreadyExists(error)) return false;
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ /** Overwrite a file in place without the rename dance. Used for lock heartbeats only. */
148
+ export async function overwrite(path: string, contents: string): Promise<void> {
149
+ await writeFile(path, contents, "utf8");
150
+ }
151
+
152
+ /** Remove a file, tolerating its absence. */
153
+ export async function removeIfPresent(path: string): Promise<void> {
154
+ try {
155
+ await unlink(path);
156
+ } catch (error) {
157
+ if (!isNotFound(error)) throw error;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * fsync a directory so that a rename within it survives a crash.
163
+ *
164
+ * Not portable: some platforms refuse to open a directory for the purpose. A failure here means
165
+ * the rename may not be durable across a power loss, which is strictly weaker than the
166
+ * within-process atomicity the rename already guarantees — so it is tolerated rather than
167
+ * escalated into a failed write.
168
+ */
169
+ async function syncDirectory(directory: string): Promise<void> {
170
+ try {
171
+ const handle = await open(directory, "r");
172
+ try {
173
+ await handle.sync();
174
+ } finally {
175
+ await handle.close();
176
+ }
177
+ } catch {
178
+ // Best effort by design; see the doc comment.
179
+ }
180
+ }
181
+
182
+ function basenameOf(path: string): string {
183
+ const index = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
184
+ return index === -1 ? path : path.slice(index + 1);
185
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Per-run collection files (contract §7: `artifacts.json`, `approvals.json`, `costs.json`,
3
+ * `release.json`).
4
+ *
5
+ * Each holds a JSON array of records that each carry their own `schemaVersion`, so the array
6
+ * itself needs no envelope. Appending writes back the **raw** parsed array with one element
7
+ * added, which preserves unknown properties on every existing element for free — there is no
8
+ * merge to get wrong, because nothing already stored is ever re-serialised from a validated
9
+ * value (ADR-0004 decision 3).
10
+ */
11
+
12
+ import {
13
+ validateRecord,
14
+ fromStructuredError,
15
+ type SchemaTypeFor,
16
+ type VersionedSchemaName,
17
+ } from "@aldus-runtime/core";
18
+
19
+ import { readFileOrUndefined, type AtomicWriteOptions } from "./atomic.js";
20
+ import { writeDocument } from "./document.js";
21
+ import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
22
+
23
+ /** A collection read: validated records plus the raw array they were parsed from. */
24
+ export interface StoredCollection<T> {
25
+ values: T[];
26
+ raw: unknown[];
27
+ }
28
+
29
+ /**
30
+ * Read and validate a collection file.
31
+ *
32
+ * An absent file reads as an empty collection: a Run that has produced no artifacts yet is an
33
+ * ordinary state (contract §6.2 `created`), not a missing record.
34
+ */
35
+ export async function readCollection<N extends VersionedSchemaName>(
36
+ path: string,
37
+ schema: N,
38
+ ): Promise<StoredCollection<SchemaTypeFor<N>>> {
39
+ const contents = await readFileOrUndefined(path);
40
+ if (contents === undefined || contents.trim().length === 0) return { values: [], raw: [] };
41
+
42
+ let parsed: unknown;
43
+ try {
44
+ parsed = JSON.parse(contents);
45
+ } catch {
46
+ throw fileStoreError(
47
+ FileStoreErrorCodes.RECORD_MALFORMED,
48
+ `The stored ${schema} collection is not valid JSON. Atomic writes make a torn file ` +
49
+ "impossible, so this means the file was edited or replaced by something other than the store.",
50
+ { category: "io", retryable: false, details: { path, schema, byteLength: contents.length } },
51
+ );
52
+ }
53
+
54
+ if (!Array.isArray(parsed)) {
55
+ throw fileStoreError(
56
+ FileStoreErrorCodes.RECORD_MALFORMED,
57
+ `The stored ${schema} collection is valid JSON but not an array.`,
58
+ { category: "io", retryable: false, details: { path, schema } },
59
+ );
60
+ }
61
+
62
+ const values: SchemaTypeFor<N>[] = [];
63
+ for (let index = 0; index < parsed.length; index += 1) {
64
+ const result = validateRecord(schema, parsed[index]);
65
+ if (!result.ok) {
66
+ const error = fromStructuredError(result.error);
67
+ // Name the offending index. Without it, a 200-element artifacts file reports "one of these
68
+ // is wrong" and leaves an operator to bisect by hand.
69
+ throw fileStoreError(
70
+ FileStoreErrorCodes.RECORD_MALFORMED,
71
+ `Element ${index} of the stored ${schema} collection is not a valid ${schema}: ${error.message}`,
72
+ {
73
+ category: "io",
74
+ retryable: false,
75
+ details: { path, schema, index },
76
+ },
77
+ );
78
+ }
79
+ values.push(result.value);
80
+ }
81
+
82
+ return { values, raw: parsed };
83
+ }
84
+
85
+ /** Append one record to a collection file, preserving every existing element byte for byte. */
86
+ export async function appendToCollection<N extends VersionedSchemaName>(
87
+ path: string,
88
+ schema: N,
89
+ record: SchemaTypeFor<N>,
90
+ options: AtomicWriteOptions = {},
91
+ ): Promise<void> {
92
+ const existing = await readCollection(path, schema);
93
+ await writeDocument(path, [...existing.raw, record], options);
94
+ }