narraeon 0.1.1 → 0.2.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/dist/node/protocol/modelConnections.js +49 -0
- package/dist/node/protocol/modelUsage.js +39 -0
- package/dist/node/protocol/v1.js +29 -0
- package/dist/node/runtime/V1Runtime.js +59 -23
- package/dist/node/runtime/model/FileNativeModelAdapters.js +691 -275
- package/dist/node/runtime/model/ModelConnectionStore.js +123 -2
- package/dist/node/runtime/model/ModelHost.js +74 -9
- package/dist/node/runtime/model/ModelHostStream.js +61 -1
- package/dist/node/runtime/model/SettingAuthorStream.js +16 -113
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +269 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +1039 -0
- package/dist/node/runtime/play/PlayCallChain.js +609 -330
- package/dist/node/runtime/play/PlayDocumentTools.js +35 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +1 -4
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -30
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +55 -29
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +9 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +626 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +449 -191
- package/dist/web/assets/index-DHV_VWl3.js +1192 -0
- package/dist/web/assets/index-DjLCkP99.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CRGTZ9Ak.css +0 -1
- package/dist/web/assets/index-DDx1itzT.js +0 -1192
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, open, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
const currentAuthorityHeadFile = "play-authority-head.json";
|
|
6
|
+
const releasedAuthorityFile = "play-authority.json";
|
|
7
|
+
const releasedCallChainFile = "play-call-chain.json";
|
|
8
|
+
/**
|
|
9
|
+
* One-way upgrade seam for every cumulative storage shape shipped through
|
|
10
|
+
* v0.1.1. Callers serialize this module per world. It writes all new facts and
|
|
11
|
+
* projections first and publishes the small Authority head last, so that head
|
|
12
|
+
* is also the durable marker for the whole current layout.
|
|
13
|
+
*/
|
|
14
|
+
export class FileNativeWorldStorageMigrator {
|
|
15
|
+
#worldsRoot;
|
|
16
|
+
#operationsRoot;
|
|
17
|
+
#timeline;
|
|
18
|
+
constructor(dataRoot, timeline) {
|
|
19
|
+
const root = resolve(dataRoot);
|
|
20
|
+
this.#worldsRoot = join(root, "worlds-file-native");
|
|
21
|
+
this.#operationsRoot = join(root, "operations");
|
|
22
|
+
this.#timeline = timeline;
|
|
23
|
+
}
|
|
24
|
+
async migrate(worldId) {
|
|
25
|
+
assertIdentity(worldId, "World ID");
|
|
26
|
+
const worldRoot = join(this.#worldsRoot, worldId);
|
|
27
|
+
const runtimeRoot = join(worldRoot, "runtime");
|
|
28
|
+
const current = await readOptionalJson(join(runtimeRoot, currentAuthorityHeadFile));
|
|
29
|
+
if (current !== null) {
|
|
30
|
+
assertCurrentAuthorityHead(current);
|
|
31
|
+
return {
|
|
32
|
+
outcome: "already_current",
|
|
33
|
+
authorityCommits: current.sequence,
|
|
34
|
+
callChainContexts: 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const publication = await readJson(join(worldRoot, "publication.json"));
|
|
38
|
+
if (!isRecord(publication) ||
|
|
39
|
+
publication.schemaVersion !== 1 ||
|
|
40
|
+
publication.worldId !== worldId)
|
|
41
|
+
throw new Error("Released world publication has an invalid identity");
|
|
42
|
+
const genesis = await readJson(join(runtimeRoot, "genesis.json"));
|
|
43
|
+
const genesisHistory = releasedGenesisHistory(genesis, worldId);
|
|
44
|
+
const releasedAuthority = await readReleasedAuthority(worldRoot);
|
|
45
|
+
const currentCommits = convertReleasedCommits(releasedAuthority.commits);
|
|
46
|
+
await publishJson(join(runtimeRoot, "play-genesis-timeline.json"), {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
worldId,
|
|
49
|
+
history: genesisHistory,
|
|
50
|
+
});
|
|
51
|
+
const callChainSource = await readOptionalText(join(runtimeRoot, releasedCallChainFile));
|
|
52
|
+
let callChainContexts = 0;
|
|
53
|
+
if (callChainSource !== null) {
|
|
54
|
+
const value = parseJson(callChainSource, "Released play call chain");
|
|
55
|
+
callChainContexts =
|
|
56
|
+
isRecord(value) && Array.isArray(value.previousContexts)
|
|
57
|
+
? value.previousContexts.length + 1
|
|
58
|
+
: 1;
|
|
59
|
+
await this.#timeline.migrateReleasedRecord(worldId, value, sha256Hex(callChainSource));
|
|
60
|
+
}
|
|
61
|
+
crashAtStorageMigrationEdge("after_timeline");
|
|
62
|
+
for (const commit of currentCommits)
|
|
63
|
+
await publishImmutableJson(join(runtimeRoot, "play-commits", `${currentCommitDigest(commit)}.json`), commit);
|
|
64
|
+
const checkpoint = await migrateMaterializedCheckpoint(runtimeRoot, currentCommits);
|
|
65
|
+
await publishJson(join(runtimeRoot, "materialized-head.json"), checkpoint);
|
|
66
|
+
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
67
|
+
for (const commit of currentCommits) {
|
|
68
|
+
const outcome = {
|
|
69
|
+
outcome: checkpoint.sequence >= commit.sequence
|
|
70
|
+
? "committed"
|
|
71
|
+
: "committed_materialization_pending",
|
|
72
|
+
worldId,
|
|
73
|
+
parentHead: commit.parentHead,
|
|
74
|
+
head: commit.head,
|
|
75
|
+
commitDigest: currentCommitDigest(commit),
|
|
76
|
+
historyAppend: commit.historyAppend.map(({ role, exactText }) => ({
|
|
77
|
+
role,
|
|
78
|
+
exactText,
|
|
79
|
+
})),
|
|
80
|
+
nextAdditionalMaterials: structuredClone(commit.nextAdditionalMaterials),
|
|
81
|
+
mode: commit.mode,
|
|
82
|
+
};
|
|
83
|
+
await publishJson(operationOutcomePath(this.#operationsRoot, commit.operationId), outcome);
|
|
84
|
+
}
|
|
85
|
+
crashAtStorageMigrationEdge("after_current_facts");
|
|
86
|
+
await verifyCurrentFacts(runtimeRoot, currentCommits, checkpoint);
|
|
87
|
+
crashAtStorageMigrationEdge("before_current_head");
|
|
88
|
+
const tip = currentCommits.at(-1);
|
|
89
|
+
const head = tip === undefined
|
|
90
|
+
? {
|
|
91
|
+
schemaVersion: 2,
|
|
92
|
+
head: "genesis",
|
|
93
|
+
sequence: 0,
|
|
94
|
+
commitDigest: null,
|
|
95
|
+
operationId: null,
|
|
96
|
+
}
|
|
97
|
+
: {
|
|
98
|
+
schemaVersion: 2,
|
|
99
|
+
head: tip.head,
|
|
100
|
+
sequence: tip.sequence,
|
|
101
|
+
commitDigest: currentCommitDigest(tip),
|
|
102
|
+
operationId: tip.operationId,
|
|
103
|
+
};
|
|
104
|
+
await publishJson(join(runtimeRoot, currentAuthorityHeadFile), head);
|
|
105
|
+
crashAtStorageMigrationEdge("after_current_head");
|
|
106
|
+
return {
|
|
107
|
+
outcome: "migrated",
|
|
108
|
+
authorityCommits: currentCommits.length,
|
|
109
|
+
callChainContexts,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function readReleasedAuthority(worldRoot) {
|
|
114
|
+
const runtimeRoot = join(worldRoot, "runtime");
|
|
115
|
+
const published = await readOptionalJson(join(runtimeRoot, releasedAuthorityFile));
|
|
116
|
+
if (published !== null) {
|
|
117
|
+
assertReleasedAuthority(published);
|
|
118
|
+
for (const commit of published.commits) {
|
|
119
|
+
const digest = releasedCommitDigest(commit);
|
|
120
|
+
const immutable = await readJson(join(runtimeRoot, "play-commits", `${digest}.json`));
|
|
121
|
+
assertReleasedCommit(immutable);
|
|
122
|
+
if (!isDeepStrictEqual(immutable, commit))
|
|
123
|
+
throw new Error(`Released Authority endpoint does not match immutable commit ${commit.head}`);
|
|
124
|
+
}
|
|
125
|
+
return structuredClone(published);
|
|
126
|
+
}
|
|
127
|
+
const names = await readDirectoryFileNames(join(runtimeRoot, "play-commits"));
|
|
128
|
+
const remaining = [];
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const value = await readJson(join(runtimeRoot, "play-commits", name));
|
|
131
|
+
if (isRecord(value) && value.schemaVersion === 2)
|
|
132
|
+
continue;
|
|
133
|
+
assertReleasedCommit(value);
|
|
134
|
+
if (name !== `${releasedCommitDigest(value)}.json`)
|
|
135
|
+
throw new Error("Released immutable commit does not match its durable identity");
|
|
136
|
+
remaining.push(value);
|
|
137
|
+
}
|
|
138
|
+
if (remaining.length === 0)
|
|
139
|
+
return { schemaVersion: 1, head: "genesis", commits: [] };
|
|
140
|
+
const commits = [];
|
|
141
|
+
let parent = "genesis";
|
|
142
|
+
while (remaining.length > 0) {
|
|
143
|
+
const matches = remaining.filter((commit) => commit.parentHead === parent);
|
|
144
|
+
if (matches.length !== 1)
|
|
145
|
+
throw new Error("Released immutable commits cannot rebuild one unique Authority chain");
|
|
146
|
+
const next = matches[0];
|
|
147
|
+
commits.push(next);
|
|
148
|
+
remaining.splice(remaining.indexOf(next), 1);
|
|
149
|
+
parent = next.head;
|
|
150
|
+
}
|
|
151
|
+
const rebuilt = {
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
head: parent,
|
|
154
|
+
commits,
|
|
155
|
+
};
|
|
156
|
+
assertReleasedAuthority(rebuilt);
|
|
157
|
+
return rebuilt;
|
|
158
|
+
}
|
|
159
|
+
function convertReleasedCommits(commits) {
|
|
160
|
+
const converted = [];
|
|
161
|
+
let parentCommitDigest = null;
|
|
162
|
+
for (const [index, source] of commits.entries()) {
|
|
163
|
+
const commit = {
|
|
164
|
+
schemaVersion: 2,
|
|
165
|
+
sequence: index + 1,
|
|
166
|
+
operationId: source.operationId,
|
|
167
|
+
parentHead: source.parentHead,
|
|
168
|
+
parentCommitDigest,
|
|
169
|
+
head: source.head,
|
|
170
|
+
mode: source.mode,
|
|
171
|
+
historyAppend: structuredClone(source.historyAppend),
|
|
172
|
+
stateChanges: structuredClone(source.stateChanges),
|
|
173
|
+
nextAdditionalMaterials: structuredClone(source.nextAdditionalMaterials),
|
|
174
|
+
...(source.correctionTargets === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { correctionTargets: structuredClone(source.correctionTargets) }),
|
|
177
|
+
...(source.corrects === undefined ? {} : { corrects: source.corrects }),
|
|
178
|
+
...(source.timelineRevision === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: { timelineRevision: structuredClone(source.timelineRevision) }),
|
|
181
|
+
};
|
|
182
|
+
converted.push(commit);
|
|
183
|
+
parentCommitDigest = currentCommitDigest(commit);
|
|
184
|
+
}
|
|
185
|
+
return converted;
|
|
186
|
+
}
|
|
187
|
+
async function migrateMaterializedCheckpoint(runtimeRoot, commits) {
|
|
188
|
+
const value = await readOptionalJson(join(runtimeRoot, "materialized-head.json"));
|
|
189
|
+
if (value === null)
|
|
190
|
+
return {
|
|
191
|
+
schemaVersion: 2,
|
|
192
|
+
head: "genesis",
|
|
193
|
+
sequence: 0,
|
|
194
|
+
commitDigest: null,
|
|
195
|
+
};
|
|
196
|
+
if (isCurrentMaterializedCheckpoint(value)) {
|
|
197
|
+
const expected = value.sequence === 0 ? undefined : commits[value.sequence - 1];
|
|
198
|
+
if ((value.sequence === 0 &&
|
|
199
|
+
(value.head !== "genesis" || value.commitDigest !== null)) ||
|
|
200
|
+
(value.sequence > 0 &&
|
|
201
|
+
(expected?.head !== value.head ||
|
|
202
|
+
currentCommitDigest(expected) !== value.commitDigest)))
|
|
203
|
+
throw new Error("Partially migrated materialized checkpoint is not in released Authority");
|
|
204
|
+
return structuredClone(value);
|
|
205
|
+
}
|
|
206
|
+
if (!isRecord(value) ||
|
|
207
|
+
!hasExactKeys(value, ["head"]) ||
|
|
208
|
+
typeof value.head !== "string")
|
|
209
|
+
throw new Error("Released materialized checkpoint has an invalid shape");
|
|
210
|
+
if (value.head === "genesis")
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: 2,
|
|
213
|
+
head: "genesis",
|
|
214
|
+
sequence: 0,
|
|
215
|
+
commitDigest: null,
|
|
216
|
+
};
|
|
217
|
+
const commit = commits.find(({ head }) => head === value.head);
|
|
218
|
+
if (commit === undefined)
|
|
219
|
+
throw new Error("Released materialized checkpoint is not in accepted Authority");
|
|
220
|
+
return {
|
|
221
|
+
schemaVersion: 2,
|
|
222
|
+
head: commit.head,
|
|
223
|
+
sequence: commit.sequence,
|
|
224
|
+
commitDigest: currentCommitDigest(commit),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function verifyCurrentFacts(runtimeRoot, commits, checkpoint) {
|
|
228
|
+
let parentDigest = null;
|
|
229
|
+
for (const commit of commits) {
|
|
230
|
+
if (commit.parentCommitDigest !== parentDigest)
|
|
231
|
+
throw new Error("Migrated Authority parent digest is inconsistent");
|
|
232
|
+
const digest = currentCommitDigest(commit);
|
|
233
|
+
const durable = await readJson(join(runtimeRoot, "play-commits", `${digest}.json`));
|
|
234
|
+
if (!isDeepStrictEqual(durable, commit))
|
|
235
|
+
throw new Error("Migrated Authority commit verification failed");
|
|
236
|
+
parentDigest = digest;
|
|
237
|
+
}
|
|
238
|
+
const durableCheckpoint = await readJson(join(runtimeRoot, "materialized-head.json"));
|
|
239
|
+
if (!isDeepStrictEqual(durableCheckpoint, checkpoint))
|
|
240
|
+
throw new Error("Migrated materialized checkpoint verification failed");
|
|
241
|
+
}
|
|
242
|
+
function releasedGenesisHistory(value, worldId) {
|
|
243
|
+
if (!isRecord(value) ||
|
|
244
|
+
value.schemaVersion !== 1 ||
|
|
245
|
+
value.type !== "file_native_genesis" ||
|
|
246
|
+
value.worldId !== worldId ||
|
|
247
|
+
!Array.isArray(value.history) ||
|
|
248
|
+
!value.history.every(isReleasedHistoryMessage))
|
|
249
|
+
throw new Error("Released world genesis has an invalid identity");
|
|
250
|
+
return structuredClone(value.history);
|
|
251
|
+
}
|
|
252
|
+
function assertReleasedAuthority(value) {
|
|
253
|
+
if (!isRecord(value) ||
|
|
254
|
+
!hasExactKeys(value, ["schemaVersion", "head", "commits"]) ||
|
|
255
|
+
value.schemaVersion !== 1 ||
|
|
256
|
+
typeof value.head !== "string" ||
|
|
257
|
+
!Array.isArray(value.commits))
|
|
258
|
+
throw new Error("Released Play Authority has an invalid shape");
|
|
259
|
+
const operationIds = new Set();
|
|
260
|
+
const heads = new Set();
|
|
261
|
+
const commitsByHead = new Map();
|
|
262
|
+
let parent = "genesis";
|
|
263
|
+
for (const [index, commit] of value.commits.entries()) {
|
|
264
|
+
assertReleasedCommit(commit);
|
|
265
|
+
const revision = commit.timelineRevision;
|
|
266
|
+
const replaced = revision === undefined
|
|
267
|
+
? undefined
|
|
268
|
+
: commitsByHead.get(revision.replacesHead);
|
|
269
|
+
const replacedLogicalParent = replaced?.mode === "timeline_revision"
|
|
270
|
+
? replaced.timelineRevision.restoresHead
|
|
271
|
+
: replaced?.parentHead;
|
|
272
|
+
if (commit.parentHead !== parent ||
|
|
273
|
+
commit.head !== `commit:${index + 1}` ||
|
|
274
|
+
operationIds.has(commit.operationId) ||
|
|
275
|
+
heads.has(commit.head) ||
|
|
276
|
+
(commit.mode === "timeline_revision" &&
|
|
277
|
+
((revision.restoresHead !== "genesis" &&
|
|
278
|
+
!heads.has(revision.restoresHead)) ||
|
|
279
|
+
replaced === undefined ||
|
|
280
|
+
!replaced.historyAppend.some(({ role }) => role === "player") ||
|
|
281
|
+
replacedLogicalParent !== revision.restoresHead)))
|
|
282
|
+
throw new Error("Released Play Authority commit chain is invalid");
|
|
283
|
+
operationIds.add(commit.operationId);
|
|
284
|
+
heads.add(commit.head);
|
|
285
|
+
commitsByHead.set(commit.head, commit);
|
|
286
|
+
parent = commit.head;
|
|
287
|
+
}
|
|
288
|
+
if (value.head !== parent)
|
|
289
|
+
throw new Error("Released Play Authority head is invalid");
|
|
290
|
+
}
|
|
291
|
+
function assertReleasedCommit(value) {
|
|
292
|
+
if (!isRecord(value))
|
|
293
|
+
throw new Error("Released play commit has an invalid shape");
|
|
294
|
+
const correction = value.mode === "correction";
|
|
295
|
+
const timelineRevision = value.mode === "timeline_revision";
|
|
296
|
+
if (!hasExactKeys(value, [
|
|
297
|
+
"schemaVersion",
|
|
298
|
+
"operationId",
|
|
299
|
+
"parentHead",
|
|
300
|
+
"head",
|
|
301
|
+
"mode",
|
|
302
|
+
"historyAppend",
|
|
303
|
+
"stateChanges",
|
|
304
|
+
"nextAdditionalMaterials",
|
|
305
|
+
...(correction ? ["correctionTargets", "corrects"] : []),
|
|
306
|
+
...(timelineRevision ? ["timelineRevision"] : []),
|
|
307
|
+
]) ||
|
|
308
|
+
value.schemaVersion !== 1 ||
|
|
309
|
+
typeof value.operationId !== "string" ||
|
|
310
|
+
value.operationId.trim() === "" ||
|
|
311
|
+
typeof value.parentHead !== "string" ||
|
|
312
|
+
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
|
|
313
|
+
typeof value.head !== "string" ||
|
|
314
|
+
!/^commit:[1-9][0-9]*$/u.test(value.head) ||
|
|
315
|
+
(value.mode !== "play" &&
|
|
316
|
+
value.mode !== "correction" &&
|
|
317
|
+
value.mode !== "timeline_revision") ||
|
|
318
|
+
!Array.isArray(value.historyAppend) ||
|
|
319
|
+
(correction
|
|
320
|
+
? value.historyAppend.length !== 0
|
|
321
|
+
: timelineRevision
|
|
322
|
+
? value.historyAppend.length !== 1 ||
|
|
323
|
+
!isRecord(value.historyAppend[0]) ||
|
|
324
|
+
value.historyAppend[0].role !== "player"
|
|
325
|
+
: value.historyAppend.length === 0 &&
|
|
326
|
+
(!Array.isArray(value.stateChanges) ||
|
|
327
|
+
value.stateChanges.length === 0)) ||
|
|
328
|
+
!value.historyAppend.every(isReleasedHistoryMessage) ||
|
|
329
|
+
!Array.isArray(value.stateChanges) ||
|
|
330
|
+
!value.stateChanges.every(isReleasedStateChange) ||
|
|
331
|
+
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
332
|
+
!value.nextAdditionalMaterials.every(isMaterialSelection))
|
|
333
|
+
throw new Error("Released play commit has an invalid shape");
|
|
334
|
+
if (correction &&
|
|
335
|
+
(!Array.isArray(value.correctionTargets) ||
|
|
336
|
+
!value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
|
|
337
|
+
typeof value.corrects !== "string" ||
|
|
338
|
+
value.corrects !== value.parentHead))
|
|
339
|
+
throw new Error("Released correction commit has an invalid shape");
|
|
340
|
+
if (timelineRevision &&
|
|
341
|
+
(value.stateChanges.length !== 0 ||
|
|
342
|
+
!isReleasedTimelineRevision(value.timelineRevision)))
|
|
343
|
+
throw new Error("Released timeline-revision commit has an invalid shape");
|
|
344
|
+
}
|
|
345
|
+
function isReleasedTimelineRevision(value) {
|
|
346
|
+
if (!isRecord(value) ||
|
|
347
|
+
!hasExactKeys(value, [
|
|
348
|
+
"restoresHead",
|
|
349
|
+
"replacesHead",
|
|
350
|
+
"requestFingerprint",
|
|
351
|
+
"replacementState",
|
|
352
|
+
"replacementHistory",
|
|
353
|
+
]) ||
|
|
354
|
+
typeof value.restoresHead !== "string" ||
|
|
355
|
+
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.restoresHead) ||
|
|
356
|
+
typeof value.replacesHead !== "string" ||
|
|
357
|
+
!/^commit:[1-9][0-9]*$/u.test(value.replacesHead) ||
|
|
358
|
+
typeof value.requestFingerprint !== "string" ||
|
|
359
|
+
!/^sha256:[a-f0-9]{64}$/u.test(value.requestFingerprint) ||
|
|
360
|
+
!Array.isArray(value.replacementState) ||
|
|
361
|
+
!Array.isArray(value.replacementHistory) ||
|
|
362
|
+
!value.replacementHistory.every(isReleasedHistoryMessage))
|
|
363
|
+
return false;
|
|
364
|
+
return value.replacementState.every((file) => isRecord(file) &&
|
|
365
|
+
hasExactKeys(file, ["path", "sha256", "canonicalBytes"]) &&
|
|
366
|
+
typeof file.path === "string" &&
|
|
367
|
+
validRelativePath(file.path) &&
|
|
368
|
+
typeof file.sha256 === "string" &&
|
|
369
|
+
typeof file.canonicalBytes === "string" &&
|
|
370
|
+
`sha256:${sha256Hex(file.canonicalBytes)}` === file.sha256);
|
|
371
|
+
}
|
|
372
|
+
function isReleasedHistoryMessage(value) {
|
|
373
|
+
return (isRecord(value) &&
|
|
374
|
+
hasExactKeys(value, ["messageId", "role", "exactText"]) &&
|
|
375
|
+
typeof value.messageId === "string" &&
|
|
376
|
+
value.messageId.trim() !== "" &&
|
|
377
|
+
(value.role === "player" || value.role === "narrator") &&
|
|
378
|
+
typeof value.exactText === "string");
|
|
379
|
+
}
|
|
380
|
+
function isReleasedStateChange(value) {
|
|
381
|
+
return (isRecord(value) &&
|
|
382
|
+
hasExactKeys(value, [
|
|
383
|
+
"kind",
|
|
384
|
+
"documentId",
|
|
385
|
+
"stableShortRef",
|
|
386
|
+
"relativePath",
|
|
387
|
+
"codec",
|
|
388
|
+
"expectedPreviousHash",
|
|
389
|
+
"nextHash",
|
|
390
|
+
"canonicalNextBytes",
|
|
391
|
+
]) &&
|
|
392
|
+
(value.kind === "create" || value.kind === "replace") &&
|
|
393
|
+
typeof value.documentId === "string" &&
|
|
394
|
+
value.documentId.trim() !== "" &&
|
|
395
|
+
typeof value.stableShortRef === "string" &&
|
|
396
|
+
value.stableShortRef.trim() !== "" &&
|
|
397
|
+
typeof value.relativePath === "string" &&
|
|
398
|
+
validRelativePath(value.relativePath) &&
|
|
399
|
+
(value.codec === "yaml" || value.codec === "markdown") &&
|
|
400
|
+
(value.expectedPreviousHash === null ||
|
|
401
|
+
(typeof value.expectedPreviousHash === "string" &&
|
|
402
|
+
/^sha256:[a-f0-9]{64}$/u.test(value.expectedPreviousHash))) &&
|
|
403
|
+
typeof value.nextHash === "string" &&
|
|
404
|
+
/^sha256:[a-f0-9]{64}$/u.test(value.nextHash) &&
|
|
405
|
+
typeof value.canonicalNextBytes === "string" &&
|
|
406
|
+
`sha256:${sha256Hex(value.canonicalNextBytes)}` === value.nextHash);
|
|
407
|
+
}
|
|
408
|
+
function isMaterialSelection(value) {
|
|
409
|
+
if (!isRecord(value))
|
|
410
|
+
return false;
|
|
411
|
+
if (value.kind === "document")
|
|
412
|
+
return (hasExactKeys(value, ["kind", "document"]) &&
|
|
413
|
+
typeof value.document === "string");
|
|
414
|
+
if (value.kind === "node")
|
|
415
|
+
return (hasExactKeys(value, ["kind", "document", "locator"]) &&
|
|
416
|
+
typeof value.document === "string" &&
|
|
417
|
+
isMaterialLocator(value.locator));
|
|
418
|
+
if (value.kind === "history_message")
|
|
419
|
+
return (hasExactKeys(value, ["kind", "message"]) &&
|
|
420
|
+
typeof value.message === "string");
|
|
421
|
+
return (value.kind === "history_commit" &&
|
|
422
|
+
hasExactKeys(value, ["kind", "commit"]) &&
|
|
423
|
+
typeof value.commit === "string");
|
|
424
|
+
}
|
|
425
|
+
function isMaterialLocator(value) {
|
|
426
|
+
return (isRecord(value) &&
|
|
427
|
+
hasRequiredAndOptionalKeys(value, [], ["yaml", "markdown"]) &&
|
|
428
|
+
(value.yaml === undefined ||
|
|
429
|
+
(Array.isArray(value.yaml) &&
|
|
430
|
+
value.yaml.every((segment) => typeof segment === "string" || Number.isSafeInteger(segment)))) &&
|
|
431
|
+
(value.markdown === undefined ||
|
|
432
|
+
(Array.isArray(value.markdown) &&
|
|
433
|
+
value.markdown.every((segment) => typeof segment === "string"))));
|
|
434
|
+
}
|
|
435
|
+
function assertCurrentAuthorityHead(value) {
|
|
436
|
+
if (!isRecord(value) ||
|
|
437
|
+
!hasExactKeys(value, [
|
|
438
|
+
"schemaVersion",
|
|
439
|
+
"head",
|
|
440
|
+
"sequence",
|
|
441
|
+
"commitDigest",
|
|
442
|
+
"operationId",
|
|
443
|
+
]) ||
|
|
444
|
+
value.schemaVersion !== 2 ||
|
|
445
|
+
typeof value.head !== "string" ||
|
|
446
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
447
|
+
Number(value.sequence) < 0 ||
|
|
448
|
+
(value.commitDigest !== null &&
|
|
449
|
+
(typeof value.commitDigest !== "string" ||
|
|
450
|
+
!/^[a-f0-9]{64}$/u.test(value.commitDigest))) ||
|
|
451
|
+
(value.operationId !== null && typeof value.operationId !== "string") ||
|
|
452
|
+
(Number(value.sequence) === 0
|
|
453
|
+
? value.head !== "genesis" ||
|
|
454
|
+
value.commitDigest !== null ||
|
|
455
|
+
value.operationId !== null
|
|
456
|
+
: value.head !== `commit:${String(value.sequence)}` ||
|
|
457
|
+
value.commitDigest === null ||
|
|
458
|
+
value.operationId === null))
|
|
459
|
+
throw new Error("Current Authority head has an invalid shape");
|
|
460
|
+
}
|
|
461
|
+
function isCurrentMaterializedCheckpoint(value) {
|
|
462
|
+
return (isRecord(value) &&
|
|
463
|
+
hasExactKeys(value, [
|
|
464
|
+
"schemaVersion",
|
|
465
|
+
"head",
|
|
466
|
+
"sequence",
|
|
467
|
+
"commitDigest",
|
|
468
|
+
]) &&
|
|
469
|
+
value.schemaVersion === 2 &&
|
|
470
|
+
typeof value.head === "string" &&
|
|
471
|
+
Number.isSafeInteger(value.sequence) &&
|
|
472
|
+
Number(value.sequence) >= 0 &&
|
|
473
|
+
(value.commitDigest === null ||
|
|
474
|
+
(typeof value.commitDigest === "string" &&
|
|
475
|
+
/^[a-f0-9]{64}$/u.test(value.commitDigest))));
|
|
476
|
+
}
|
|
477
|
+
function currentCommitDigest(commit) {
|
|
478
|
+
return sha256Hex(JSON.stringify(commit));
|
|
479
|
+
}
|
|
480
|
+
function releasedCommitDigest(commit) {
|
|
481
|
+
return sha256Hex(JSON.stringify(commit));
|
|
482
|
+
}
|
|
483
|
+
function operationOutcomePath(root, operationId) {
|
|
484
|
+
return join(root, `outcome-${sha256Hex(operationId)}.json`);
|
|
485
|
+
}
|
|
486
|
+
function validRelativePath(path) {
|
|
487
|
+
return (path.length > 0 &&
|
|
488
|
+
!path.startsWith("/") &&
|
|
489
|
+
!path.includes("\\") &&
|
|
490
|
+
!path
|
|
491
|
+
.split("/")
|
|
492
|
+
.some((part) => part === "" || part === "." || part === ".."));
|
|
493
|
+
}
|
|
494
|
+
function assertIdentity(value, label) {
|
|
495
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(value))
|
|
496
|
+
throw new TypeError(`${label} is invalid`);
|
|
497
|
+
}
|
|
498
|
+
function isRecord(value) {
|
|
499
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
500
|
+
}
|
|
501
|
+
function hasExactKeys(value, keys) {
|
|
502
|
+
return (Object.keys(value).length === keys.length &&
|
|
503
|
+
keys.every((key) => Object.hasOwn(value, key)));
|
|
504
|
+
}
|
|
505
|
+
function hasRequiredAndOptionalKeys(value, required, optional) {
|
|
506
|
+
const allowed = new Set([...required, ...optional]);
|
|
507
|
+
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
508
|
+
Object.keys(value).every((key) => allowed.has(key)));
|
|
509
|
+
}
|
|
510
|
+
function parseJson(source, label) {
|
|
511
|
+
try {
|
|
512
|
+
return JSON.parse(source);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
throw new Error(`${label} is not valid JSON`, { cause: error });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function sha256Hex(value) {
|
|
519
|
+
return createHash("sha256").update(value).digest("hex");
|
|
520
|
+
}
|
|
521
|
+
function isNodeError(value) {
|
|
522
|
+
return value instanceof Error && "code" in value;
|
|
523
|
+
}
|
|
524
|
+
async function readJson(path) {
|
|
525
|
+
return parseJson(await readFile(path, "utf8"), path);
|
|
526
|
+
}
|
|
527
|
+
async function readOptionalJson(path) {
|
|
528
|
+
try {
|
|
529
|
+
return await readJson(path);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
533
|
+
return null;
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function readOptionalText(path) {
|
|
538
|
+
try {
|
|
539
|
+
return await readFile(path, "utf8");
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
543
|
+
return null;
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function readDirectoryFileNames(root) {
|
|
548
|
+
try {
|
|
549
|
+
return (await readdir(root, { withFileTypes: true }))
|
|
550
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
551
|
+
.map(({ name }) => name)
|
|
552
|
+
.sort();
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
556
|
+
return [];
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async function publishJson(path, value) {
|
|
561
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
562
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
563
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
564
|
+
encoding: "utf8",
|
|
565
|
+
mode: 0o600,
|
|
566
|
+
});
|
|
567
|
+
await syncFile(temporary);
|
|
568
|
+
await rename(temporary, path);
|
|
569
|
+
await syncDirectory(dirname(path));
|
|
570
|
+
}
|
|
571
|
+
async function publishImmutableJson(path, value) {
|
|
572
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
573
|
+
const contents = `${JSON.stringify(value, null, 2)}\n`;
|
|
574
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
575
|
+
try {
|
|
576
|
+
await writeFile(temporary, contents, {
|
|
577
|
+
encoding: "utf8",
|
|
578
|
+
flag: "wx",
|
|
579
|
+
mode: 0o600,
|
|
580
|
+
});
|
|
581
|
+
await syncFile(temporary);
|
|
582
|
+
try {
|
|
583
|
+
await link(temporary, path);
|
|
584
|
+
await syncDirectory(dirname(path));
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
588
|
+
throw error;
|
|
589
|
+
if ((await readFile(path, "utf8")) !== contents)
|
|
590
|
+
throw new Error("Migrated immutable file conflicts with existing data", {
|
|
591
|
+
cause: error,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
await rm(temporary, { force: true });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
async function syncFile(path) {
|
|
600
|
+
const handle = await open(path, "r");
|
|
601
|
+
try {
|
|
602
|
+
await handle.sync();
|
|
603
|
+
}
|
|
604
|
+
finally {
|
|
605
|
+
await handle.close();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async function syncDirectory(path) {
|
|
609
|
+
let handle;
|
|
610
|
+
try {
|
|
611
|
+
handle = await open(path, "r");
|
|
612
|
+
await handle.sync();
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
if (!isNodeError(error) || error.code !== "EINVAL")
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
await handle?.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function crashAtStorageMigrationEdge(edge) {
|
|
623
|
+
if (process.env.NODE_ENV === "test" &&
|
|
624
|
+
process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_STORAGE_MIGRATION_EDGE === edge)
|
|
625
|
+
process.kill(process.pid, "SIGKILL");
|
|
626
|
+
}
|