@evo-dev/core 0.0.1-alpha.11 → 0.0.1-alpha.12

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.
@@ -0,0 +1,129 @@
1
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { resolveEvoDevPaths } from "../../config/paths.ts";
3
+
4
+ export interface TrajectoryImportSourcePaths {
5
+ rootDir: string;
6
+ projectDir: string;
7
+ sourceDir: string;
8
+ manifestPath: string;
9
+ lockDir: string;
10
+ lockOwnerPath: string;
11
+ lockRecoveryPath: string;
12
+ generationsDir: string;
13
+ receiptsDir: string;
14
+ materializationsDir: string;
15
+ }
16
+
17
+ export interface TrajectoryImportPaths extends TrajectoryImportSourcePaths {
18
+ generationDir: string;
19
+ snapshotsDir: string;
20
+ snapshotDir: string;
21
+ recordIndexPath: string;
22
+ previewReceiptDir: string;
23
+ stageReceiptPath: string;
24
+ materializeReceiptPath: string;
25
+ materializationPath: string;
26
+ }
27
+
28
+ export interface ResolveTrajectoryImportSourcePathsInput {
29
+ homeDir: string;
30
+ projectKey: string;
31
+ sourceKey: string;
32
+ }
33
+
34
+ export interface ResolveTrajectoryImportPathsInput extends ResolveTrajectoryImportSourcePathsInput {
35
+ generationId: string;
36
+ snapshotId: string;
37
+ previewId: string;
38
+ }
39
+
40
+ export function resolveTrajectoryImportSourcePaths(
41
+ input: ResolveTrajectoryImportSourcePathsInput,
42
+ ): TrajectoryImportSourcePaths {
43
+ assertProjectKey(input.projectKey);
44
+ assertStateId("sourceKey", input.sourceKey, /^src-[a-f0-9]{24}$/u);
45
+
46
+ const rootDir = resolve(resolveEvoDevPaths(input.homeDir).stateDir, "trajectory-imports");
47
+ const projectDir = join(rootDir, input.projectKey);
48
+ const sourceDir = join(projectDir, input.sourceKey);
49
+ const lockDir = join(sourceDir, "lock");
50
+ const generationsDir = join(sourceDir, "generations");
51
+ const receiptsDir = join(sourceDir, "receipts");
52
+ const materializationsDir = join(sourceDir, "materializations");
53
+
54
+ for (const candidate of [
55
+ projectDir,
56
+ sourceDir,
57
+ lockDir,
58
+ generationsDir,
59
+ receiptsDir,
60
+ materializationsDir,
61
+ ]) {
62
+ assertDescendant(rootDir, candidate);
63
+ }
64
+
65
+ return {
66
+ rootDir,
67
+ projectDir,
68
+ sourceDir,
69
+ manifestPath: join(sourceDir, "manifest.json"),
70
+ lockDir,
71
+ lockOwnerPath: join(lockDir, "owner.json"),
72
+ lockRecoveryPath: join(lockDir, "recovery.json"),
73
+ generationsDir,
74
+ receiptsDir,
75
+ materializationsDir,
76
+ };
77
+ }
78
+
79
+ export function resolveTrajectoryImportPaths(
80
+ input: ResolveTrajectoryImportPathsInput,
81
+ ): TrajectoryImportPaths {
82
+ assertStateId("generationId", input.generationId, /^gen-[a-f0-9]{24}$/u);
83
+ assertStateId("snapshotId", input.snapshotId, /^snap-[a-f0-9]{24}$/u);
84
+ assertStateId("previewId", input.previewId, /^preview-[a-f0-9]{24}$/u);
85
+
86
+ const sourcePaths = resolveTrajectoryImportSourcePaths(input);
87
+ const generationDir = join(sourcePaths.generationsDir, input.generationId);
88
+ const snapshotsDir = join(generationDir, "snapshots");
89
+ const snapshotDir = join(snapshotsDir, input.snapshotId);
90
+ const previewReceiptDir = join(sourcePaths.receiptsDir, input.previewId);
91
+
92
+ for (const candidate of [generationDir, snapshotDir, previewReceiptDir]) {
93
+ assertDescendant(sourcePaths.rootDir, candidate);
94
+ }
95
+
96
+ return {
97
+ ...sourcePaths,
98
+ generationDir,
99
+ snapshotsDir,
100
+ snapshotDir,
101
+ recordIndexPath: join(snapshotDir, "record-index.jsonl"),
102
+ previewReceiptDir,
103
+ stageReceiptPath: join(previewReceiptDir, "stage.json"),
104
+ materializeReceiptPath: join(previewReceiptDir, "materialize.json"),
105
+ materializationPath: join(sourcePaths.materializationsDir, `${input.snapshotId}.json`),
106
+ };
107
+ }
108
+
109
+ function assertProjectKey(value: string): void {
110
+ if (
111
+ value.length > 120 ||
112
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u.test(value) ||
113
+ value === "." ||
114
+ value === ".."
115
+ ) {
116
+ throw new Error("Invalid trajectory import projectKey.");
117
+ }
118
+ }
119
+
120
+ function assertStateId(field: string, value: string, pattern: RegExp): void {
121
+ if (!pattern.test(value)) throw new Error(`Invalid trajectory import ${field}.`);
122
+ }
123
+
124
+ function assertDescendant(rootDir: string, candidate: string): void {
125
+ const child = relative(rootDir, candidate);
126
+ if (child === "" || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) {
127
+ throw new Error("Trajectory import path escaped its private state root.");
128
+ }
129
+ }
@@ -0,0 +1,414 @@
1
+ import {
2
+ createTrajectoryImportIndexDigest,
3
+ createTrajectoryImportPreviewId,
4
+ createTrajectoryImportSnapshotId,
5
+ } from "./diff.ts";
6
+ import { resolveTrajectoryImportPaths, resolveTrajectoryImportSourcePaths } from "./paths.ts";
7
+ import {
8
+ type TrajectoryImportSourceLock,
9
+ assertTrajectoryImportSourceLockOwned,
10
+ ensureTrajectoryImportStageDirectories,
11
+ readTrajectoryImportIndex,
12
+ readTrajectoryImportManifestAtPath,
13
+ readTrajectoryImportReceipt,
14
+ withTrajectoryImportSourceLock,
15
+ writeImmutableTrajectoryImportIndex,
16
+ writeImmutableTrajectoryImportReceipt,
17
+ writeTrajectoryImportManifest,
18
+ } from "./storage.ts";
19
+ import type {
20
+ StageTrajectoryImportInput,
21
+ StageTrajectoryImportResult,
22
+ TrajectoryImportGenerationContractV1,
23
+ TrajectoryImportIndexRecordV1,
24
+ TrajectoryImportManifestV1,
25
+ TrajectoryImportReceiptV1,
26
+ } from "./types.ts";
27
+
28
+ export async function stageTrajectoryImport(
29
+ input: StageTrajectoryImportInput,
30
+ ): Promise<StageTrajectoryImportResult> {
31
+ const { sourcePaths } = validateAndResolveStageInput(input);
32
+ return withTrajectoryImportSourceLock(sourcePaths, async (lock) =>
33
+ stageTrajectoryImportWithLock(input, lock),
34
+ );
35
+ }
36
+
37
+ export async function stageTrajectoryImportWithLock(
38
+ input: StageTrajectoryImportInput,
39
+ lock: TrajectoryImportSourceLock,
40
+ ): Promise<StageTrajectoryImportResult> {
41
+ const { projectKey, sourcePaths } = validateAndResolveStageInput(input);
42
+ if (lock.ownerPath !== sourcePaths.lockOwnerPath) {
43
+ throw new Error("Trajectory import stage lock does not match its source.");
44
+ }
45
+ await assertTrajectoryImportSourceLockOwned(lock);
46
+ const manifest = await readTrajectoryImportManifestAtPath(sourcePaths.manifestPath);
47
+ validateManifestTransition(input, manifest);
48
+ await validateCurrentIndexTransition(input, manifest);
49
+
50
+ if (manifest !== null && manifest.currentSnapshotId === input.preview.snapshotId) {
51
+ return readUnchangedResult(input, manifest);
52
+ }
53
+ if (manifest !== null && input.preview.records.new === 0) {
54
+ return readUnchangedResult(input, manifest);
55
+ }
56
+
57
+ const paths = resolveTrajectoryImportPaths({
58
+ homeDir: input.homeDir,
59
+ projectKey,
60
+ sourceKey: input.preview.sourceKey,
61
+ generationId: input.preview.generationId,
62
+ snapshotId: input.preview.snapshotId,
63
+ previewId: input.preview.id,
64
+ });
65
+ await ensureTrajectoryImportStageDirectories(paths);
66
+ await assertTrajectoryImportSourceLockOwned(lock);
67
+
68
+ const indexWrite = await writeImmutableTrajectoryImportIndex(
69
+ paths,
70
+ input.comparisonSnapshot.records,
71
+ );
72
+ if (indexWrite.digest !== createTrajectoryImportIndexDigest(input.comparisonSnapshot.records)) {
73
+ throw new Error("Trajectory import staged index digest changed during validation.");
74
+ }
75
+ await assertTrajectoryImportSourceLockOwned(lock);
76
+
77
+ const existingReceipt = await readTrajectoryImportReceipt(paths.stageReceiptPath);
78
+ const receipt = resolveStageReceipt(input, existingReceipt);
79
+ const receiptWrite = await writeImmutableTrajectoryImportReceipt(paths.stageReceiptPath, receipt);
80
+ await assertTrajectoryImportSourceLockOwned(lock);
81
+
82
+ const nextManifest: TrajectoryImportManifestV1 = {
83
+ schemaVersion: 1,
84
+ kind: "trajectory-import-manifest",
85
+ source: input.preview.source,
86
+ sourceKey: input.preview.sourceKey,
87
+ projectKey,
88
+ scope: {
89
+ roleId: input.preview.scope.roleId,
90
+ },
91
+ currentGenerationId: input.preview.generationId,
92
+ initialInputFingerprint: input.comparisonSnapshot.initialInputFingerprint,
93
+ generationContract: input.comparisonSnapshot.generationContract,
94
+ currentSnapshotId: input.preview.snapshotId,
95
+ previousSnapshotId: input.preview.previousSnapshotId,
96
+ currentPreviewId: input.preview.id,
97
+ currentInputFingerprint: input.preview.input.fingerprint,
98
+ currentInputBytes: input.preview.input.bytes,
99
+ currentIndexDigest: indexWrite.digest,
100
+ stagedAt: receipt.createdAt,
101
+ applyState: "staged",
102
+ latestMaterializedSnapshotId: manifest?.latestMaterializedSnapshotId ?? null,
103
+ rawContentStored: false,
104
+ };
105
+ await writeTrajectoryImportManifest(sourcePaths, nextManifest);
106
+ await assertTrajectoryImportSourceLockOwned(lock);
107
+
108
+ return {
109
+ status: "staged",
110
+ manifest: nextManifest,
111
+ receipt,
112
+ stateWrites: [
113
+ ...(indexWrite.created
114
+ ? [
115
+ `generations/${input.preview.generationId}/snapshots/${input.preview.snapshotId}/record-index.jsonl`,
116
+ ]
117
+ : []),
118
+ ...(receiptWrite.created ? [`receipts/${input.preview.id}/stage.json`] : []),
119
+ "manifest.json",
120
+ ],
121
+ };
122
+ }
123
+
124
+ function validateAndResolveStageInput(input: StageTrajectoryImportInput): {
125
+ projectKey: string;
126
+ sourcePaths: ReturnType<typeof resolveTrajectoryImportSourcePaths>;
127
+ } {
128
+ validateStageInput(input);
129
+ const projectKey = input.preview.projectKey;
130
+ if (projectKey === null) {
131
+ throw new Error("Trajectory import stage requires a projectKey.");
132
+ }
133
+ return {
134
+ projectKey,
135
+ sourcePaths: resolveTrajectoryImportSourcePaths({
136
+ homeDir: input.homeDir,
137
+ projectKey,
138
+ sourceKey: input.preview.sourceKey,
139
+ }),
140
+ };
141
+ }
142
+
143
+ async function readUnchangedResult(
144
+ input: StageTrajectoryImportInput,
145
+ manifest: TrajectoryImportManifestV1,
146
+ ): Promise<StageTrajectoryImportResult> {
147
+ const projectKey = input.preview.projectKey;
148
+ if (projectKey === null) {
149
+ throw new Error("Trajectory import stage requires a projectKey.");
150
+ }
151
+ const currentPaths = resolveTrajectoryImportPaths({
152
+ homeDir: input.homeDir,
153
+ projectKey,
154
+ sourceKey: manifest.sourceKey,
155
+ generationId: manifest.currentGenerationId,
156
+ snapshotId: manifest.currentSnapshotId,
157
+ previewId: manifest.currentPreviewId,
158
+ });
159
+ const records = await readTrajectoryImportIndex(currentPaths.recordIndexPath);
160
+ const digest = createTrajectoryImportIndexDigest(records);
161
+ if (digest !== manifest.currentIndexDigest) {
162
+ throw new Error("Trajectory import current index digest does not match its manifest.");
163
+ }
164
+ const receipt = await readTrajectoryImportReceipt(currentPaths.stageReceiptPath);
165
+ if (receipt === null) {
166
+ throw new Error("Trajectory import manifest is missing its immutable stage receipt.");
167
+ }
168
+ assertReceiptMatchesManifest(receipt, manifest);
169
+ return {
170
+ status: "unchanged",
171
+ manifest,
172
+ receipt,
173
+ stateWrites: [],
174
+ };
175
+ }
176
+
177
+ function validateStageInput(input: StageTrajectoryImportInput): void {
178
+ const { preview, comparisonSnapshot } = input;
179
+ if (preview.schemaVersion !== 1 || preview.kind !== "trajectory-import-preview") {
180
+ throw new Error("Trajectory import stage requires a v1 preview.");
181
+ }
182
+ const { id: previewId, ...previewWithoutId } = preview;
183
+ if (createTrajectoryImportPreviewId(previewWithoutId) !== previewId) {
184
+ throw new Error("Trajectory import preview id does not match its content.");
185
+ }
186
+ if (preview.rawContentStored !== false) {
187
+ throw new Error("Trajectory import stage cannot persist raw content.");
188
+ }
189
+ if (preview.blockers.length > 0) {
190
+ throw new Error(`Trajectory import stage is blocked: ${preview.blockers.join(", ")}.`);
191
+ }
192
+ if (preview.records.changed > 0 || preview.records.missing > 0) {
193
+ throw new Error("Trajectory import stage is append-only.");
194
+ }
195
+ if (
196
+ preview.records.total !== comparisonSnapshot.records.length ||
197
+ preview.records.total !==
198
+ preview.records.new + preview.records.unchanged + preview.records.changed
199
+ ) {
200
+ throw new Error("Trajectory import preview counts do not match its index.");
201
+ }
202
+ if (
203
+ comparisonSnapshot.sourceKey !== preview.sourceKey ||
204
+ comparisonSnapshot.generationId !== preview.generationId ||
205
+ comparisonSnapshot.snapshotId !== preview.snapshotId
206
+ ) {
207
+ throw new Error("Trajectory import preview does not match its comparison snapshot.");
208
+ }
209
+ if (
210
+ createTrajectoryImportSnapshotId(
211
+ comparisonSnapshot.generationId,
212
+ comparisonSnapshot.records,
213
+ ) !== preview.snapshotId
214
+ ) {
215
+ throw new Error("Trajectory import snapshot id does not match its index.");
216
+ }
217
+ if (
218
+ !sameGenerationContract(comparisonSnapshot.generationContract, {
219
+ normalizerVersion: preview.normalizer.version,
220
+ canonicalSchemaVersion: preview.normalizer.canonicalSchemaVersion,
221
+ canonicalConfigHash: preview.normalizer.canonicalConfigHash,
222
+ })
223
+ ) {
224
+ throw new Error("Trajectory import generation contract does not match its preview.");
225
+ }
226
+ if (
227
+ input.now !== undefined &&
228
+ (!Number.isFinite(Date.parse(input.now)) || new Date(input.now).toISOString() !== input.now)
229
+ ) {
230
+ throw new Error("Trajectory import stage now must be an ISO timestamp.");
231
+ }
232
+ }
233
+
234
+ async function validateCurrentIndexTransition(
235
+ input: StageTrajectoryImportInput,
236
+ manifest: TrajectoryImportManifestV1 | null,
237
+ ): Promise<void> {
238
+ if (manifest === null) return;
239
+ const projectKey = input.preview.projectKey;
240
+ if (projectKey === null) {
241
+ throw new Error("Trajectory import stage requires a projectKey.");
242
+ }
243
+ const currentPaths = resolveTrajectoryImportPaths({
244
+ homeDir: input.homeDir,
245
+ projectKey,
246
+ sourceKey: manifest.sourceKey,
247
+ generationId: manifest.currentGenerationId,
248
+ snapshotId: manifest.currentSnapshotId,
249
+ previewId: manifest.currentPreviewId,
250
+ });
251
+ const previousRecords = await readTrajectoryImportIndex(currentPaths.recordIndexPath);
252
+ const previousDigest = createTrajectoryImportIndexDigest(previousRecords);
253
+ if (
254
+ previousDigest !== manifest.currentIndexDigest ||
255
+ createTrajectoryImportSnapshotId(manifest.currentGenerationId, previousRecords) !==
256
+ manifest.currentSnapshotId
257
+ ) {
258
+ throw new Error("Trajectory import current index does not match its manifest.");
259
+ }
260
+ if (manifest.currentSnapshotId === input.preview.snapshotId) {
261
+ if (createTrajectoryImportIndexDigest(input.comparisonSnapshot.records) !== previousDigest) {
262
+ throw new Error("Trajectory import repeated snapshot does not match staged state.");
263
+ }
264
+ return;
265
+ }
266
+
267
+ const actual = compareAppendOnlyIndexes(input.comparisonSnapshot.records, previousRecords);
268
+ if (
269
+ actual.new !== input.preview.records.new ||
270
+ actual.unchanged !== input.preview.records.unchanged ||
271
+ actual.changed !== input.preview.records.changed ||
272
+ actual.missing !== input.preview.records.missing
273
+ ) {
274
+ throw new Error("Trajectory import preview diff does not match current staged state.");
275
+ }
276
+ if (actual.changed > 0 || actual.missing > 0 || actual.identityRegressed) {
277
+ throw new Error("Trajectory import stage is append-only.");
278
+ }
279
+ }
280
+
281
+ function compareAppendOnlyIndexes(
282
+ current: TrajectoryImportIndexRecordV1[],
283
+ previous: TrajectoryImportIndexRecordV1[],
284
+ ): {
285
+ new: number;
286
+ unchanged: number;
287
+ changed: number;
288
+ missing: number;
289
+ identityRegressed: boolean;
290
+ } {
291
+ const currentByKey = new Map(current.map((record) => [record.stableRecordKeyHash, record]));
292
+ const previousByKey = new Map(previous.map((record) => [record.stableRecordKeyHash, record]));
293
+ let newRecords = 0;
294
+ let unchanged = 0;
295
+ let changed = 0;
296
+ let identityRegressed = false;
297
+ for (const record of current) {
298
+ const old = previousByKey.get(record.stableRecordKeyHash);
299
+ if (old === undefined) {
300
+ newRecords += 1;
301
+ continue;
302
+ }
303
+ if (old.sourceIdentityKind !== "content" && record.sourceIdentityKind === "content") {
304
+ identityRegressed = true;
305
+ }
306
+ if (old.contentDigest === record.contentDigest) unchanged += 1;
307
+ else changed += 1;
308
+ }
309
+ let missing = 0;
310
+ for (const record of previous) {
311
+ if (!currentByKey.has(record.stableRecordKeyHash)) missing += 1;
312
+ }
313
+ return { new: newRecords, unchanged, changed, missing, identityRegressed };
314
+ }
315
+
316
+ function validateManifestTransition(
317
+ input: StageTrajectoryImportInput,
318
+ manifest: TrajectoryImportManifestV1 | null,
319
+ ): void {
320
+ const { preview, comparisonSnapshot } = input;
321
+ if (manifest === null) {
322
+ if (preview.previousSnapshotId !== null) {
323
+ throw new Error("Trajectory import initial stage cannot reference a previous snapshot.");
324
+ }
325
+ if (preview.records.new !== preview.records.total || preview.records.unchanged !== 0) {
326
+ throw new Error("Trajectory import initial stage must contain only new records.");
327
+ }
328
+ return;
329
+ }
330
+
331
+ if (
332
+ manifest.source !== preview.source ||
333
+ manifest.sourceKey !== preview.sourceKey ||
334
+ manifest.projectKey !== preview.projectKey ||
335
+ manifest.scope.roleId !== preview.scope.roleId
336
+ ) {
337
+ throw new Error("Trajectory import stage scope does not match its manifest.");
338
+ }
339
+ if (
340
+ manifest.currentGenerationId !== preview.generationId ||
341
+ manifest.initialInputFingerprint !== comparisonSnapshot.initialInputFingerprint ||
342
+ !sameGenerationContract(manifest.generationContract, comparisonSnapshot.generationContract)
343
+ ) {
344
+ throw new Error("Trajectory import generation contract changed.");
345
+ }
346
+ if (manifest.currentSnapshotId === preview.snapshotId) return;
347
+ if (preview.previousSnapshotId !== manifest.currentSnapshotId) {
348
+ throw new Error("Trajectory import preview is stale for the current manifest.");
349
+ }
350
+ if (
351
+ manifest.applyState !== "completed" ||
352
+ manifest.latestMaterializedSnapshotId !== manifest.currentSnapshotId
353
+ ) {
354
+ throw new Error("Trajectory import cannot append until the current snapshot is materialized.");
355
+ }
356
+ }
357
+
358
+ function resolveStageReceipt(
359
+ input: StageTrajectoryImportInput,
360
+ existing: TrajectoryImportReceiptV1 | null,
361
+ ): TrajectoryImportReceiptV1 {
362
+ const expected: TrajectoryImportReceiptV1 = {
363
+ schemaVersion: 1,
364
+ kind: "trajectory-import-receipt",
365
+ previewId: input.preview.id,
366
+ sourceKey: input.preview.sourceKey,
367
+ generationId: input.preview.generationId,
368
+ snapshotId: input.preview.snapshotId,
369
+ operation: "stage",
370
+ outcome: "staged",
371
+ counts: {
372
+ total: input.preview.records.total,
373
+ new: input.preview.records.new,
374
+ unchanged: input.preview.records.unchanged,
375
+ changed: input.preview.records.changed,
376
+ missing: input.preview.records.missing,
377
+ },
378
+ createdAt: input.now ?? new Date().toISOString(),
379
+ rawContentStored: false,
380
+ };
381
+ if (existing === null) return expected;
382
+ const existingWithoutTime = { ...existing, createdAt: expected.createdAt };
383
+ if (JSON.stringify(existingWithoutTime) !== JSON.stringify(expected)) {
384
+ throw new Error("Trajectory import immutable stage receipt does not match its preview.");
385
+ }
386
+ return existing;
387
+ }
388
+
389
+ function assertReceiptMatchesManifest(
390
+ receipt: TrajectoryImportReceiptV1,
391
+ manifest: TrajectoryImportManifestV1,
392
+ ): void {
393
+ if (
394
+ receipt.operation !== "stage" ||
395
+ receipt.outcome !== "staged" ||
396
+ receipt.sourceKey !== manifest.sourceKey ||
397
+ receipt.generationId !== manifest.currentGenerationId ||
398
+ receipt.snapshotId !== manifest.currentSnapshotId ||
399
+ receipt.previewId !== manifest.currentPreviewId
400
+ ) {
401
+ throw new Error("Trajectory import stage receipt does not match its manifest.");
402
+ }
403
+ }
404
+
405
+ function sameGenerationContract(
406
+ left: TrajectoryImportGenerationContractV1,
407
+ right: TrajectoryImportGenerationContractV1,
408
+ ): boolean {
409
+ return (
410
+ left.normalizerVersion === right.normalizerVersion &&
411
+ left.canonicalSchemaVersion === right.canonicalSchemaVersion &&
412
+ left.canonicalConfigHash === right.canonicalConfigHash
413
+ );
414
+ }