narraeon 0.6.0 → 0.6.2

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 (48) hide show
  1. package/README.md +11 -0
  2. package/README.zh-CN.md +8 -0
  3. package/dist/node/protocol/v1.js +12 -2
  4. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  5. package/dist/node/runtime/V1Runtime.js +5 -5
  6. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  7. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  8. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  9. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  10. package/dist/node/runtime/authoring/ModelToolConversation.js +25 -2
  11. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  12. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  13. package/dist/node/runtime/config/dataRootLock.js +108 -0
  14. package/dist/node/runtime/content/ContentWorkspace.js +0 -10
  15. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +7 -44
  16. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  17. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  18. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  19. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  20. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +32 -207
  21. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +29 -239
  22. package/dist/node/runtime/play/PlayCallChain.js +338 -366
  23. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  24. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  25. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  26. package/dist/node/runtime/prompt/WorldMaintenanceReport.js +39 -7
  27. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  28. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +14 -6
  29. package/dist/node/runtime/setting/SettingImprovementSession.js +39 -25
  30. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  31. package/dist/node/runtime/world/FileNativeWorldStore.js +230 -781
  32. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  33. package/dist/node/runtime/world/WorldActivity.js +14 -22
  34. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  35. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  36. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  37. package/dist/node/runtime/world/WorldQueue.js +46 -0
  38. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  39. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +1 -13
  40. package/dist/node/runtime/world-revision/WorldRevisionSession.js +29 -2
  41. package/dist/node/server/mobile.js +52 -0
  42. package/dist/node/server/startWebServer.js +15 -8
  43. package/dist/web/assets/index-CXnFaYK9.js +1323 -0
  44. package/dist/web/assets/{index-BJ-1Mb0e.css → index-LPMV0pl3.css} +1 -1
  45. package/dist/web/index.html +2 -2
  46. package/package.json +2 -1
  47. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  48. package/dist/web/assets/index-BgBL8G-A.js +0 -1323
