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.
@@ -0,0 +1,269 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ /**
6
+ * Durable progress facts for the one play advance currently being settled.
7
+ *
8
+ * Every stage is immutable. `current.json` is only a small lookup projection;
9
+ * deleting it cannot change Authority and a repeated user operation keeps the
10
+ * same deterministic advance identity.
11
+ */
12
+ export class FileNativePlayAdvanceStore {
13
+ #worldsRoot;
14
+ constructor(dataRoot) {
15
+ this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
16
+ }
17
+ async begin(base) {
18
+ assertBase(base);
19
+ const current = await this.readCurrent(base.worldId, base.chainId);
20
+ if (current !== null && current.settled === null) {
21
+ if (!sameAdvanceBase(current.base, base))
22
+ throw new Error("Another play advance is still awaiting settlement");
23
+ return;
24
+ }
25
+ const basePath = this.#factPath(base, "base.json");
26
+ const existing = await readOptionalJson(basePath);
27
+ if (existing === null)
28
+ await publishImmutableJson(basePath, base);
29
+ else {
30
+ assertBase(existing);
31
+ if (!sameAdvanceBase(existing, base))
32
+ throw new Error("A play advance identity is bound to different data");
33
+ }
34
+ await publishJson(this.#currentPath(base.worldId, base.chainId), {
35
+ schemaVersion: 1,
36
+ worldId: base.worldId,
37
+ chainId: base.chainId,
38
+ advanceId: base.advanceId,
39
+ });
40
+ }
41
+ async recordProviderCompleted(base, response) {
42
+ const durableBase = await this.#assertCurrent(base);
43
+ await publishImmutableJson(this.#factPath(base, "provider-completed.json"), {
44
+ schemaVersion: 1,
45
+ stage: "provider_completed",
46
+ response: structuredClone(response),
47
+ recordedAt: durableBase.createdAt,
48
+ });
49
+ }
50
+ async recordSettlementPrepared(base, settlement) {
51
+ const durableBase = await this.#assertCurrent(base);
52
+ await publishImmutableJson(this.#factPath(base, "settlement-prepared.json"), {
53
+ schemaVersion: 1,
54
+ stage: "settlement_prepared",
55
+ settlement: structuredClone(settlement),
56
+ recordedAt: durableBase.createdAt,
57
+ });
58
+ }
59
+ async markSettled(base, head) {
60
+ const durableBase = await this.#assertCurrent(base);
61
+ await publishImmutableJson(this.#factPath(base, "settled.json"), {
62
+ schemaVersion: 1,
63
+ stage: "settled",
64
+ head,
65
+ recordedAt: durableBase.createdAt,
66
+ });
67
+ }
68
+ async readCurrent(worldId, chainId) {
69
+ assertIdentity(worldId, "World ID");
70
+ assertIdentity(chainId, "Call-chain ID");
71
+ const pointer = await readOptionalJson(this.#currentPath(worldId, chainId));
72
+ if (pointer === null)
73
+ return null;
74
+ assertPointer(pointer);
75
+ if (pointer.worldId !== worldId || pointer.chainId !== chainId)
76
+ throw new Error("Play advance pointer belongs to another context");
77
+ const recordRoot = this.#recordRoot(worldId, chainId, pointer.advanceId);
78
+ const [base, providerCompleted, settlementPrepared, settled] = await Promise.all([
79
+ readJson(join(recordRoot, "base.json")),
80
+ readOptionalJson(join(recordRoot, "provider-completed.json")),
81
+ readOptionalJson(join(recordRoot, "settlement-prepared.json")),
82
+ readOptionalJson(join(recordRoot, "settled.json")),
83
+ ]);
84
+ assertBase(base);
85
+ if (base.worldId !== worldId ||
86
+ base.chainId !== chainId ||
87
+ base.advanceId !== pointer.advanceId)
88
+ throw new Error("Play advance durable identity is inconsistent");
89
+ assertProviderCompleted(providerCompleted);
90
+ assertSettlementPrepared(settlementPrepared);
91
+ assertSettled(settled);
92
+ return { base, providerCompleted, settlementPrepared, settled };
93
+ }
94
+ async #assertCurrent(base) {
95
+ const current = await this.readCurrent(base.worldId, base.chainId);
96
+ if (current === null || !sameAdvanceBase(current.base, base))
97
+ throw new Error("Play advance is no longer current");
98
+ return current.base;
99
+ }
100
+ #currentPath(worldId, chainId) {
101
+ return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "current.json");
102
+ }
103
+ #recordRoot(worldId, chainId, advanceId) {
104
+ return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "records", digest(advanceId));
105
+ }
106
+ #factPath(base, name) {
107
+ return join(this.#recordRoot(base.worldId, base.chainId, base.advanceId), name);
108
+ }
109
+ }
110
+ function sameAdvanceBase(left, right) {
111
+ const { createdAt: leftCreatedAt, ...leftIdentity } = left;
112
+ const { createdAt: rightCreatedAt, ...rightIdentity } = right;
113
+ void leftCreatedAt;
114
+ void rightCreatedAt;
115
+ return isDeepStrictEqual(leftIdentity, rightIdentity);
116
+ }
117
+ function assertBase(value) {
118
+ if (!isRecord(value) ||
119
+ value.schemaVersion !== 1 ||
120
+ value.kind !== "play_advance" ||
121
+ (value.advanceKind !== "player" && value.advanceKind !== "response") ||
122
+ typeof value.worldId !== "string" ||
123
+ typeof value.chainId !== "string" ||
124
+ typeof value.advanceId !== "string" ||
125
+ typeof value.operationId !== "string" ||
126
+ typeof value.parentHead !== "string" ||
127
+ !Number.isSafeInteger(value.eventId) ||
128
+ Number(value.eventId) < 1 ||
129
+ typeof value.createdAt !== "number")
130
+ throw new Error("Play advance base has an invalid shape");
131
+ assertIdentity(value.worldId, "World ID");
132
+ assertIdentity(value.chainId, "Call-chain ID");
133
+ assertIdentity(value.advanceId, "Advance ID");
134
+ assertIdentity(value.operationId, "Operation ID");
135
+ if (value.advanceKind === "player") {
136
+ if (typeof value.exchangeId !== "string" ||
137
+ typeof value.playerText !== "string" ||
138
+ (value.context !== "fresh" && value.context !== "append") ||
139
+ !Number.isSafeInteger(value.transcriptStart) ||
140
+ Number(value.transcriptStart) < 0 ||
141
+ !Number.isSafeInteger(value.exchange) ||
142
+ Number(value.exchange) < 1 ||
143
+ !isRecord(value.nextRequest))
144
+ throw new Error("Player advance has an invalid shape");
145
+ }
146
+ else if (!Number.isSafeInteger(value.exchange) ||
147
+ Number(value.exchange) < 0 ||
148
+ !Number.isSafeInteger(value.attempt) ||
149
+ Number(value.attempt) < 1)
150
+ throw new Error("Response advance has an invalid shape");
151
+ }
152
+ function assertPointer(value) {
153
+ if (!isRecord(value) ||
154
+ value.schemaVersion !== 1 ||
155
+ typeof value.worldId !== "string" ||
156
+ typeof value.chainId !== "string" ||
157
+ typeof value.advanceId !== "string")
158
+ throw new Error("Play advance pointer has an invalid shape");
159
+ }
160
+ function assertProviderCompleted(value) {
161
+ if (value !== null &&
162
+ (value.schemaVersion !== 1 ||
163
+ value.stage !== "provider_completed" ||
164
+ !isRecord(value.response) ||
165
+ typeof value.recordedAt !== "number"))
166
+ throw new Error("Provider-completed play fact has an invalid shape");
167
+ }
168
+ function assertSettlementPrepared(value) {
169
+ if (value !== null &&
170
+ (value.schemaVersion !== 1 ||
171
+ value.stage !== "settlement_prepared" ||
172
+ !isRecord(value.settlement) ||
173
+ typeof value.recordedAt !== "number"))
174
+ throw new Error("Settlement-prepared play fact has an invalid shape");
175
+ }
176
+ function assertSettled(value) {
177
+ if (value !== null &&
178
+ (value.schemaVersion !== 1 ||
179
+ value.stage !== "settled" ||
180
+ typeof value.head !== "string" ||
181
+ typeof value.recordedAt !== "number"))
182
+ throw new Error("Settled play fact has an invalid shape");
183
+ }
184
+ function digest(value) {
185
+ return createHash("sha256").update(value).digest("hex");
186
+ }
187
+ function assertIdentity(value, label) {
188
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/u.test(value))
189
+ throw new TypeError(`${label} is invalid`);
190
+ }
191
+ function isRecord(value) {
192
+ return typeof value === "object" && value !== null && !Array.isArray(value);
193
+ }
194
+ async function readJson(path) {
195
+ return JSON.parse(await readFile(path, "utf8"));
196
+ }
197
+ async function readOptionalJson(path) {
198
+ try {
199
+ return await readJson(path);
200
+ }
201
+ catch (error) {
202
+ if (isNodeError(error) && error.code === "ENOENT")
203
+ return null;
204
+ throw error;
205
+ }
206
+ }
207
+ async function publishJson(path, value) {
208
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
209
+ const temporary = `${path}.${randomUUID()}.tmp`;
210
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
211
+ encoding: "utf8",
212
+ mode: 0o600,
213
+ });
214
+ await syncFile(temporary);
215
+ await rename(temporary, path);
216
+ await syncDirectory(dirname(path));
217
+ }
218
+ async function publishImmutableJson(path, value) {
219
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
220
+ const existing = await readOptionalJson(path);
221
+ if (existing !== null) {
222
+ if (!isDeepStrictEqual(existing, value))
223
+ throw new Error("Immutable play-advance fact already has different data");
224
+ return;
225
+ }
226
+ const temporary = `${path}.${randomUUID()}.tmp`;
227
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
228
+ encoding: "utf8",
229
+ mode: 0o600,
230
+ });
231
+ await syncFile(temporary);
232
+ try {
233
+ await link(temporary, path);
234
+ }
235
+ catch (error) {
236
+ if (!isNodeError(error) || error.code !== "EEXIST")
237
+ throw error;
238
+ const published = await readJson(path);
239
+ if (!isDeepStrictEqual(published, value))
240
+ throw new Error("Concurrent immutable play-advance fact conflicts", {
241
+ cause: error,
242
+ });
243
+ }
244
+ finally {
245
+ await rm(temporary, { force: true });
246
+ }
247
+ await syncDirectory(dirname(path));
248
+ }
249
+ async function syncFile(path) {
250
+ const handle = await open(path, "r");
251
+ try {
252
+ await handle.sync();
253
+ }
254
+ finally {
255
+ await handle.close();
256
+ }
257
+ }
258
+ async function syncDirectory(path) {
259
+ const handle = await open(path, "r");
260
+ try {
261
+ await handle.sync();
262
+ }
263
+ finally {
264
+ await handle.close();
265
+ }
266
+ }
267
+ function isNodeError(error) {
268
+ return error instanceof Error && "code" in error;
269
+ }