@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/document.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validated JSON documents with forward-compatible round-tripping.
|
|
3
|
+
*
|
|
4
|
+
* ADR-0004 decision 3 is a MUST on this package: a store MUST preserve unknown properties across
|
|
5
|
+
* a read-modify-write. The hazard it addresses is quiet. Zod strips unknown properties on parse
|
|
6
|
+
* (ADR-0002 decision 7), so an older build that reads a manifest written by a newer minor
|
|
7
|
+
* version, changes one field, and writes it back would delete the newer version's data without
|
|
8
|
+
* any error, any warning, or any way to notice afterwards.
|
|
9
|
+
*
|
|
10
|
+
* A Git-tracked `.aldus/` shared between machines on different builds makes that an ordinary
|
|
11
|
+
* situation, not an edge case (contract §5.1, §7). So every read keeps the raw parsed JSON
|
|
12
|
+
* beside the validated value, and every update merges the caller's changes back over the raw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
validateRecord,
|
|
17
|
+
fromStructuredError,
|
|
18
|
+
type SchemaCompatibility,
|
|
19
|
+
type SchemaTypeFor,
|
|
20
|
+
type VersionedSchemaName,
|
|
21
|
+
} from "@aldus-runtime/core";
|
|
22
|
+
|
|
23
|
+
import { readFileOrUndefined, writeFileAtomic, type AtomicWriteOptions } from "./atomic.js";
|
|
24
|
+
import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
|
|
25
|
+
|
|
26
|
+
/** A record as it exists on disk: validated, plus the bytes it was validated from. */
|
|
27
|
+
export interface StoredDocument<T> {
|
|
28
|
+
/** The validated record. Unknown properties are absent, as Zod strips them. */
|
|
29
|
+
value: T;
|
|
30
|
+
/** The parsed JSON exactly as stored, including properties this build does not know about. */
|
|
31
|
+
raw: Record<string, unknown>;
|
|
32
|
+
/** How the stored `schemaVersion` relates to this build (ADR-0003). */
|
|
33
|
+
compatibility: SchemaCompatibility;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read and validate a versioned record.
|
|
38
|
+
*
|
|
39
|
+
* Returns `undefined` when the file does not exist — an absent record is an ordinary state, not
|
|
40
|
+
* a failure, and a store that threw here would make "does this run exist?" an exception-handling
|
|
41
|
+
* exercise.
|
|
42
|
+
*
|
|
43
|
+
* @throws {AldusError} `ALDUS_RECORD_MALFORMED` if the bytes are not JSON,
|
|
44
|
+
* `ALDUS_SCHEMA_VERSION_UNSUPPORTED` if the major version is unreadable, or
|
|
45
|
+
* `ALDUS_SCHEMA_VALIDATION_FAILED` if the record does not satisfy its schema.
|
|
46
|
+
*/
|
|
47
|
+
export async function readDocument<N extends VersionedSchemaName>(
|
|
48
|
+
path: string,
|
|
49
|
+
schema: N,
|
|
50
|
+
): Promise<StoredDocument<SchemaTypeFor<N>> | undefined> {
|
|
51
|
+
const contents = await readFileOrUndefined(path);
|
|
52
|
+
if (contents === undefined) return undefined;
|
|
53
|
+
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(contents);
|
|
57
|
+
} catch {
|
|
58
|
+
throw fileStoreError(
|
|
59
|
+
FileStoreErrorCodes.RECORD_MALFORMED,
|
|
60
|
+
`The stored ${schema} record is not valid JSON. Atomic writes make a torn file impossible, ` +
|
|
61
|
+
"so this means the file was edited or replaced by something other than the store.",
|
|
62
|
+
{
|
|
63
|
+
category: "io",
|
|
64
|
+
retryable: false,
|
|
65
|
+
// The file's contents are excluded deliberately: a record may carry redacted-but-
|
|
66
|
+
// sensitive context (§19.2), and an error is itself durable.
|
|
67
|
+
details: { path, schema, byteLength: contents.length },
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isPlainObject(parsed)) {
|
|
73
|
+
throw fileStoreError(
|
|
74
|
+
FileStoreErrorCodes.RECORD_MALFORMED,
|
|
75
|
+
`The stored ${schema} record is valid JSON but not an object.`,
|
|
76
|
+
{ category: "io", retryable: false, details: { path, schema } },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = validateRecord(schema, parsed);
|
|
81
|
+
if (!result.ok) throw fromStructuredError(result.error);
|
|
82
|
+
|
|
83
|
+
return { value: result.value, raw: parsed, compatibility: result.compatibility };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write a record, replacing any previous contents atomically. */
|
|
87
|
+
export async function writeDocument(
|
|
88
|
+
path: string,
|
|
89
|
+
value: unknown,
|
|
90
|
+
options: AtomicWriteOptions = {},
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`, options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Merge a validated update back over the bytes it came from, keeping properties this build does
|
|
97
|
+
* not know about (ADR-0004 decision 3).
|
|
98
|
+
*
|
|
99
|
+
* The three arguments are what make this precise rather than a guess:
|
|
100
|
+
*
|
|
101
|
+
* - `raw` — everything that was on disk.
|
|
102
|
+
* - `original` — the same record after validation, so its keys are exactly the ones this build
|
|
103
|
+
* understands.
|
|
104
|
+
* - `next` — the caller's updated record.
|
|
105
|
+
*
|
|
106
|
+
* A key present in `raw` but absent from `original` is unknown to this build, so it is preserved.
|
|
107
|
+
* A key present in both but absent from `next` was deliberately removed by the caller, so it is
|
|
108
|
+
* dropped. Without that distinction, preservation would resurrect fields a caller had just
|
|
109
|
+
* deleted.
|
|
110
|
+
*
|
|
111
|
+
* Arrays are merged element-wise **only when the lengths match**, which is the case where index
|
|
112
|
+
* identity is sound. If the caller added or removed elements, indices no longer denote the same
|
|
113
|
+
* records and `next` is taken wholesale — losing unknown properties inside those elements rather
|
|
114
|
+
* than attaching them to the wrong element. That trade is deliberate: silently misattributing a
|
|
115
|
+
* field is worse than dropping it.
|
|
116
|
+
*/
|
|
117
|
+
export function preserveUnknown(raw: unknown, original: unknown, next: unknown): unknown {
|
|
118
|
+
if (Array.isArray(raw) && Array.isArray(original) && Array.isArray(next)) {
|
|
119
|
+
if (raw.length !== original.length || original.length !== next.length) return next;
|
|
120
|
+
return next.map((element, index) => preserveUnknown(raw[index], original[index], element));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isPlainObject(raw) || !isPlainObject(original) || !isPlainObject(next)) return next;
|
|
124
|
+
|
|
125
|
+
const merged: Record<string, unknown> = {};
|
|
126
|
+
for (const key of Object.keys(raw)) {
|
|
127
|
+
if (!(key in original)) merged[key] = raw[key];
|
|
128
|
+
}
|
|
129
|
+
for (const key of Object.keys(next)) {
|
|
130
|
+
merged[key] =
|
|
131
|
+
key in raw && key in original
|
|
132
|
+
? preserveUnknown(raw[key], original[key], next[key])
|
|
133
|
+
: next[key];
|
|
134
|
+
}
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Apply {@link preserveUnknown} against a stored document. */
|
|
139
|
+
export function mergeForWrite<T>(document: StoredDocument<T>, next: T): unknown {
|
|
140
|
+
return preserveUnknown(document.raw, document.value, next);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True for a JSON object, excluding arrays and null. */
|
|
144
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
145
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
146
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures specific to file-backed storage.
|
|
3
|
+
*
|
|
4
|
+
* Aldus Core deliberately keeps no central error-code registry, so that a package can name a new
|
|
5
|
+
* failure without forking Core. These codes are this package's contribution; they carry the same
|
|
6
|
+
* `ALDUS_` prefix and `SCREAMING_SNAKE_CASE` shape so production trace (contract §20) stays
|
|
7
|
+
* uniform across packages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AldusError, type ErrorCategory } from "@aldus-runtime/core";
|
|
11
|
+
|
|
12
|
+
/** Error codes raised by the file store. */
|
|
13
|
+
export const FileStoreErrorCodes = {
|
|
14
|
+
/** The `.aldus` workspace directory does not exist, or is not a directory. */
|
|
15
|
+
WORKSPACE_NOT_FOUND: "ALDUS_WORKSPACE_NOT_FOUND",
|
|
16
|
+
/** A record was requested that has never been written. */
|
|
17
|
+
RECORD_NOT_FOUND: "ALDUS_RECORD_NOT_FOUND",
|
|
18
|
+
/** A stored file held bytes that are not valid JSON. */
|
|
19
|
+
RECORD_MALFORMED: "ALDUS_RECORD_MALFORMED",
|
|
20
|
+
/**
|
|
21
|
+
* An event log line in the interior of the file could not be parsed.
|
|
22
|
+
*
|
|
23
|
+
* Distinct from a torn tail: a bad line in the middle means bytes were lost or overwritten
|
|
24
|
+
* inside an append-only file, which is corruption. See {@link FileStoreErrorCodes.EVENT_LOG_TORN_TAIL}.
|
|
25
|
+
*/
|
|
26
|
+
EVENT_LOG_CORRUPT: "ALDUS_EVENT_LOG_CORRUPT",
|
|
27
|
+
/**
|
|
28
|
+
* The final line of an event log was truncated, and the caller asked for strict reads.
|
|
29
|
+
*
|
|
30
|
+
* Recoverable by design: a process that died mid-append leaves exactly this, and everything
|
|
31
|
+
* before the torn line is intact (contract §19.1 "recovery from partial success").
|
|
32
|
+
*/
|
|
33
|
+
EVENT_LOG_TORN_TAIL: "ALDUS_EVENT_LOG_TORN_TAIL",
|
|
34
|
+
/** An event was appended out of sequence order (ADR-0005). */
|
|
35
|
+
EVENT_OUT_OF_SEQUENCE: "ALDUS_EVENT_OUT_OF_SEQUENCE",
|
|
36
|
+
/** An event was appended whose `eventId` already exists in the log. */
|
|
37
|
+
EVENT_DUPLICATE: "ALDUS_EVENT_DUPLICATE",
|
|
38
|
+
/** A lock could not be acquired before the caller's deadline. */
|
|
39
|
+
LOCK_TIMEOUT: "ALDUS_LOCK_TIMEOUT",
|
|
40
|
+
/** A lock was released or renewed by something that no longer holds it. */
|
|
41
|
+
LOCK_LOST: "ALDUS_LOCK_LOST",
|
|
42
|
+
/**
|
|
43
|
+
* A lock was re-acquired inside a scope that already holds it.
|
|
44
|
+
*
|
|
45
|
+
* File locks are not re-entrant, so this can never succeed: the acquirer is waiting on itself
|
|
46
|
+
* and would spin until the acquisition deadline. Distinct from {@link LOCK_TIMEOUT}, which
|
|
47
|
+
* means another session genuinely holds the lock — this one means the caller's own design is
|
|
48
|
+
* wrong, and no amount of retrying will fix it.
|
|
49
|
+
*/
|
|
50
|
+
LOCK_REENTRANT: "ALDUS_LOCK_REENTRANT",
|
|
51
|
+
/** A write was attempted against a record whose identity does not match its location. */
|
|
52
|
+
RECORD_IDENTITY_MISMATCH: "ALDUS_RECORD_IDENTITY_MISMATCH",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
/** @see FileStoreErrorCodes */
|
|
56
|
+
export type FileStoreErrorCode = (typeof FileStoreErrorCodes)[keyof typeof FileStoreErrorCodes];
|
|
57
|
+
|
|
58
|
+
/** Construct an {@link AldusError} with a file-store code. */
|
|
59
|
+
export function fileStoreError(
|
|
60
|
+
code: FileStoreErrorCode,
|
|
61
|
+
message: string,
|
|
62
|
+
options: { category: ErrorCategory; retryable?: boolean; details?: Record<string, unknown> },
|
|
63
|
+
): AldusError {
|
|
64
|
+
return new AldusError(code, message, options);
|
|
65
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@aldus-runtime/file-store` — file-backed state and event storage for the Aldus runtime.
|
|
3
|
+
*
|
|
4
|
+
* Implements architecture contract §22 **WP-02 File state and event store**: atomic manifest
|
|
5
|
+
* writes, an append-only JSONL event log, file locking, materialized current state, and recovery
|
|
6
|
+
* from interrupted writes, over the local layout §7 recommends.
|
|
7
|
+
*
|
|
8
|
+
* This package stores and retrieves records. It does not interpret them: stage execution is
|
|
9
|
+
* WP-04, gate evaluation is WP-05, and artifact lineage is WP-03.
|
|
10
|
+
*
|
|
11
|
+
* Physical storage stays behind {@link EpisodeStore}, {@link RunStore}, and {@link EventStore}
|
|
12
|
+
* so that §7's requirement is structural rather than aspirational: the databases, object stores,
|
|
13
|
+
* and cloud drives §7 lists must remain possible as adapters instead of becoming assumptions.
|
|
14
|
+
* Core deliberately names none of them, and neither does this package (§4.2).
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
appendLineSynced,
|
|
21
|
+
createExclusive,
|
|
22
|
+
isAlreadyExists,
|
|
23
|
+
isNotFound,
|
|
24
|
+
overwrite,
|
|
25
|
+
readFileOrUndefined,
|
|
26
|
+
removeIfPresent,
|
|
27
|
+
writeFileAtomic,
|
|
28
|
+
type AtomicWriteHooks,
|
|
29
|
+
type AtomicWriteOptions,
|
|
30
|
+
} from "./atomic.js";
|
|
31
|
+
|
|
32
|
+
export { appendToCollection, readCollection, type StoredCollection } from "./collections.js";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
isPlainObject,
|
|
36
|
+
mergeForWrite,
|
|
37
|
+
preserveUnknown,
|
|
38
|
+
readDocument,
|
|
39
|
+
writeDocument,
|
|
40
|
+
type StoredDocument,
|
|
41
|
+
} from "./document.js";
|
|
42
|
+
|
|
43
|
+
export { FileStoreErrorCodes, fileStoreError, type FileStoreErrorCode } from "./errors.js";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
parseJsonLines,
|
|
47
|
+
readJsonLines,
|
|
48
|
+
toJsonLine,
|
|
49
|
+
type JsonLinesReadResult,
|
|
50
|
+
type ReadJsonLinesOptions,
|
|
51
|
+
} from "./jsonl.js";
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
ALDUS_DIRECTORY,
|
|
55
|
+
EPISODE_LOCK_RESOURCE,
|
|
56
|
+
RUN_FILES,
|
|
57
|
+
WorkspaceLayout,
|
|
58
|
+
runLockResource,
|
|
59
|
+
type RunFileName,
|
|
60
|
+
} from "./layout.js";
|
|
61
|
+
|
|
62
|
+
export {
|
|
63
|
+
DEFAULT_LOCK_RETRY_MS,
|
|
64
|
+
DEFAULT_LOCK_TIMEOUT_MS,
|
|
65
|
+
DEFAULT_LOCK_TTL_MS,
|
|
66
|
+
FileLockManager,
|
|
67
|
+
type AcquireOptions,
|
|
68
|
+
type FileLockManagerOptions,
|
|
69
|
+
type Lease,
|
|
70
|
+
type LockManager,
|
|
71
|
+
} from "./lock.js";
|
|
72
|
+
|
|
73
|
+
export type {
|
|
74
|
+
EpisodeStore,
|
|
75
|
+
EventReadOptions,
|
|
76
|
+
EventReadResult,
|
|
77
|
+
EventStore,
|
|
78
|
+
RunCollectionName,
|
|
79
|
+
RunCollectionTypes,
|
|
80
|
+
RunStore,
|
|
81
|
+
} from "./ports.js";
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
FileEpisodeStore,
|
|
85
|
+
FileEventStore,
|
|
86
|
+
FileRunStore,
|
|
87
|
+
RUN_COLLECTION_SCHEMAS,
|
|
88
|
+
nextSequenceOf,
|
|
89
|
+
} from "./stores.js";
|
|
90
|
+
|
|
91
|
+
export {
|
|
92
|
+
FileWorkspace,
|
|
93
|
+
initWorkspace,
|
|
94
|
+
openWorkspace,
|
|
95
|
+
type OpenWorkspaceOptions,
|
|
96
|
+
} from "./workspace.js";
|
package/src/jsonl.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only JSON Lines reading.
|
|
3
|
+
*
|
|
4
|
+
* Architecture contract §6.4 requires every state mutation to emit an immutable event, and §7
|
|
5
|
+
* stores those events in `events.jsonl`. §19.1 requires "recovery from partial success".
|
|
6
|
+
*
|
|
7
|
+
* The distinction this module draws is the whole point of it:
|
|
8
|
+
*
|
|
9
|
+
* - A **torn tail** is the last line of the file being incomplete. That is what a process killed
|
|
10
|
+
* mid-append leaves behind, it is expected, and everything before it is intact. Discarding it
|
|
11
|
+
* with a report is recovery.
|
|
12
|
+
* - A **corrupt interior line** is an unparseable line with complete lines after it. That cannot
|
|
13
|
+
* result from an interrupted append, because appends only ever extend the file. It means bytes
|
|
14
|
+
* inside an append-only log were lost or overwritten, and reporting it as a recoverable
|
|
15
|
+
* truncation would be a lie that silently drops an audit record.
|
|
16
|
+
*
|
|
17
|
+
* Treating the second case as the first is the failure mode worth engineering against: it would
|
|
18
|
+
* turn "your audit log is damaged" into "everything is fine, minus one event you will never
|
|
19
|
+
* learn about".
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileOrUndefined } from "./atomic.js";
|
|
23
|
+
import { FileStoreErrorCodes, fileStoreError } from "./errors.js";
|
|
24
|
+
|
|
25
|
+
/** What a JSON Lines read found. */
|
|
26
|
+
export interface JsonLinesReadResult {
|
|
27
|
+
/** Successfully parsed values, in file order. */
|
|
28
|
+
values: unknown[];
|
|
29
|
+
/**
|
|
30
|
+
* The raw text of a truncated final line, when one was found.
|
|
31
|
+
*
|
|
32
|
+
* Surfaced rather than swallowed so a caller can log it, and so a test can assert recovery
|
|
33
|
+
* happened rather than assert that nothing went wrong.
|
|
34
|
+
*/
|
|
35
|
+
tornTail?: string;
|
|
36
|
+
/** 1-based line numbers that were blank and therefore carried no record. */
|
|
37
|
+
blankLines: number[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Options for {@link readJsonLines}. */
|
|
41
|
+
export interface ReadJsonLinesOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Fail on a torn tail instead of recovering from it.
|
|
44
|
+
*
|
|
45
|
+
* Default `false`. A reader inspecting state wants recovery; a tool auditing log integrity
|
|
46
|
+
* wants to know. Both are legitimate, so it is the caller's choice rather than a policy baked
|
|
47
|
+
* into the reader.
|
|
48
|
+
*/
|
|
49
|
+
strictTail?: boolean;
|
|
50
|
+
/** Included in error details so a failure names the file it came from. */
|
|
51
|
+
path?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a JSON Lines file.
|
|
56
|
+
*
|
|
57
|
+
* Returns an empty result when the file does not exist: a Run with no events yet is an ordinary
|
|
58
|
+
* state, not an error.
|
|
59
|
+
*
|
|
60
|
+
* @throws {AldusError} `ALDUS_EVENT_LOG_CORRUPT` when an interior line cannot be parsed, or
|
|
61
|
+
* `ALDUS_EVENT_LOG_TORN_TAIL` when the tail is torn and `strictTail` is set.
|
|
62
|
+
*/
|
|
63
|
+
export async function readJsonLines(
|
|
64
|
+
path: string,
|
|
65
|
+
options: ReadJsonLinesOptions = {},
|
|
66
|
+
): Promise<JsonLinesReadResult> {
|
|
67
|
+
const contents = await readFileOrUndefined(path);
|
|
68
|
+
if (contents === undefined || contents.length === 0) {
|
|
69
|
+
return { values: [], blankLines: [] };
|
|
70
|
+
}
|
|
71
|
+
return parseJsonLines(contents, { ...options, path: options.path ?? path });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parse JSON Lines text. Separated from IO so the recovery rules can be tested directly. */
|
|
75
|
+
export function parseJsonLines(
|
|
76
|
+
contents: string,
|
|
77
|
+
options: ReadJsonLinesOptions = {},
|
|
78
|
+
): JsonLinesReadResult {
|
|
79
|
+
// An empty file is an empty log, not a blank line: `"".split("\n")` yields `[""]`, which would
|
|
80
|
+
// otherwise be reported as a blank line that was never written.
|
|
81
|
+
if (contents.length === 0) return { values: [], blankLines: [] };
|
|
82
|
+
|
|
83
|
+
const endsWithNewline = contents.endsWith("\n");
|
|
84
|
+
const rawLines = contents.split("\n");
|
|
85
|
+
// A well-formed file ends with a newline, which `split` renders as a trailing empty element.
|
|
86
|
+
// Dropping it is what makes "the last element" mean "the possibly-torn line".
|
|
87
|
+
if (endsWithNewline) rawLines.pop();
|
|
88
|
+
|
|
89
|
+
const values: unknown[] = [];
|
|
90
|
+
const blankLines: number[] = [];
|
|
91
|
+
let tornTail: string | undefined;
|
|
92
|
+
|
|
93
|
+
for (let index = 0; index < rawLines.length; index += 1) {
|
|
94
|
+
const line = rawLines[index] ?? "";
|
|
95
|
+
const isLastLine = index === rawLines.length - 1;
|
|
96
|
+
|
|
97
|
+
// A blank line carries no record and cannot represent a lost event, so it is skipped rather
|
|
98
|
+
// than treated as corruption — but it is reported, so nothing is silently ignored.
|
|
99
|
+
if (line.trim().length === 0) {
|
|
100
|
+
blankLines.push(index + 1);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
values.push(JSON.parse(line));
|
|
106
|
+
} catch {
|
|
107
|
+
// Only the final line of a file with no trailing newline can be a torn append. Anything
|
|
108
|
+
// else is damage inside an append-only file.
|
|
109
|
+
if (isLastLine && !endsWithNewline) {
|
|
110
|
+
if (options.strictTail === true) {
|
|
111
|
+
throw fileStoreError(
|
|
112
|
+
FileStoreErrorCodes.EVENT_LOG_TORN_TAIL,
|
|
113
|
+
"The final line of the event log is truncated, which is what an interrupted append " +
|
|
114
|
+
"leaves behind. Every line before it is intact.",
|
|
115
|
+
{
|
|
116
|
+
category: "io",
|
|
117
|
+
retryable: false,
|
|
118
|
+
details: { path: options.path, line: index + 1, byteLength: line.length },
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
tornTail = line;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw fileStoreError(
|
|
127
|
+
FileStoreErrorCodes.EVENT_LOG_CORRUPT,
|
|
128
|
+
"An event log line could not be parsed, and it is not the final line. An append-only " +
|
|
129
|
+
"log can only ever be damaged at its tail, so this means bytes inside the log were " +
|
|
130
|
+
"lost or overwritten.",
|
|
131
|
+
{
|
|
132
|
+
category: "io",
|
|
133
|
+
retryable: false,
|
|
134
|
+
// The line's contents are deliberately excluded: an event may carry redacted-but-
|
|
135
|
+
// sensitive context (§19.2), and an error is itself a durable record.
|
|
136
|
+
details: { path: options.path, line: index + 1, byteLength: line.length },
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return tornTail === undefined ? { values, blankLines } : { values, blankLines, tornTail };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Serialise a value to a single JSON Lines entry. Rejects embedded newlines by construction. */
|
|
146
|
+
export function toJsonLine(value: unknown): string {
|
|
147
|
+
// `JSON.stringify` escapes newlines inside strings, so one value is always exactly one line.
|
|
148
|
+
return JSON.stringify(value);
|
|
149
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace layout.
|
|
3
|
+
*
|
|
4
|
+
* Paths follow architecture contract §7's recommended local layout verbatim. They are collected
|
|
5
|
+
* here rather than spread through the stores because §8.1 states that a path MUST NOT be treated
|
|
6
|
+
* as identity: keeping every path construction in one module makes it visible that paths are a
|
|
7
|
+
* storage detail this adapter owns, and that no record's identity is derived from one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Directory name holding Aldus state inside a workspace (contract §7). */
|
|
13
|
+
export const ALDUS_DIRECTORY = ".aldus";
|
|
14
|
+
|
|
15
|
+
/** File names inside a run directory (contract §7). */
|
|
16
|
+
export const RUN_FILES = {
|
|
17
|
+
manifest: "run.json",
|
|
18
|
+
events: "events.jsonl",
|
|
19
|
+
artifacts: "artifacts.json",
|
|
20
|
+
approvals: "approvals.json",
|
|
21
|
+
costs: "costs.json",
|
|
22
|
+
release: "release.json",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/** @see RUN_FILES */
|
|
26
|
+
export type RunFileName = keyof typeof RUN_FILES;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolves the contract §7 paths for one workspace.
|
|
30
|
+
*
|
|
31
|
+
* A run ID becomes a directory name, so it is validated before use: an identifier containing a
|
|
32
|
+
* path separator or `..` would escape the workspace, and identifiers can originate from files
|
|
33
|
+
* another machine wrote.
|
|
34
|
+
*/
|
|
35
|
+
export class WorkspaceLayout {
|
|
36
|
+
readonly root: string;
|
|
37
|
+
readonly aldusDirectory: string;
|
|
38
|
+
|
|
39
|
+
constructor(workspaceRoot: string) {
|
|
40
|
+
this.root = workspaceRoot;
|
|
41
|
+
this.aldusDirectory = join(workspaceRoot, ALDUS_DIRECTORY);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `.aldus/episode.json` — the durable content identity (contract §6.1, §7). */
|
|
45
|
+
episodePath(): string {
|
|
46
|
+
return join(this.aldusDirectory, "episode.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `.aldus/runs` */
|
|
50
|
+
runsDirectory(): string {
|
|
51
|
+
return join(this.aldusDirectory, "runs");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `.aldus/runs/{run-id}` */
|
|
55
|
+
runDirectory(runId: string): string {
|
|
56
|
+
return join(this.runsDirectory(), assertPathSafe(runId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A named file inside a run directory (contract §7). */
|
|
60
|
+
runFilePath(runId: string, file: RunFileName): string {
|
|
61
|
+
return join(this.runDirectory(runId), RUN_FILES[file]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `.aldus/locks` — lockfiles, which are machine-local runtime state.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately a sibling of `runs/` rather than a file inside each run directory: §7's run
|
|
68
|
+
* directory lists exactly six files, and a lockfile appearing among Git-tracked state would
|
|
69
|
+
* invite committing another machine's PID.
|
|
70
|
+
*/
|
|
71
|
+
locksDirectory(): string {
|
|
72
|
+
return join(this.aldusDirectory, "locks");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resource name for the workspace-wide Episode record. */
|
|
77
|
+
export const EPISODE_LOCK_RESOURCE = "episode";
|
|
78
|
+
|
|
79
|
+
/** Resource name locking one Run. */
|
|
80
|
+
export function runLockResource(runId: string): string {
|
|
81
|
+
return `run-${assertPathSafe(runId)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reject an identifier that could escape its directory.
|
|
86
|
+
*
|
|
87
|
+
* Run IDs are minted by Core as `run_<ULID>`, but a workspace is shared and Git-tracked, so an
|
|
88
|
+
* identifier read from disk is untrusted input.
|
|
89
|
+
*/
|
|
90
|
+
function assertPathSafe(identifier: string): string {
|
|
91
|
+
if (
|
|
92
|
+
identifier.length === 0 ||
|
|
93
|
+
identifier === "." ||
|
|
94
|
+
identifier === ".." ||
|
|
95
|
+
identifier.includes("/") ||
|
|
96
|
+
identifier.includes("\\") ||
|
|
97
|
+
identifier.includes("\0")
|
|
98
|
+
) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Identifier ${JSON.stringify(identifier)} cannot be used as a directory name: it is empty, ` +
|
|
101
|
+
"a relative path segment, or contains a path separator.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return identifier;
|
|
105
|
+
}
|