@@ -1,1010 +1,248 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { link, mkdir, open, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { isDeepStrictEqual } from "node:util";
5
- import { authorityV3Directory, continuityHeadFile, FileNativeAuthorityV3, } from "./FileNativeAuthorityV3.js";
6
- import { projectFileNativeHistorySurface } from "./FileNativeHistoryProjection.js";
7
- const currentAuthorityHeadFile = "play-authority-head.json";
8
- const releasedAuthorityFile = "play-authority.json";
9
- const releasedCallChainFile = "play-call-chain.json";
4
+ import { authorityV3Directory, continuityHeadFile, LegacyAuthorityV3Reader, staleV3WorldFiles, } from "./LegacyAuthorityV3Reader.js";
5
+ import { projectFileNativeHistorySurface, projectHistoryBackgrounds, } from "./FileNativeHistoryProjection.js";
6
+ import { WorldLedgerAuthority } from "./WorldLedgerAuthority.js";
7
+ /** A world written by v0.2.0 or earlier: no ledger and no Authority V3 chain. */
8
+ export class UnsupportedLegacyStorageError extends Error {
9
+ code = "unsupported_legacy_storage";
10
+ constructor(worldId) {
11
+ super(`World ${worldId} uses a storage format that is no longer supported; open it once with v0.3.0–v0.6.0 first`);
12
+ this.name = "UnsupportedLegacyStorageError";
13
+ }
14
+ }
10
15
  /**
11
- * One-way upgrade seam for every cumulative storage shape shipped through
12
- * v0.1.1. Callers serialize this module per world. It writes all new facts and
13
- * projections first and publishes the small Authority head last, so that head
14
- * is also the durable marker for the whole current layout.
16
+ * One-way upgrade seam from the Authority V3 chain to the current-files-plus-
17
+ * turn-ledger layout. Callers serialize this module per world. Formats older
18
+ * than V3 are rejected with UnsupportedLegacyStorageError.
15
19
  */
16
20
  export class FileNativeWorldStorageMigrator {
17
21
  #worldsRoot;
18
- #operationsRoot;
19
- #timeline;
20
- constructor(dataRoot, timeline) {
21
- const root = resolve(dataRoot);
22
- this.#worldsRoot = join(root, "worlds-file-native");
23
- this.#operationsRoot = join(root, "operations");
24
- this.#timeline = timeline;
22
+ constructor(dataRoot) {
23
+ this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
25
24
  }
26
25
  async migrate(worldId) {
27
26
  assertIdentity(worldId, "World ID");
28
27
  const worldRoot = join(this.#worldsRoot, worldId);
29
28
  const runtimeRoot = join(worldRoot, "runtime");
30
- const currentAuthority = new FileNativeAuthorityV3(worldRoot);
31
- if (await currentAuthority.exists()) {
32
- const head = await currentAuthority.readHead();
33
- return {
34
- outcome: "already_current",
35
- authorityCommits: head.sequence,
36
- callChainContexts: 0,
37
- };
38
- }
39
- const current = await readOptionalJson(join(runtimeRoot, currentAuthorityHeadFile));
40
- if (current !== null) {
41
- assertCurrentAuthorityHead(current);
42
- const commits = await readCurrentAuthority(worldRoot, current);
43
- if ((commits.at(-1)?.operationId ?? null) !== current.operationId)
44
- throw new Error("Current Authority head operation does not match its immutable tip");
45
- const contexts = await this.#timeline.readAllContexts(worldId);
46
- assertCurrentTimelineAuthorityRefs(contexts, commits);
47
- const checkpoint = await migrateMaterializedCheckpoint(runtimeRoot, commits);
48
- return this.#migrateCurrentAuthority({
49
- worldId,
50
- worldRoot,
51
- commits,
52
- checkpoint,
53
- callChainContexts: contexts.length,
54
- });
55
- }
56
- const publication = await readJson(join(worldRoot, "publication.json"));
57
- if (!isRecord(publication) ||
58
- publication.schemaVersion !== 1 ||
59
- publication.worldId !== worldId)
60
- throw new Error("Released world publication has an invalid identity");
61
- const genesis = await readJson(join(runtimeRoot, "genesis.json"));
62
- const genesisHistory = releasedGenesisHistory(genesis, worldId);
63
- const releasedAuthority = await readReleasedAuthority(worldRoot);
64
- const currentCommits = convertReleasedCommits(releasedAuthority.commits);
65
- await publishJson(join(runtimeRoot, "play-genesis-timeline.json"), {
66
- schemaVersion: 1,
67
- worldId,
68
- history: genesisHistory,
69
- });
70
- const callChainSource = await readOptionalText(join(runtimeRoot, releasedCallChainFile));
71
- let callChainContexts = 0;
72
- if (callChainSource !== null) {
73
- const value = parseJson(callChainSource, "Released play call chain");
74
- callChainContexts =
75
- isRecord(value) && Array.isArray(value.previousContexts)
76
- ? value.previousContexts.length + 1
77
- : 1;
78
- await this.#timeline.migrateReleasedRecord(worldId, value, sha256Hex(callChainSource));
79
- }
80
- crashAtStorageMigrationEdge("after_timeline");
81
- const migratedContexts = await this.#timeline.readAllContexts(worldId);
82
- if (migratedContexts.length !== callChainContexts)
83
- throw new Error("Released play timeline migration changed its context closure");
84
- assertCurrentTimelineAuthorityRefs(migratedContexts, currentCommits);
85
- for (const commit of currentCommits)
86
- await publishImmutableJson(join(runtimeRoot, "play-commits", `${currentCommitDigest(commit)}.json`), commit);
87
- const checkpoint = await migrateMaterializedCheckpoint(runtimeRoot, currentCommits);
88
- await publishJson(join(runtimeRoot, "materialized-head.json"), checkpoint);
89
- await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
90
- for (const commit of currentCommits) {
91
- const outcome = {
92
- outcome: checkpoint.sequence >= commit.sequence
93
- ? "committed"
94
- : "committed_materialization_pending",
95
- worldId,
96
- parentHead: commit.parentHead,
97
- head: commit.head,
98
- commitDigest: currentCommitDigest(commit),
99
- historyAppend: commit.historyAppend.map(({ role, exactText }) => ({
100
- role,
101
- exactText,
102
- })),
103
- nextAdditionalMaterials: structuredClone(commit.nextAdditionalMaterials),
104
- mode: commit.mode,
105
- };
106
- await publishJson(operationOutcomePath(this.#operationsRoot, commit.operationId), outcome);
107
- }
108
- crashAtStorageMigrationEdge("after_current_facts");
109
- await verifyCurrentFacts(runtimeRoot, currentCommits, checkpoint);
110
- crashAtStorageMigrationEdge("before_current_head");
111
- const tip = currentCommits.at(-1);
112
- const head = tip === undefined
113
- ? {
114
- schemaVersion: 2,
115
- head: "genesis",
116
- sequence: 0,
117
- commitDigest: null,
118
- operationId: null,
119
- }
120
- : {
121
- schemaVersion: 2,
122
- head: tip.head,
123
- sequence: tip.sequence,
124
- commitDigest: currentCommitDigest(tip),
125
- operationId: tip.operationId,
126
- };
127
- await publishJson(join(runtimeRoot, currentAuthorityHeadFile), head);
128
- crashAtStorageMigrationEdge("after_current_head");
129
- return this.#migrateCurrentAuthority({
130
- worldId,
131
- worldRoot,
132
- commits: currentCommits,
133
- checkpoint,
134
- callChainContexts,
135
- });
136
- }
137
- async #migrateCurrentAuthority(input) {
138
- return migrateCurrentAuthorityToV3({
139
- ...input,
140
- operationsRoot: this.#operationsRoot,
141
- });
142
- }
143
- }
144
- async function migrateCurrentAuthorityToV3(input) {
145
- const runtimeRoot = join(input.worldRoot, "runtime");
146
- const genesis = await readCurrentGenesis(input.worldRoot, input.worldId);
147
- const normalized = normalizeAuthorityIdentity(genesis.history, input.commits);
148
- const stagingWorld = join(runtimeRoot, ".authority-v3-migration");
149
- const stagedRuntime = join(stagingWorld, "runtime");
150
- const targetAuthority = join(runtimeRoot, authorityV3Directory);
151
- await rm(stagingWorld, { recursive: true, force: true });
152
- const staged = new FileNativeAuthorityV3(stagingWorld);
153
- try {
154
- const genesisMaterials = normalizeMaterials(genesis.additionalMaterials, normalized.messageIds, input.worldId);
155
- await staged.initialize({
156
- operationId: genesis.operationId,
157
- state: genesis.state,
158
- history: normalized.genesisHistory,
159
- additionalMaterials: genesisMaterials,
160
- });
161
- const expectedEndpoints = new Map([
162
- [
163
- "genesis",
164
- {
165
- state: structuredClone(genesis.state),
166
- history: structuredClone(normalized.genesisHistory),
167
- additionalMaterials: structuredClone(genesisMaterials),
168
- },
169
- ],
170
- ]);
171
- const migrated = [];
172
- for (const source of input.commits) {
173
- const historyAppend = source.historyAppend.map((message) => normalizeHistoryMessage(message, normalized.messageIds));
174
- const nextMaterials = normalizeMaterials(source.nextAdditionalMaterials, normalized.messageIds, input.worldId);
175
- const basisHead = source.timelineRevision?.restoresHead ?? source.parentHead;
176
- const basis = expectedEndpoints.get(basisHead);
177
- if (basis === undefined)
178
- throw new Error("Released Authority transition references an unknown result basis");
179
- if (source.timelineRevision !== undefined) {
180
- const restored = await staged.recover(source.timelineRevision.restoresHead);
181
- const expectedHistory = source.timelineRevision.replacementHistory.map((message) => normalizeHistoryMessage(message, normalized.messageIds));
182
- if (!isDeepStrictEqual(canonicalState(restored.state), canonicalLegacyState(source.timelineRevision.replacementState)) ||
183
- !isDeepStrictEqual(restored.history, expectedHistory) ||
184
- !isDeepStrictEqual(restored.additionalMaterials, nextMaterials))
185
- throw new Error("Released timeline revision does not exactly restore its logical parent");
186
- }
187
- const prepared = await staged.prepareAppend({
188
- operationId: source.operationId,
189
- parentHead: source.parentHead,
190
- ...(source.timelineRevision === undefined
191
- ? {}
192
- : { timelineParentHead: source.timelineRevision.restoresHead }),
193
- mode: source.mode,
194
- historyAppend: historyAppend.map(({ role, exactText }) => ({
195
- role,
196
- exactText,
197
- })),
198
- stateChanges: source.stateChanges,
199
- nextMaterials,
200
- ...(source.correctionTargets === undefined
201
- ? {}
202
- : { correctionTargets: source.correctionTargets }),
203
- ...(source.corrects === undefined ? {} : { corrects: source.corrects }),
204
- ...(source.timelineRevision === undefined
205
- ? {}
206
- : {
207
- timelineRevision: {
208
- restoresHead: source.timelineRevision.restoresHead,
209
- replacesHead: source.timelineRevision.replacesHead,
210
- requestFingerprint: source.timelineRevision.requestFingerprint,
211
- },
212
- }),
213
- });
214
- if (!isDeepStrictEqual(prepared.commit.historyAppend, historyAppend) ||
215
- prepared.commit.head !== source.head)
216
- throw new Error("Migrated Authority fact changed released semantics");
217
- await staged.publishPrepared(prepared);
218
- const expected = applyReleasedTransition(basis, source, historyAppend, nextMaterials);
219
- const recovered = await staged.recover(source.head);
220
- if (!isDeepStrictEqual(canonicalState(recovered.state), canonicalState(expected.state)) ||
221
- !isDeepStrictEqual(recovered.history, expected.history) ||
222
- !isDeepStrictEqual(recovered.additionalMaterials, expected.additionalMaterials))
223
- throw new Error(`Migrated Authority endpoint changed released semantics: ${source.head}`);
224
- expectedEndpoints.set(source.head, expected);
225
- migrated.push({
226
- source,
227
- fact: prepared.commit,
228
- digest: prepared.commitDigest,
229
- });
29
+ const ledger = new WorldLedgerAuthority(worldRoot);
30
+ if (await ledger.exists()) {
31
+ // V3 迁移在写完 head.json 后、删除旧目录前若中断,会留下只读不写的残留。
32
+ // 账本已经是端点,残留目录不再被读取,这里一并清理。
33
+ for (const residue of [
34
+ join(runtimeRoot, authorityV3Directory),
35
+ join(runtimeRoot, ".authority-v3-migration"),
36
+ ])
37
+ await rm(residue, { recursive: true, force: true });
38
+ const head = await ledger.readHead();
39
+ return { outcome: "already_current", authorityCommits: head.sequence };
230
40
  }
231
- const finalHead = await staged.readHead();
232
- if (finalHead.sequence !== input.commits.length ||
233
- finalHead.head !== (input.commits.at(-1)?.head ?? "genesis"))
234
- throw new Error("Migrated Authority head does not match released history");
235
- await staged.readHistory();
236
- const checkpointFact = input.checkpoint.sequence === 0
237
- ? null
238
- : migrated[input.checkpoint.sequence - 1];
239
- if ((input.checkpoint.sequence === 0 &&
240
- input.checkpoint.head !== "genesis") ||
241
- (input.checkpoint.sequence > 0 &&
242
- checkpointFact?.fact.head !== input.checkpoint.head))
243
- throw new Error("Released materialized checkpoint is not in migrated Authority");
244
- const checkpointEndpoint = await staged.recover(input.checkpoint.head);
245
- await rm(targetAuthority, { recursive: true, force: true });
246
- await rename(join(stagedRuntime, authorityV3Directory), targetAuthority);
247
- await syncDirectory(runtimeRoot);
248
- await publishJson(join(runtimeRoot, "play-genesis-timeline.json"), {
249
- schemaVersion: 1,
250
- worldId: input.worldId,
251
- history: normalized.genesisHistory,
252
- });
253
- // V2 filenames hash world-scoped message IDs. Rebuild this derived surface
254
- // from the normalized checkpoint before publishing the V3 checkpoint/head.
255
- await replaceMaterializedHistory(join(input.worldRoot, "history"), projectFileNativeHistorySurface(checkpointEndpoint.history, (messageId) => {
256
- throw new Error(`Migrated history-message identity cannot form a materialization path: ${messageId}`);
257
- }));
258
- await publishJson(join(runtimeRoot, "additional-materials.json"), {
259
- head: input.checkpoint.head,
260
- items: checkpointEndpoint.additionalMaterials,
261
- });
262
- await publishJson(join(runtimeRoot, "materialized-head.json"), {
263
- schemaVersion: 3,
264
- head: input.checkpoint.head,
265
- sequence: input.checkpoint.sequence,
266
- commitDigest: checkpointFact?.digest ?? null,
267
- });
268
- await mkdir(input.operationsRoot, { recursive: true, mode: 0o700 });
269
- for (const { source, fact, digest } of migrated) {
270
- await publishJson(operationOutcomePath(input.operationsRoot, source.operationId), {
271
- outcome: input.checkpoint.sequence >= fact.sequence
272
- ? "committed"
273
- : "committed_materialization_pending",
274
- worldId: input.worldId,
275
- parentHead: fact.auditParent.head,
276
- head: fact.head,
277
- commitDigest: digest,
278
- historyAppend: fact.historyAppend.map(({ role, exactText }) => ({
279
- role,
280
- exactText,
281
- })),
282
- nextAdditionalMaterials: fact.nextAdditionalMaterials,
283
- mode: fact.mode,
284
- });
41
+ const authority = new LegacyAuthorityV3Reader(worldRoot);
42
+ if (await authority.exists()) {
43
+ const { commits } = await convertAuthorityV3ToLedger(worldRoot);
44
+ return { outcome: "migrated", authorityCommits: commits };
285
45
  }
286
- await rm(stagingWorld, { recursive: true, force: true });
287
- crashAtStorageMigrationEdge("before_authority_v3_head");
288
- await publishJson(join(runtimeRoot, continuityHeadFile), finalHead);
289
- crashAtStorageMigrationEdge("after_authority_v3_head");
290
- return {
291
- outcome: "migrated",
292
- authorityCommits: migrated.length,
293
- callChainContexts: input.callChainContexts,
294
- };
295
- }
296
- finally {
297
- await rm(stagingWorld, { recursive: true, force: true }).catch(() => undefined);
46
+ throw new UnsupportedLegacyStorageError(worldId);
298
47
  }
299
48
  }
300
- function applyReleasedTransition(basis, source, historyAppend, nextMaterials) {
301
- const state = new Map(basis.state.map(({ path, contents }) => [path, contents]));
302
- for (const change of source.stateChanges) {
303
- const previous = state.get(change.relativePath);
304
- if ((previous === undefined ? null : `sha256:${sha256Hex(previous)}`) !==
305
- change.expectedPreviousHash ||
306
- `sha256:${sha256Hex(change.canonicalNextBytes)}` !== change.nextHash)
307
- throw new Error(`Released state transition hash conflicts: ${change.relativePath}`);
308
- state.set(change.relativePath, change.canonicalNextBytes);
309
- }
310
- return {
311
- state: [...state].map(([path, contents]) => ({ path, contents })),
312
- history: [
313
- ...structuredClone(basis.history),
314
- ...structuredClone(historyAppend),
315
- ],
316
- additionalMaterials: structuredClone([...nextMaterials]),
49
+ const retiredAuthorityPaths = [
50
+ join("runtime", authorityV3Directory),
51
+ join("runtime", continuityHeadFile),
52
+ join("runtime", ".authority-v3-migration"),
53
+ ...staleV3WorldFiles,
54
+ ];
55
+ /**
56
+ * Write the current-files-plus-turn-ledger layout for a migrated world.
57
+ * Each turn's before-images come from its basis endpoint; a timeline revision
58
+ * folds into one turn that restores its logical parent, so committed numbering
59
+ * is unchanged. The result is verified by reverse replay before anything is
60
+ * published, and head.json is written last: an interrupted migration simply
61
+ * runs again.
62
+ */
63
+ async function writeTurnLedger(worldRoot, input) {
64
+ const filesOf = new Map();
65
+ const filesAt = (sequence) => {
66
+ const cached = filesOf.get(sequence);
67
+ if (cached !== undefined)
68
+ return cached;
69
+ const endpoint = input.endpoints.get(sequence);
70
+ if (endpoint === undefined)
71
+ throw new Error(`Migrated world has no endpoint at ${String(sequence)}`);
72
+ const files = endpointFiles(endpoint);
73
+ filesOf.set(sequence, files);
74
+ return files;
317
75
  };
318
- }
319
- function assertCurrentTimelineAuthorityRefs(contexts, commits) {
320
- // Released clone-and-remap forks retained their inherited page/model trace
321
- // prefix while absorbing the selected source history into the target
322
- // genesis. Older contexts can therefore carry source-world commit aliases
323
- // that never belonged to the target Authority. They are immutable display
324
- // history; only the current context is a continuation boundary owned by the
325
- // target world and must resolve against its Authority.
326
- const context = contexts.at(-1);
327
- if (context === undefined)
328
- return;
329
- const heads = new Set(["genesis", ...commits.map(({ head }) => head)]);
330
- if (!heads.has(context.baselineHead) || !heads.has(context.parentHead))
331
- throw new Error("Released play timeline references an unknown Authority endpoint");
332
- for (const event of context.events) {
333
- if ("committedHead" in event &&
334
- event.committedHead !== undefined &&
335
- !heads.has(event.committedHead))
336
- throw new Error("Released play event references an unknown Authority endpoint");
337
- }
338
- }
339
- async function readCurrentAuthority(worldRoot, head) {
340
- const reverse = [];
341
- let digest = head.commitDigest;
342
- let expectedHead = head.head;
343
- let expectedSequence = head.sequence;
344
- while (digest !== null) {
345
- const value = await readJson(join(worldRoot, "runtime", "play-commits", `${digest}.json`));
346
- assertCurrentPlayCommit(value);
347
- if (currentCommitDigest(value) !== digest ||
348
- value.head !== expectedHead ||
349
- value.sequence !== expectedSequence)
350
- throw new Error("Current Authority endpoint does not match its commit");
351
- reverse.push(value);
352
- digest = value.parentCommitDigest;
353
- expectedHead = value.parentHead;
354
- expectedSequence -= 1;
76
+ const finalFiles = filesAt(input.headSequence);
77
+ let records = [];
78
+ const basisOf = new Map();
79
+ for (const step of input.steps) {
80
+ basisOf.set(step.sequence, step.basisSequence);
81
+ if (step.restoresSequence !== undefined)
82
+ records = records.filter((record) => record.sequence <= step.restoresSequence);
83
+ records.push({
84
+ sequence: step.sequence,
85
+ mode: step.mode,
86
+ createdAt: new Date().toISOString(),
87
+ ...(step.restoresSequence === undefined
88
+ ? {}
89
+ : { restoresSequence: step.restoresSequence }),
90
+ payload: step.payload,
91
+ files: diffEndpoints(filesAt(step.basisSequence), filesAt(step.sequence)),
92
+ });
355
93
  }
356
- if (expectedHead !== "genesis" || expectedSequence !== 0)
357
- throw new Error("Current Authority chain does not terminate at genesis");
358
- return reverse.reverse();
359
- }
360
- async function readCurrentGenesis(worldRoot, worldId) {
361
- const value = await readJson(join(worldRoot, "runtime", "genesis.json"));
362
- if (!isRecord(value) ||
363
- value.schemaVersion !== 1 ||
364
- value.type !== "file_native_genesis" ||
365
- value.worldId !== worldId ||
366
- (typeof value.operationId !== "string" && value.operationId !== null) ||
367
- !Array.isArray(value.state) ||
368
- !Array.isArray(value.history) ||
369
- !value.history.every(isReleasedHistoryMessage) ||
370
- !Array.isArray(value.additionalMaterials) ||
371
- !value.additionalMaterials.every(isMaterialSelection))
372
- throw new Error("Current world genesis has an invalid shape");
373
- const state = value.state.map((file) => {
374
- if (!isRecord(file) ||
375
- typeof file.path !== "string" ||
376
- !validRelativePath(file.path) ||
377
- typeof file.sha256 !== "string" ||
378
- typeof file.canonicalBytes !== "string" ||
379
- `sha256:${sha256Hex(file.canonicalBytes)}` !== file.sha256)
380
- throw new Error("Current world genesis state is corrupt");
381
- return { path: file.path, contents: file.canonicalBytes };
382
- });
383
- return {
384
- operationId: value.operationId,
385
- state,
386
- history: structuredClone(value.history),
387
- additionalMaterials: structuredClone(value.additionalMaterials),
388
- };
389
- }
390
- function normalizeAuthorityIdentity(genesis, commits) {
391
- const messageIds = new Map();
392
- const bind = (source, target) => {
393
- if (messageIds.has(source) && messageIds.get(source) !== target)
394
- throw new Error("Released Authority reuses one message identity");
395
- messageIds.set(source, target);
396
- messageIds.set(target, target);
397
- };
398
- for (const [index, message] of genesis.entries())
399
- bind(message.messageId, index === 0 && message.role === "narrator"
400
- ? "message.genesis.narrator"
401
- : `message.genesis.${index + 1}.${message.role}`);
402
- for (const commit of commits)
403
- for (const [index, message] of commit.historyAppend.entries())
404
- bind(message.messageId, `message.${commit.sequence}.${index + 1}.${message.role}`);
405
- return {
406
- genesisHistory: genesis.map((message) => normalizeHistoryMessage(message, messageIds)),
407
- messageIds,
408
- };
409
- }
410
- function normalizeHistoryMessage(message, messageIds) {
411
- const messageId = messageIds.get(message.messageId);
412
- if (messageId === undefined)
413
- throw new Error(`Released Authority references an unknown history message: ${message.messageId}`);
414
- return { ...structuredClone(message), messageId };
415
- }
416
- function normalizeMaterials(materials, messageIds, worldId) {
417
- return materials.map((material) => {
418
- if (material.kind === "history_message") {
419
- const message = messageIds.get(material.message);
420
- if (message === undefined)
421
- throw new Error(`Released material references an unknown history message: ${material.message}`);
422
- return { ...material, message };
94
+ // 逆向回放:从最终端点倒推每一轮,必须逐轮复现它的基底端点。
95
+ const current = new Map(finalFiles);
96
+ for (const record of [...records].reverse()) {
97
+ for (const file of record.files) {
98
+ if (file.before === null)
99
+ current.delete(file.path);
100
+ else
101
+ current.set(file.path, file.before);
423
102
  }
424
- if (material.kind === "history_commit") {
425
- const prefix = `${worldId}.`;
426
- const commit = material.commit.startsWith(prefix)
427
- ? material.commit.slice(prefix.length)
428
- : material.commit;
429
- if (!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(commit))
430
- throw new Error(`Released material references a foreign Authority endpoint: ${material.commit}`);
431
- return {
432
- ...material,
433
- commit,
434
- };
435
- }
436
- return structuredClone(material);
103
+ const expected = filesAt(basisOf.get(record.sequence) ?? -1);
104
+ if (!sameFiles(current, expected))
105
+ throw new Error(`World ledger migration cannot reproduce the endpoint before commit ${String(record.sequence)}`);
106
+ }
107
+ for (const directory of [
108
+ "state",
109
+ "history",
110
+ join("runtime", "history-backgrounds"),
111
+ ])
112
+ await replaceDirectoryFiles(worldRoot, directory, finalFiles);
113
+ await publishText(join(worldRoot, "runtime", "additional-materials.json"), finalFiles.get(join("runtime", "additional-materials.json")) ?? "");
114
+ await rm(join(worldRoot, "runtime", "turns"), {
115
+ recursive: true,
116
+ force: true,
437
117
  });
118
+ for (const record of records)
119
+ await publishJson(join(worldRoot, "runtime", "turns", `${String(record.sequence).padStart(8, "0")}.json`), record);
120
+ await publishJson(join(worldRoot, "runtime", "head.json"), {
121
+ sequence: input.headSequence,
122
+ });
123
+ // 账本已经发布:旧的 V3 与物化文件不再被读取,移除它们。
124
+ for (const retired of retiredAuthorityPaths)
125
+ await rm(join(worldRoot, retired), { recursive: true, force: true });
438
126
  }
439
- function canonicalState(files) {
440
- return files
441
- .map(({ path, contents }) => ({
442
- path,
443
- sha256: `sha256:${sha256Hex(contents)}`,
444
- canonicalBytes: contents,
445
- }))
446
- .sort((left, right) => left.path.localeCompare(right.path));
447
- }
448
- function canonicalLegacyState(files) {
449
- return [...structuredClone(files)].sort((left, right) => left.path.localeCompare(right.path));
450
- }
451
- async function readReleasedAuthority(worldRoot) {
452
- const runtimeRoot = join(worldRoot, "runtime");
453
- const published = await readOptionalJson(join(runtimeRoot, releasedAuthorityFile));
454
- if (published !== null) {
455
- assertReleasedAuthority(published);
456
- for (const commit of published.commits) {
457
- const digest = releasedCommitDigest(commit);
458
- const immutable = await readJson(join(runtimeRoot, "play-commits", `${digest}.json`));
459
- assertReleasedCommit(immutable);
460
- if (!isDeepStrictEqual(immutable, commit))
461
- throw new Error(`Released Authority endpoint does not match immutable commit ${commit.head}`);
462
- }
463
- return structuredClone(published);
464
- }
465
- const names = await readDirectoryFileNames(join(runtimeRoot, "play-commits"));
466
- const remaining = [];
467
- for (const name of names) {
468
- const value = await readJson(join(runtimeRoot, "play-commits", name));
469
- if (isRecord(value) && value.schemaVersion === 2)
470
- continue;
471
- assertReleasedCommit(value);
472
- if (name !== `${releasedCommitDigest(value)}.json`)
473
- throw new Error("Released immutable commit does not match its durable identity");
474
- remaining.push(value);
475
- }
476
- if (remaining.length === 0)
477
- return { schemaVersion: 1, head: "genesis", commits: [] };
478
- const commits = [];
479
- let parent = "genesis";
480
- while (remaining.length > 0) {
481
- const matches = remaining.filter((commit) => commit.parentHead === parent);
482
- if (matches.length !== 1)
483
- throw new Error("Released immutable commits cannot rebuild one unique Authority chain");
484
- const next = matches[0];
485
- commits.push(next);
486
- remaining.splice(remaining.indexOf(next), 1);
487
- parent = next.head;
488
- }
489
- const rebuilt = {
490
- schemaVersion: 1,
491
- head: parent,
492
- commits,
493
- };
494
- assertReleasedAuthority(rebuilt);
495
- return rebuilt;
496
- }
497
- function convertReleasedCommits(commits) {
498
- const converted = [];
499
- let parentCommitDigest = null;
500
- for (const [index, source] of commits.entries()) {
501
- const commit = {
502
- schemaVersion: 2,
503
- sequence: index + 1,
504
- operationId: source.operationId,
505
- parentHead: source.parentHead,
506
- parentCommitDigest,
507
- head: source.head,
508
- mode: source.mode,
509
- historyAppend: structuredClone(source.historyAppend),
510
- stateChanges: structuredClone(source.stateChanges),
511
- nextAdditionalMaterials: structuredClone(source.nextAdditionalMaterials),
512
- ...(source.correctionTargets === undefined
513
- ? {}
514
- : { correctionTargets: structuredClone(source.correctionTargets) }),
515
- ...(source.corrects === undefined ? {} : { corrects: source.corrects }),
516
- ...(source.timelineRevision === undefined
517
- ? {}
518
- : { timelineRevision: structuredClone(source.timelineRevision) }),
519
- };
520
- converted.push(commit);
521
- parentCommitDigest = currentCommitDigest(commit);
127
+ /**
128
+ * Convert an Authority V3 fact chain into the current layout. Every endpoint is
129
+ * recovered from V3 and handed to the shared writer.
130
+ */
131
+ export async function convertAuthorityV3ToLedger(worldRoot) {
132
+ const authority = new LegacyAuthorityV3Reader(worldRoot);
133
+ const head = await authority.readHead();
134
+ const { commits } = await authority.readHistory();
135
+ const endpoints = new Map();
136
+ endpoints.set(0, await authority.recover("genesis"));
137
+ const steps = [];
138
+ for (const commit of commits) {
139
+ const revision = commit.timelineRevision;
140
+ const basisHead = revision?.restoresHead ?? commit.auditParent.head;
141
+ const basisSequence = headPositionOf(basisHead);
142
+ endpoints.set(commit.sequence, await authority.recover(commit.head));
143
+ steps.push({
144
+ sequence: commit.sequence,
145
+ basisSequence,
146
+ ...(revision === undefined ? {} : { restoresSequence: basisSequence }),
147
+ mode: commit.mode === "correction" ? "correction" : "play",
148
+ payload: payloadOf(commit, basisHead, commit.sequence === head.sequence ? head.operationId : null),
149
+ });
522
150
  }
523
- return converted;
151
+ await writeTurnLedger(worldRoot, {
152
+ endpoints,
153
+ steps,
154
+ headSequence: head.sequence,
155
+ });
156
+ return { commits: commits.length };
157
+ }
158
+ function headPositionOf(head) {
159
+ if (head === "genesis")
160
+ return 0;
161
+ const match = /^commit:([1-9][0-9]*)$/u.exec(head);
162
+ if (match === null)
163
+ throw new Error(`Authority endpoint is invalid: ${head}`);
164
+ return Number.parseInt(match[1], 10);
165
+ }
166
+ function endpointFiles(endpoint) {
167
+ const files = new Map();
168
+ for (const file of endpoint.state)
169
+ files.set(`state/${file.path}`, file.contents);
170
+ for (const file of projectFileNativeHistorySurface(endpoint.history, (messageId) => {
171
+ throw new Error(`History-message identity cannot form a path: ${messageId}`);
172
+ }))
173
+ files.set(`history/${file.path}`, file.contents);
174
+ for (const file of projectHistoryBackgrounds(endpoint.history))
175
+ files.set(`runtime/history-backgrounds/${file.path}`, file.contents);
176
+ files.set(join("runtime", "additional-materials.json"), `${JSON.stringify({ head: endpoint.head, items: endpoint.additionalMaterials }, null, 2)}\n`);
177
+ return files;
178
+ }
179
+ function diffEndpoints(basis, target) {
180
+ const paths = new Set([...basis.keys(), ...target.keys()]);
181
+ const changed = [];
182
+ for (const path of [...paths].sort()) {
183
+ const before = basis.get(path) ?? null;
184
+ if ((target.get(path) ?? null) !== before)
185
+ changed.push({ path, before });
186
+ }
187
+ return changed;
188
+ }
189
+ function sameFiles(left, right) {
190
+ if (left.size !== right.size)
191
+ return false;
192
+ for (const [path, contents] of left)
193
+ if (right.get(path) !== contents)
194
+ return false;
195
+ return true;
524
196
  }
525
- async function migrateMaterializedCheckpoint(runtimeRoot, commits) {
526
- const value = await readOptionalJson(join(runtimeRoot, "materialized-head.json"));
527
- if (value === null)
528
- return {
529
- schemaVersion: 2,
530
- head: "genesis",
531
- sequence: 0,
532
- commitDigest: null,
533
- };
534
- if (isRecord(value) &&
535
- hasExactKeys(value, [
536
- "schemaVersion",
537
- "head",
538
- "sequence",
539
- "commitDigest",
540
- ]) &&
541
- value.schemaVersion === 3 &&
542
- typeof value.head === "string" &&
543
- Number.isSafeInteger(value.sequence) &&
544
- Number(value.sequence) >= 0 &&
545
- (value.commitDigest === null ||
546
- (typeof value.commitDigest === "string" &&
547
- /^[a-f0-9]{64}$/u.test(value.commitDigest)))) {
548
- const sequence = Number(value.sequence);
549
- const expected = sequence === 0 ? undefined : commits[sequence - 1];
550
- if ((sequence === 0 &&
551
- (value.head !== "genesis" || value.commitDigest !== null)) ||
552
- (sequence > 0 && expected?.head !== value.head))
553
- throw new Error("Unmarked V3 materialized checkpoint is not in released Authority");
554
- return {
555
- schemaVersion: 2,
556
- head: value.head,
557
- sequence,
558
- commitDigest: expected === undefined ? null : currentCommitDigest(expected),
559
- };
560
- }
561
- if (isCurrentMaterializedCheckpoint(value)) {
562
- const expected = value.sequence === 0 ? undefined : commits[value.sequence - 1];
563
- if ((value.sequence === 0 &&
564
- (value.head !== "genesis" || value.commitDigest !== null)) ||
565
- (value.sequence > 0 &&
566
- (expected?.head !== value.head ||
567
- currentCommitDigest(expected) !== value.commitDigest)))
568
- throw new Error("Partially migrated materialized checkpoint is not in released Authority");
569
- return structuredClone(value);
570
- }
571
- if (!isRecord(value) ||
572
- !hasExactKeys(value, ["head"]) ||
573
- typeof value.head !== "string")
574
- throw new Error("Released materialized checkpoint has an invalid shape");
575
- if (value.head === "genesis")
576
- return {
577
- schemaVersion: 2,
578
- head: "genesis",
579
- sequence: 0,
580
- commitDigest: null,
581
- };
582
- const commit = commits.find(({ head }) => head === value.head);
583
- if (commit === undefined)
584
- throw new Error("Released materialized checkpoint is not in accepted Authority");
197
+ function payloadOf(commit, parentHead, operationId) {
585
198
  return {
586
- schemaVersion: 2,
587
- head: commit.head,
588
199
  sequence: commit.sequence,
589
- commitDigest: currentCommitDigest(commit),
200
+ head: commit.head,
201
+ operationId,
202
+ parentHead,
203
+ mode: commit.mode === "correction" ? "correction" : "play",
204
+ ...(commit.playContext === undefined
205
+ ? {}
206
+ : { playContext: commit.playContext }),
207
+ ...(commit.worldClock === undefined
208
+ ? {}
209
+ : { worldClock: commit.worldClock }),
210
+ historyAppend: structuredClone([...commit.historyAppend]),
211
+ stateChanges: commit.stateChanges.map(({ nextBlob, ...stored }) => {
212
+ void nextBlob;
213
+ return structuredClone(stored);
214
+ }),
215
+ nextAdditionalMaterials: structuredClone([
216
+ ...commit.nextAdditionalMaterials,
217
+ ]),
218
+ ...(commit.correctionTargets === undefined
219
+ ? {}
220
+ : { correctionTargets: [...commit.correctionTargets] }),
221
+ ...(commit.corrects === undefined ? {} : { corrects: commit.corrects }),
222
+ ...(commit.narrativeCheckpoint === undefined
223
+ ? {}
224
+ : { narrativeCheckpoint: structuredClone(commit.narrativeCheckpoint) }),
590
225
  };
591
226
  }
592
- async function verifyCurrentFacts(runtimeRoot, commits, checkpoint) {
593
- let parentDigest = null;
594
- for (const commit of commits) {
595
- if (commit.parentCommitDigest !== parentDigest)
596
- throw new Error("Migrated Authority parent digest is inconsistent");
597
- const digest = currentCommitDigest(commit);
598
- const durable = await readJson(join(runtimeRoot, "play-commits", `${digest}.json`));
599
- if (!isDeepStrictEqual(durable, commit))
600
- throw new Error("Migrated Authority commit verification failed");
601
- parentDigest = digest;
602
- }
603
- const durableCheckpoint = await readJson(join(runtimeRoot, "materialized-head.json"));
604
- if (!isDeepStrictEqual(durableCheckpoint, checkpoint))
605
- throw new Error("Migrated materialized checkpoint verification failed");
606
- }
607
- function releasedGenesisHistory(value, worldId) {
608
- if (!isRecord(value) ||
609
- value.schemaVersion !== 1 ||
610
- value.type !== "file_native_genesis" ||
611
- value.worldId !== worldId ||
612
- !Array.isArray(value.history) ||
613
- !value.history.every(isReleasedHistoryMessage))
614
- throw new Error("Released world genesis has an invalid identity");
615
- return structuredClone(value.history);
616
- }
617
- function assertReleasedAuthority(value) {
618
- if (!isRecord(value) ||
619
- !hasExactKeys(value, ["schemaVersion", "head", "commits"]) ||
620
- value.schemaVersion !== 1 ||
621
- typeof value.head !== "string" ||
622
- !Array.isArray(value.commits))
623
- throw new Error("Released Play Authority has an invalid shape");
624
- const operationIds = new Set();
625
- const heads = new Set();
626
- const commitsByHead = new Map();
627
- let parent = "genesis";
628
- for (const [index, commit] of value.commits.entries()) {
629
- assertReleasedCommit(commit);
630
- const revision = commit.timelineRevision;
631
- const replaced = revision === undefined
632
- ? undefined
633
- : commitsByHead.get(revision.replacesHead);
634
- const replacedLogicalParent = replaced?.mode === "timeline_revision"
635
- ? replaced.timelineRevision.restoresHead
636
- : replaced?.parentHead;
637
- if (commit.parentHead !== parent ||
638
- commit.head !== `commit:${index + 1}` ||
639
- operationIds.has(commit.operationId) ||
640
- heads.has(commit.head) ||
641
- (commit.mode === "timeline_revision" &&
642
- ((revision.restoresHead !== "genesis" &&
643
- !heads.has(revision.restoresHead)) ||
644
- replaced === undefined ||
645
- !replaced.historyAppend.some(({ role }) => role === "player") ||
646
- replacedLogicalParent !== revision.restoresHead)))
647
- throw new Error("Released Play Authority commit chain is invalid");
648
- operationIds.add(commit.operationId);
649
- heads.add(commit.head);
650
- commitsByHead.set(commit.head, commit);
651
- parent = commit.head;
227
+ async function replaceDirectoryFiles(worldRoot, directory, files) {
228
+ const prefix = `${directory}/`;
229
+ await rm(join(worldRoot, directory), { recursive: true, force: true });
230
+ for (const [path, contents] of files) {
231
+ if (!path.startsWith(prefix))
232
+ continue;
233
+ await publishText(join(worldRoot, path), contents);
652
234
  }
653
- if (value.head !== parent)
654
- throw new Error("Released Play Authority head is invalid");
655
- }
656
- function assertReleasedCommit(value) {
657
- if (!isRecord(value))
658
- throw new Error("Released play commit has an invalid shape");
659
- const correction = value.mode === "correction";
660
- const timelineRevision = value.mode === "timeline_revision";
661
- if (!hasExactKeys(value, [
662
- "schemaVersion",
663
- "operationId",
664
- "parentHead",
665
- "head",
666
- "mode",
667
- "historyAppend",
668
- "stateChanges",
669
- "nextAdditionalMaterials",
670
- ...(correction ? ["correctionTargets", "corrects"] : []),
671
- ...(timelineRevision ? ["timelineRevision"] : []),
672
- ]) ||
673
- value.schemaVersion !== 1 ||
674
- typeof value.operationId !== "string" ||
675
- value.operationId.trim() === "" ||
676
- typeof value.parentHead !== "string" ||
677
- !/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
678
- typeof value.head !== "string" ||
679
- !/^commit:[1-9][0-9]*$/u.test(value.head) ||
680
- (value.mode !== "play" &&
681
- value.mode !== "correction" &&
682
- value.mode !== "timeline_revision") ||
683
- !Array.isArray(value.historyAppend) ||
684
- (correction
685
- ? value.historyAppend.length !== 0
686
- : timelineRevision
687
- ? value.historyAppend.length !== 1 ||
688
- !isRecord(value.historyAppend[0]) ||
689
- value.historyAppend[0].role !== "player"
690
- : value.historyAppend.length === 0 &&
691
- (!Array.isArray(value.stateChanges) ||
692
- value.stateChanges.length === 0)) ||
693
- !value.historyAppend.every(isReleasedHistoryMessage) ||
694
- !Array.isArray(value.stateChanges) ||
695
- !value.stateChanges.every(isReleasedStateChange) ||
696
- !Array.isArray(value.nextAdditionalMaterials) ||
697
- !value.nextAdditionalMaterials.every(isMaterialSelection))
698
- throw new Error("Released play commit has an invalid shape");
699
- if (correction &&
700
- (!Array.isArray(value.correctionTargets) ||
701
- !value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
702
- typeof value.corrects !== "string" ||
703
- value.corrects !== value.parentHead))
704
- throw new Error("Released correction commit has an invalid shape");
705
- if (timelineRevision &&
706
- (value.stateChanges.length !== 0 ||
707
- !isReleasedTimelineRevision(value.timelineRevision)))
708
- throw new Error("Released timeline-revision commit has an invalid shape");
709
- }
710
- function isReleasedTimelineRevision(value) {
711
- if (!isRecord(value) ||
712
- !hasExactKeys(value, [
713
- "restoresHead",
714
- "replacesHead",
715
- "requestFingerprint",
716
- "replacementState",
717
- "replacementHistory",
718
- ]) ||
719
- typeof value.restoresHead !== "string" ||
720
- !/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.restoresHead) ||
721
- typeof value.replacesHead !== "string" ||
722
- !/^commit:[1-9][0-9]*$/u.test(value.replacesHead) ||
723
- typeof value.requestFingerprint !== "string" ||
724
- !/^sha256:[a-f0-9]{64}$/u.test(value.requestFingerprint) ||
725
- !Array.isArray(value.replacementState) ||
726
- !Array.isArray(value.replacementHistory) ||
727
- !value.replacementHistory.every(isReleasedHistoryMessage))
728
- return false;
729
- return value.replacementState.every((file) => isRecord(file) &&
730
- hasExactKeys(file, ["path", "sha256", "canonicalBytes"]) &&
731
- typeof file.path === "string" &&
732
- validRelativePath(file.path) &&
733
- typeof file.sha256 === "string" &&
734
- typeof file.canonicalBytes === "string" &&
735
- `sha256:${sha256Hex(file.canonicalBytes)}` === file.sha256);
736
- }
737
- function isReleasedHistoryMessage(value) {
738
- return (isRecord(value) &&
739
- hasExactKeys(value, ["messageId", "role", "exactText"]) &&
740
- typeof value.messageId === "string" &&
741
- value.messageId.trim() !== "" &&
742
- (value.role === "player" || value.role === "narrator") &&
743
- typeof value.exactText === "string");
744
- }
745
- function isReleasedStateChange(value) {
746
- return (isRecord(value) &&
747
- hasExactKeys(value, [
748
- "kind",
749
- "documentId",
750
- "stableShortRef",
751
- "relativePath",
752
- "codec",
753
- "expectedPreviousHash",
754
- "nextHash",
755
- "canonicalNextBytes",
756
- ]) &&
757
- (value.kind === "create" || value.kind === "replace") &&
758
- typeof value.documentId === "string" &&
759
- value.documentId.trim() !== "" &&
760
- typeof value.stableShortRef === "string" &&
761
- value.stableShortRef.trim() !== "" &&
762
- typeof value.relativePath === "string" &&
763
- validRelativePath(value.relativePath) &&
764
- (value.codec === "yaml" || value.codec === "markdown") &&
765
- (value.expectedPreviousHash === null ||
766
- (typeof value.expectedPreviousHash === "string" &&
767
- /^sha256:[a-f0-9]{64}$/u.test(value.expectedPreviousHash))) &&
768
- typeof value.nextHash === "string" &&
769
- /^sha256:[a-f0-9]{64}$/u.test(value.nextHash) &&
770
- typeof value.canonicalNextBytes === "string" &&
771
- `sha256:${sha256Hex(value.canonicalNextBytes)}` === value.nextHash);
772
- }
773
- function isMaterialSelection(value) {
774
- if (!isRecord(value))
775
- return false;
776
- if (value.kind === "document")
777
- return (hasExactKeys(value, ["kind", "document"]) &&
778
- typeof value.document === "string");
779
- if (value.kind === "node")
780
- return (hasExactKeys(value, ["kind", "document", "locator"]) &&
781
- typeof value.document === "string" &&
782
- isMaterialLocator(value.locator));
783
- if (value.kind === "history_message")
784
- return (hasExactKeys(value, ["kind", "message"]) &&
785
- typeof value.message === "string");
786
- return (value.kind === "history_commit" &&
787
- hasExactKeys(value, ["kind", "commit"]) &&
788
- typeof value.commit === "string");
789
- }
790
- function isMaterialLocator(value) {
791
- return (isRecord(value) &&
792
- hasRequiredAndOptionalKeys(value, [], ["yaml", "markdown"]) &&
793
- (value.yaml === undefined ||
794
- (Array.isArray(value.yaml) &&
795
- value.yaml.every((segment) => typeof segment === "string" || Number.isSafeInteger(segment)))) &&
796
- (value.markdown === undefined ||
797
- (Array.isArray(value.markdown) &&
798
- value.markdown.every((segment) => typeof segment === "string"))));
799
- }
800
- function assertCurrentAuthorityHead(value) {
801
- if (!isRecord(value) ||
802
- !hasExactKeys(value, [
803
- "schemaVersion",
804
- "head",
805
- "sequence",
806
- "commitDigest",
807
- "operationId",
808
- ]) ||
809
- value.schemaVersion !== 2 ||
810
- typeof value.head !== "string" ||
811
- !Number.isSafeInteger(value.sequence) ||
812
- Number(value.sequence) < 0 ||
813
- (value.commitDigest !== null &&
814
- (typeof value.commitDigest !== "string" ||
815
- !/^[a-f0-9]{64}$/u.test(value.commitDigest))) ||
816
- (value.operationId !== null && typeof value.operationId !== "string") ||
817
- (Number(value.sequence) === 0
818
- ? value.head !== "genesis" ||
819
- value.commitDigest !== null ||
820
- value.operationId !== null
821
- : value.head !== `commit:${String(value.sequence)}` ||
822
- value.commitDigest === null ||
823
- value.operationId === null))
824
- throw new Error("Current Authority head has an invalid shape");
825
- }
826
- function assertCurrentPlayCommit(value) {
827
- if (!isRecord(value))
828
- throw new Error("Current play commit has an invalid shape");
829
- const correction = value.mode === "correction";
830
- const timelineRevision = value.mode === "timeline_revision";
831
- if (!hasExactKeys(value, [
832
- "schemaVersion",
833
- "sequence",
834
- "operationId",
835
- "parentHead",
836
- "parentCommitDigest",
837
- "head",
838
- "mode",
839
- "historyAppend",
840
- "stateChanges",
841
- "nextAdditionalMaterials",
842
- ...(correction ? ["correctionTargets", "corrects"] : []),
843
- ...(timelineRevision ? ["timelineRevision"] : []),
844
- ]) ||
845
- value.schemaVersion !== 2 ||
846
- !Number.isSafeInteger(value.sequence) ||
847
- Number(value.sequence) < 1 ||
848
- typeof value.operationId !== "string" ||
849
- value.operationId.trim() === "" ||
850
- typeof value.parentHead !== "string" ||
851
- !/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
852
- (value.parentCommitDigest !== null &&
853
- (typeof value.parentCommitDigest !== "string" ||
854
- !/^[a-f0-9]{64}$/u.test(value.parentCommitDigest))) ||
855
- typeof value.head !== "string" ||
856
- value.head !== `commit:${String(value.sequence)}` ||
857
- (value.mode !== "play" &&
858
- value.mode !== "correction" &&
859
- value.mode !== "timeline_revision") ||
860
- !Array.isArray(value.historyAppend) ||
861
- !value.historyAppend.every(isReleasedHistoryMessage) ||
862
- !Array.isArray(value.stateChanges) ||
863
- !value.stateChanges.every(isReleasedStateChange) ||
864
- !Array.isArray(value.nextAdditionalMaterials) ||
865
- !value.nextAdditionalMaterials.every(isMaterialSelection))
866
- throw new Error("Current play commit has an invalid shape");
867
- if (correction &&
868
- (!Array.isArray(value.correctionTargets) ||
869
- !value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
870
- typeof value.corrects !== "string" ||
871
- value.corrects !== value.parentHead))
872
- throw new Error("Current correction commit has an invalid shape");
873
- if (timelineRevision &&
874
- (value.stateChanges.length !== 0 ||
875
- !isReleasedTimelineRevision(value.timelineRevision)))
876
- throw new Error("Current timeline revision has an invalid shape");
877
- }
878
- function isCurrentMaterializedCheckpoint(value) {
879
- return (isRecord(value) &&
880
- hasExactKeys(value, [
881
- "schemaVersion",
882
- "head",
883
- "sequence",
884
- "commitDigest",
885
- ]) &&
886
- value.schemaVersion === 2 &&
887
- typeof value.head === "string" &&
888
- Number.isSafeInteger(value.sequence) &&
889
- Number(value.sequence) >= 0 &&
890
- (value.commitDigest === null ||
891
- (typeof value.commitDigest === "string" &&
892
- /^[a-f0-9]{64}$/u.test(value.commitDigest))));
893
235
  }
894
- function currentCommitDigest(commit) {
895
- return sha256Hex(JSON.stringify(commit));
896
- }
897
- function releasedCommitDigest(commit) {
898
- return sha256Hex(JSON.stringify(commit));
899
- }
900
- function operationOutcomePath(root, operationId) {
901
- return join(root, `outcome-${sha256Hex(operationId)}.json`);
902
- }
903
- function validRelativePath(path) {
904
- return (path.length > 0 &&
905
- !path.startsWith("/") &&
906
- !path.includes("\\") &&
907
- !path
908
- .split("/")
909
- .some((part) => part === "" || part === "." || part === ".."));
236
+ async function publishText(path, contents) {
237
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
238
+ const temporary = `${path}.${randomUUID()}.tmp`;
239
+ await writeFile(temporary, contents, { encoding: "utf8", mode: 0o600 });
240
+ await rename(temporary, path);
910
241
  }
911
242
  function assertIdentity(value, label) {
912
243
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(value))
913
244
  throw new TypeError(`${label} is invalid`);
914
245
  }
915
- function isRecord(value) {
916
- return typeof value === "object" && value !== null && !Array.isArray(value);
917
- }
918
- function hasExactKeys(value, keys) {
919
- return (Object.keys(value).length === keys.length &&
920
- keys.every((key) => Object.hasOwn(value, key)));
921
- }
922
- function hasRequiredAndOptionalKeys(value, required, optional) {
923
- const allowed = new Set([...required, ...optional]);
924
- return (required.every((key) => Object.hasOwn(value, key)) &&
925
- Object.keys(value).every((key) => allowed.has(key)));
926
- }
927
- function parseJson(source, label) {
928
- try {
929
- return JSON.parse(source);
930
- }
931
- catch (error) {
932
- throw new Error(`${label} is not valid JSON`, { cause: error });
933
- }
934
- }
935
- function sha256Hex(value) {
936
- return createHash("sha256").update(value).digest("hex");
937
- }
938
- function isNodeError(value) {
939
- return value instanceof Error && "code" in value;
940
- }
941
- async function readJson(path) {
942
- return parseJson(await readFile(path, "utf8"), path);
943
- }
944
- async function readOptionalJson(path) {
945
- try {
946
- return await readJson(path);
947
- }
948
- catch (error) {
949
- if (isNodeError(error) && error.code === "ENOENT")
950
- return null;
951
- throw error;
952
- }
953
- }
954
- async function readOptionalText(path) {
955
- try {
956
- return await readFile(path, "utf8");
957
- }
958
- catch (error) {
959
- if (isNodeError(error) && error.code === "ENOENT")
960
- return null;
961
- throw error;
962
- }
963
- }
964
- async function readDirectoryFileNames(root) {
965
- try {
966
- return (await readdir(root, { withFileTypes: true }))
967
- .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
968
- .map(({ name }) => name)
969
- .sort();
970
- }
971
- catch (error) {
972
- if (isNodeError(error) && error.code === "ENOENT")
973
- return [];
974
- throw error;
975
- }
976
- }
977
- async function replaceMaterializedHistory(root, files) {
978
- await mkdir(root, { recursive: true, mode: 0o700 });
979
- const entries = await readdir(root, { withFileTypes: true });
980
- if (entries.some((entry) => !entry.isFile()))
981
- throw new Error("Materialized history projection must be a flat file tree");
982
- const current = new Map(await Promise.all(entries.map(async (entry) => [entry.name, await readFile(join(root, entry.name), "utf8")])));
983
- const expected = new Set();
984
- for (const file of files) {
985
- if (!validRelativePath(file.path) || file.path.includes("/"))
986
- throw new Error(`Migrated history projection has an invalid path: ${file.path}`);
987
- if (expected.has(file.path))
988
- throw new Error(`Migrated history projection has a duplicate path: ${file.path}`);
989
- expected.add(file.path);
990
- if (current.get(file.path) === file.contents)
991
- continue;
992
- const temporary = join(root, `${file.path}.${randomUUID()}.tmp`);
993
- await writeFile(temporary, file.contents, {
994
- encoding: "utf8",
995
- mode: 0o600,
996
- });
997
- await syncFile(temporary);
998
- await rename(temporary, join(root, file.path));
999
- await syncDirectory(root);
1000
- }
1001
- for (const entry of entries) {
1002
- if (expected.has(entry.name))
1003
- continue;
1004
- await rm(join(root, entry.name), { force: true });
1005
- await syncDirectory(root);
1006
- }
1007
- }
1008
246
  async function publishJson(path, value) {
1009
247
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
1010
248
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -1012,63 +250,5 @@ async function publishJson(path, value) {
1012
250
  encoding: "utf8",
1013
251
  mode: 0o600,
1014
252
  });
1015
- await syncFile(temporary);
1016
253
  await rename(temporary, path);
1017
- await syncDirectory(dirname(path));
1018
- }
1019
- async function publishImmutableJson(path, value) {
1020
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
1021
- const contents = `${JSON.stringify(value, null, 2)}\n`;
1022
- const temporary = `${path}.${randomUUID()}.tmp`;
1023
- try {
1024
- await writeFile(temporary, contents, {
1025
- encoding: "utf8",
1026
- flag: "wx",
1027
- mode: 0o600,
1028
- });
1029
- await syncFile(temporary);
1030
- try {
1031
- await link(temporary, path);
1032
- await syncDirectory(dirname(path));
1033
- }
1034
- catch (error) {
1035
- if (!isNodeError(error) || error.code !== "EEXIST")
1036
- throw error;
1037
- if ((await readFile(path, "utf8")) !== contents)
1038
- throw new Error("Migrated immutable file conflicts with existing data", {
1039
- cause: error,
1040
- });
1041
- }
1042
- }
1043
- finally {
1044
- await rm(temporary, { force: true });
1045
- }
1046
- }
1047
- async function syncFile(path) {
1048
- const handle = await open(path, "r");
1049
- try {
1050
- await handle.sync();
1051
- }
1052
- finally {
1053
- await handle.close();
1054
- }
1055
- }
1056
- async function syncDirectory(path) {
1057
- let handle;
1058
- try {
1059
- handle = await open(path, "r");
1060
- await handle.sync();
1061
- }
1062
- catch (error) {
1063
- if (!isNodeError(error) || error.code !== "EINVAL")
1064
- throw error;
1065
- }
1066
- finally {
1067
- await handle?.close();
1068
- }
1069
- }
1070
- function crashAtStorageMigrationEdge(edge) {
1071
- if (process.env.NODE_ENV === "test" &&
1072
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_STORAGE_MIGRATION_EDGE === edge)
1073
- process.kill(process.pid, "SIGKILL");
1074
254
  }