@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.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/dist/atomic.d.ts +65 -0
- package/dist/atomic.d.ts.map +1 -0
- package/dist/atomic.js +160 -0
- package/dist/atomic.js.map +1 -0
- package/dist/collections.d.ts +27 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +58 -0
- package/dist/collections.js.map +1 -0
- package/dist/document.d.ts +66 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +109 -0
- package/dist/document.js.map +1 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +56 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonl.d.ts +62 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +99 -0
- package/dist/jsonl.js.map +1 -0
- package/dist/layout.d.ts +54 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +86 -0
- package/dist/layout.js.map +1 -0
- package/dist/lock.d.ts +80 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +257 -0
- package/dist/lock.js.map +1 -0
- package/dist/ports.d.ts +104 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +18 -0
- package/dist/ports.js.map +1 -0
- package/dist/stores.d.ts +56 -0
- package/dist/stores.d.ts.map +1 -0
- package/dist/stores.js +210 -0
- package/dist/stores.js.map +1 -0
- package/dist/workspace.d.ts +37 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +48 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +48 -0
- package/src/atomic.ts +185 -0
- package/src/collections.ts +94 -0
- package/src/document.ts +146 -0
- package/src/errors.ts +65 -0
- package/src/index.ts +96 -0
- package/src/jsonl.ts +149 -0
- package/src/layout.ts +105 -0
- package/src/lock.ts +359 -0
- package/src/ports.ts +126 -0
- package/src/stores.ts +295 -0
- package/src/workspace.ts +68 -0
package/src/stores.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
|
|
12
|
+
import { readdir } from "node:fs/promises";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
validateRecord,
|
|
16
|
+
fromStructuredError,
|
|
17
|
+
type AldusEvent,
|
|
18
|
+
type EpisodeRef,
|
|
19
|
+
type RunManifest,
|
|
20
|
+
type SchemaTypeFor,
|
|
21
|
+
type VersionedSchemaName,
|
|
22
|
+
} from "@aldus-runtime/core";
|
|
23
|
+
|
|
24
|
+
import { appendLineSynced, isNotFound } from "./atomic.js";
|
|
25
|
+
import { appendToCollection, readCollection } from "./collections.js";
|
|
26
|
+
import { mergeForWrite, readDocument, writeDocument } from "./document.js";
|
|
27
|
+
import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
|
|
28
|
+
import { readJsonLines, toJsonLine } from "./jsonl.js";
|
|
29
|
+
import { EPISODE_LOCK_RESOURCE, WorkspaceLayout, runLockResource } from "./layout.js";
|
|
30
|
+
import type { LockManager } from "./lock.js";
|
|
31
|
+
import type {
|
|
32
|
+
EpisodeStore,
|
|
33
|
+
EventReadOptions,
|
|
34
|
+
EventReadResult,
|
|
35
|
+
EventStore,
|
|
36
|
+
RunCollectionName,
|
|
37
|
+
RunCollectionTypes,
|
|
38
|
+
RunStore,
|
|
39
|
+
} from "./ports.js";
|
|
40
|
+
|
|
41
|
+
/** Schema backing each per-run collection file (contract §7). */
|
|
42
|
+
export const RUN_COLLECTION_SCHEMAS = {
|
|
43
|
+
artifacts: "ArtifactRef",
|
|
44
|
+
approvals: "GateDecision",
|
|
45
|
+
costs: "CostRecord",
|
|
46
|
+
release: "ReleaseReceipt",
|
|
47
|
+
} as const satisfies Record<RunCollectionName, VersionedSchemaName>;
|
|
48
|
+
|
|
49
|
+
/* -------------------------------------------------------------------------------------------
|
|
50
|
+
* Episode
|
|
51
|
+
* ---------------------------------------------------------------------------------------- */
|
|
52
|
+
|
|
53
|
+
/** `.aldus/episode.json` (contract §6.1, §7). */
|
|
54
|
+
export class FileEpisodeStore implements EpisodeStore {
|
|
55
|
+
readonly #layout: WorkspaceLayout;
|
|
56
|
+
readonly #locks: LockManager;
|
|
57
|
+
|
|
58
|
+
constructor(layout: WorkspaceLayout, locks: LockManager) {
|
|
59
|
+
this.#layout = layout;
|
|
60
|
+
this.#locks = locks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async get(): Promise<EpisodeRef | undefined> {
|
|
64
|
+
const document = await readDocument(this.#layout.episodePath(), "EpisodeRef");
|
|
65
|
+
return document?.value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async put(episode: EpisodeRef): Promise<void> {
|
|
69
|
+
await this.#locks.withLock(EPISODE_LOCK_RESOURCE, async () => {
|
|
70
|
+
await writeDocument(this.#layout.episodePath(), episode);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async update(mutate: (current: EpisodeRef) => EpisodeRef): Promise<EpisodeRef> {
|
|
75
|
+
return this.#locks.withLock(EPISODE_LOCK_RESOURCE, async () => {
|
|
76
|
+
const path = this.#layout.episodePath();
|
|
77
|
+
const document = await readDocument(path, "EpisodeRef");
|
|
78
|
+
if (document === undefined) {
|
|
79
|
+
throw fileStoreError(
|
|
80
|
+
FileStoreErrorCodes.RECORD_NOT_FOUND,
|
|
81
|
+
"This workspace has no Episode record, so there is nothing to update.",
|
|
82
|
+
{ category: "not_found", retryable: false, details: { path } },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const next = mutate(document.value);
|
|
86
|
+
assertValid("EpisodeRef", next);
|
|
87
|
+
await writeDocument(path, mergeForWrite(document, next));
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* -------------------------------------------------------------------------------------------
|
|
94
|
+
* Run
|
|
95
|
+
* ---------------------------------------------------------------------------------------- */
|
|
96
|
+
|
|
97
|
+
/** `.aldus/runs/{run-id}/` (contract §6.2, §7). */
|
|
98
|
+
export class FileRunStore implements RunStore {
|
|
99
|
+
readonly #layout: WorkspaceLayout;
|
|
100
|
+
readonly #locks: LockManager;
|
|
101
|
+
|
|
102
|
+
constructor(layout: WorkspaceLayout, locks: LockManager) {
|
|
103
|
+
this.#layout = layout;
|
|
104
|
+
this.#locks = locks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async list(): Promise<string[]> {
|
|
108
|
+
try {
|
|
109
|
+
const entries = await readdir(this.#layout.runsDirectory(), { withFileTypes: true });
|
|
110
|
+
return entries
|
|
111
|
+
.filter((entry) => entry.isDirectory())
|
|
112
|
+
.map((entry) => entry.name)
|
|
113
|
+
.sort();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
// A workspace with no runs yet is ordinary, not an error.
|
|
116
|
+
if (isNotFound(error)) return [];
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async get(runId: string): Promise<RunManifest | undefined> {
|
|
122
|
+
const document = await readDocument(this.#layout.runFilePath(runId, "manifest"), "RunManifest");
|
|
123
|
+
return document?.value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async create(manifest: RunManifest): Promise<void> {
|
|
127
|
+
await this.#locks.withLock(runLockResource(manifest.runId), async () => {
|
|
128
|
+
const path = this.#layout.runFilePath(manifest.runId, "manifest");
|
|
129
|
+
const existing = await readDocument(path, "RunManifest");
|
|
130
|
+
if (existing !== undefined) {
|
|
131
|
+
throw fileStoreError(
|
|
132
|
+
FileStoreErrorCodes.RECORD_IDENTITY_MISMATCH,
|
|
133
|
+
`A Run manifest already exists for "${manifest.runId}". Creating it again would ` +
|
|
134
|
+
"overwrite an execution record, which contract §6.3 makes append-only.",
|
|
135
|
+
{ category: "conflict", retryable: false, details: { runId: manifest.runId } },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
await writeDocument(path, manifest);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async update(runId: string, mutate: (current: RunManifest) => RunManifest): Promise<RunManifest> {
|
|
143
|
+
return this.#locks.withLock(runLockResource(runId), async () => {
|
|
144
|
+
const path = this.#layout.runFilePath(runId, "manifest");
|
|
145
|
+
const document = await readDocument(path, "RunManifest");
|
|
146
|
+
if (document === undefined) {
|
|
147
|
+
throw fileStoreError(
|
|
148
|
+
FileStoreErrorCodes.RECORD_NOT_FOUND,
|
|
149
|
+
`No Run manifest exists for "${runId}".`,
|
|
150
|
+
{ category: "not_found", retryable: false, details: { runId } },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const next = mutate(document.value);
|
|
154
|
+
if (next.runId !== runId) {
|
|
155
|
+
throw fileStoreError(
|
|
156
|
+
FileStoreErrorCodes.RECORD_IDENTITY_MISMATCH,
|
|
157
|
+
`An update to Run "${runId}" returned a manifest identifying itself as ` +
|
|
158
|
+
`"${next.runId}". Writing it would file one Run's state under another's identity.`,
|
|
159
|
+
{ category: "conflict", retryable: false, details: { runId, returnedRunId: next.runId } },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
assertValid("RunManifest", next);
|
|
163
|
+
await writeDocument(path, mergeForWrite(document, next));
|
|
164
|
+
return next;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async listRecords<C extends RunCollectionName>(
|
|
169
|
+
runId: string,
|
|
170
|
+
collection: C,
|
|
171
|
+
): Promise<RunCollectionTypes[C][]> {
|
|
172
|
+
const schema = RUN_COLLECTION_SCHEMAS[collection];
|
|
173
|
+
const stored = await readCollection(this.#layout.runFilePath(runId, collection), schema);
|
|
174
|
+
return stored.values as RunCollectionTypes[C][];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async addRecord<C extends RunCollectionName>(
|
|
178
|
+
runId: string,
|
|
179
|
+
collection: C,
|
|
180
|
+
record: RunCollectionTypes[C],
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
await this.#locks.withLock(runLockResource(runId), async () => {
|
|
183
|
+
const schema = RUN_COLLECTION_SCHEMAS[collection];
|
|
184
|
+
assertValid(schema, record);
|
|
185
|
+
await appendToCollection(
|
|
186
|
+
this.#layout.runFilePath(runId, collection),
|
|
187
|
+
schema,
|
|
188
|
+
record as SchemaTypeFor<typeof schema>,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* -------------------------------------------------------------------------------------------
|
|
195
|
+
* Events
|
|
196
|
+
* ---------------------------------------------------------------------------------------- */
|
|
197
|
+
|
|
198
|
+
/** `.aldus/runs/{run-id}/events.jsonl` (contract §6.4, §7). */
|
|
199
|
+
export class FileEventStore implements EventStore {
|
|
200
|
+
readonly #layout: WorkspaceLayout;
|
|
201
|
+
readonly #locks: LockManager;
|
|
202
|
+
|
|
203
|
+
constructor(layout: WorkspaceLayout, locks: LockManager) {
|
|
204
|
+
this.#layout = layout;
|
|
205
|
+
this.#locks = locks;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async append(runId: string, event: AldusEvent): Promise<AldusEvent> {
|
|
209
|
+
return this.#locks.withLock(runLockResource(runId), async () => {
|
|
210
|
+
const path = this.#layout.runFilePath(runId, "events");
|
|
211
|
+
const existing = await this.#readValidated(path, {});
|
|
212
|
+
|
|
213
|
+
const expected = nextSequenceOf(existing.events);
|
|
214
|
+
if (event.sequence !== undefined && event.sequence !== expected) {
|
|
215
|
+
throw fileStoreError(
|
|
216
|
+
FileStoreErrorCodes.EVENT_OUT_OF_SEQUENCE,
|
|
217
|
+
`Event sequence ${event.sequence} does not follow the log, which expects ${expected}. ` +
|
|
218
|
+
"A per-run sequence is a total order (ADR-0005); a gap or a repeat would make the " +
|
|
219
|
+
"log unorderable across concurrent sessions.",
|
|
220
|
+
{
|
|
221
|
+
category: "conflict",
|
|
222
|
+
retryable: false,
|
|
223
|
+
details: { runId, expected, received: event.sequence },
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (existing.events.some((stored) => stored.eventId === event.eventId)) {
|
|
229
|
+
throw fileStoreError(
|
|
230
|
+
FileStoreErrorCodes.EVENT_DUPLICATE,
|
|
231
|
+
`Event "${event.eventId}" is already in the log for Run "${runId}". Appending it again ` +
|
|
232
|
+
"would record one mutation twice (contract §6.4).",
|
|
233
|
+
{ category: "conflict", retryable: false, details: { runId, eventId: event.eventId } },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const stored: AldusEvent = { ...event, sequence: expected };
|
|
238
|
+
assertValid("AldusEvent", stored);
|
|
239
|
+
await appendLineSynced(path, toJsonLine(stored));
|
|
240
|
+
return stored;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async read(runId: string, options: EventReadOptions = {}): Promise<EventReadResult> {
|
|
245
|
+
return this.#readValidated(this.#layout.runFilePath(runId, "events"), options);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async nextSequence(runId: string): Promise<number> {
|
|
249
|
+
const result = await this.read(runId);
|
|
250
|
+
return nextSequenceOf(result.events);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #readValidated(path: string, options: EventReadOptions): Promise<EventReadResult> {
|
|
254
|
+
const lines = await readJsonLines(path, {
|
|
255
|
+
path,
|
|
256
|
+
...(options.strictTail === undefined ? {} : { strictTail: options.strictTail }),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const events: AldusEvent[] = [];
|
|
260
|
+
for (let index = 0; index < lines.values.length; index += 1) {
|
|
261
|
+
const result = validateRecord("AldusEvent", lines.values[index]);
|
|
262
|
+
if (!result.ok) {
|
|
263
|
+
const error = fromStructuredError(result.error);
|
|
264
|
+
throw fileStoreError(
|
|
265
|
+
FileStoreErrorCodes.EVENT_LOG_CORRUPT,
|
|
266
|
+
`Line ${index + 1} of the event log parsed as JSON but is not a valid AldusEvent: ${error.message}`,
|
|
267
|
+
{ category: "io", retryable: false, details: { path, line: index + 1 } },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
events.push(result.value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return lines.tornTail === undefined ? { events } : { events, tornTail: lines.tornTail };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The sequence the next event should carry.
|
|
279
|
+
*
|
|
280
|
+
* Derived from the highest stored sequence rather than the event count, so a log read after a
|
|
281
|
+
* torn-tail recovery still assigns a sequence strictly greater than anything already durable.
|
|
282
|
+
*/
|
|
283
|
+
export function nextSequenceOf(events: readonly AldusEvent[]): number {
|
|
284
|
+
let highest = -1;
|
|
285
|
+
for (const event of events) {
|
|
286
|
+
if (event.sequence !== undefined && event.sequence > highest) highest = event.sequence;
|
|
287
|
+
}
|
|
288
|
+
return highest + 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Validate before writing, so a malformed record never reaches disk. */
|
|
292
|
+
function assertValid<N extends VersionedSchemaName>(schema: N, value: unknown): void {
|
|
293
|
+
const result = validateRecord(schema, value);
|
|
294
|
+
if (!result.ok) throw fromStructuredError(result.error);
|
|
295
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
|
|
9
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { WorkspaceLayout } from "./layout.js";
|
|
13
|
+
import { FileLockManager, type FileLockManagerOptions, type LockManager } from "./lock.js";
|
|
14
|
+
import { FileEpisodeStore, FileEventStore, FileRunStore } from "./stores.js";
|
|
15
|
+
|
|
16
|
+
/** Options for opening a workspace. */
|
|
17
|
+
export interface OpenWorkspaceOptions {
|
|
18
|
+
/** Replace the default file-based lock manager, e.g. with a distributed lease. */
|
|
19
|
+
locks?: LockManager;
|
|
20
|
+
/** Tuning for the default lock manager. Ignored when `locks` is supplied. */
|
|
21
|
+
lockOptions?: FileLockManagerOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The file-backed stores for one workspace. */
|
|
25
|
+
export class FileWorkspace {
|
|
26
|
+
readonly layout: WorkspaceLayout;
|
|
27
|
+
readonly locks: LockManager;
|
|
28
|
+
readonly episodes: FileEpisodeStore;
|
|
29
|
+
readonly runs: FileRunStore;
|
|
30
|
+
readonly events: FileEventStore;
|
|
31
|
+
|
|
32
|
+
constructor(workspaceRoot: string, options: OpenWorkspaceOptions = {}) {
|
|
33
|
+
this.layout = new WorkspaceLayout(workspaceRoot);
|
|
34
|
+
this.locks =
|
|
35
|
+
options.locks ?? new FileLockManager(this.layout.locksDirectory(), options.lockOptions ?? {});
|
|
36
|
+
this.episodes = new FileEpisodeStore(this.layout, this.locks);
|
|
37
|
+
this.runs = new FileRunStore(this.layout, this.locks);
|
|
38
|
+
this.events = new FileEventStore(this.layout, this.locks);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create the `.aldus` directory structure for a workspace.
|
|
44
|
+
*
|
|
45
|
+
* Idempotent. Writing a `.gitignore` inside `locks/` is deliberate: §7 recommends a Git-friendly
|
|
46
|
+
* layout, and a committed lockfile would carry another machine's PID into everyone's checkout and
|
|
47
|
+
* block the workspace until someone deleted it by hand.
|
|
48
|
+
*/
|
|
49
|
+
export async function initWorkspace(workspaceRoot: string): Promise<WorkspaceLayout> {
|
|
50
|
+
const layout = new WorkspaceLayout(workspaceRoot);
|
|
51
|
+
await mkdir(layout.runsDirectory(), { recursive: true });
|
|
52
|
+
await mkdir(layout.locksDirectory(), { recursive: true });
|
|
53
|
+
await writeFile(
|
|
54
|
+
join(layout.locksDirectory(), ".gitignore"),
|
|
55
|
+
"# Lockfiles are machine-local runtime state and must never be committed.\n*\n!.gitignore\n",
|
|
56
|
+
"utf8",
|
|
57
|
+
);
|
|
58
|
+
return layout;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Open a workspace, creating its directory structure if absent. */
|
|
62
|
+
export async function openWorkspace(
|
|
63
|
+
workspaceRoot: string,
|
|
64
|
+
options: OpenWorkspaceOptions = {},
|
|
65
|
+
): Promise<FileWorkspace> {
|
|
66
|
+
await initWorkspace(workspaceRoot);
|
|
67
|
+
return new FileWorkspace(workspaceRoot, options);
|
|
68
|
+
}
|