narraeon 0.7.0 → 0.7.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.
@@ -20,7 +20,7 @@ import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore
20
20
  import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
21
21
  import { FileNativeWorldStorageMigrator, UnsupportedLegacyStorageError, } from "./FileNativeWorldStorageMigrator.js";
22
22
  import { WorldAuthorityError, WorldLedgerAuthority, } from "./WorldLedgerAuthority.js";
23
- import { forgetLedger, rewindTo } from "./WorldTurnLedger.js";
23
+ import { forgetLedger, rewindTo, WorldTurnLedgerError, } from "./WorldTurnLedger.js";
24
24
  import { FileNativeWorldOperationCoordinator, WorldOperationBusyError, } from "./WorldOperationCoordinator.js";
25
25
  import { projectFileNativeHistorySurface, projectHistoryBackgrounds, historyBackgroundRecord, } from "./FileNativeHistoryProjection.js";
26
26
  const publicationFile = "publication.json";
@@ -105,7 +105,8 @@ export class FileNativeWorldStore {
105
105
  try {
106
106
  const root = join(this.#worldsRoot, worldId);
107
107
  const authority = new WorldLedgerAuthority(root);
108
- if (await authority.exists()) {
108
+ if ((await authority.exists()) &&
109
+ (await this.playTimeline.layoutCurrent(worldId))) {
109
110
  await authority.readHead();
110
111
  this.#currentStorage.add(worldId);
111
112
  return;
@@ -465,12 +466,14 @@ export class FileNativeWorldStore {
465
466
  assertIdentity(worldId, "World ID");
466
467
  await rm(join(this.#worldsRoot, worldId, "runtime", "archive-import.json"), { force: true });
467
468
  }
468
- async currentHeadOperationId(worldId) {
469
+ /** 端点名称与它的操作标识取自同一次读取,回退不会让两者分属不同端点。 */
470
+ async currentEndpoint(worldId) {
469
471
  assertIdentity(worldId, "World ID");
470
472
  await this.ensureCurrentStorage(worldId);
471
473
  const root = join(this.#worldsRoot, worldId);
472
474
  await readPublicationAt(root);
473
- return new WorldLedgerAuthority(root).currentOperationId();
475
+ const { head, operationId } = await new WorldLedgerAuthority(root).readHead();
476
+ return { head, operationId };
474
477
  }
475
478
  async saveControlDraft(worldId, files) {
476
479
  assertIdentity(worldId, "World ID");
@@ -793,6 +796,9 @@ export class FileNativeWorldStore {
793
796
  catch (error) {
794
797
  if (error instanceof FileNativeWorldCreationError)
795
798
  throw error;
799
+ // 端点在读取途中被回退丢弃不是损坏:调用方重新取得当前端点即可。
800
+ if (error instanceof WorldTurnLedgerError && error.code === "discarded")
801
+ throw new FileNativeWorldCreationError("endpoint_discarded", `Authority endpoint was rewound away${head === undefined ? "" : `: ${head}`}`, { cause: error });
796
802
  throw new FileNativeWorldCreationError("world_corrupt", `Cannot recover immutable Authority endpoint${head === undefined ? "" : `: ${head}`}`, error instanceof Error ? { cause: error } : undefined);
797
803
  }
798
804
  }
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { projectFileNativeHistorySurface, projectHistoryBackgrounds, } from "./FileNativeHistoryProjection.js";
5
- import { appendTurn, initializeLedger, listTurns, readFileAt, readHead as readLedgerHead, readTreeAt, readTurnDigest, resolveEndpoint, } from "./WorldTurnLedger.js";
5
+ import { appendTurn, initializeLedger, listTurns, readEndpoint, readFileAt, readHead as readLedgerHead, readTreeAt, readTurnDigest, resolveEndpoint, } from "./WorldTurnLedger.js";
6
6
  export class WorldAuthorityError extends Error {
7
7
  code;
8
8
  constructor(code, message) {
@@ -56,28 +56,31 @@ export class WorldLedgerAuthority {
56
56
  async initialize() {
57
57
  await initializeLedger(this.#root);
58
58
  }
59
- /** 当前端点:逻辑位置(回退后即被恢复的轮)及其元数据。 */
59
+ /** 当前端点:逻辑位置(回退后即被恢复的轮)及其元数据,三者取自同一份账本视图。 */
60
60
  async readHead() {
61
- const physical = await readLedgerHead(this.#root);
62
- const position = await resolveEndpoint(this.#root, physical);
61
+ const endpoint = await readEndpoint(this.#root);
62
+ const { sequence } = endpoint;
63
63
  return {
64
- head: headName(position),
65
- sequence: position,
66
- operationId: await this.#operationIdAt(position),
67
- commitDigest: await readTurnDigest(this.#root, position),
64
+ head: headName(sequence),
65
+ sequence,
66
+ operationId: await this.#operationIdAt(sequence, endpoint.record),
67
+ commitDigest: endpoint.digest,
68
68
  };
69
69
  }
70
70
  async currentOperationId() {
71
71
  return (await this.readHead()).operationId;
72
72
  }
73
- async #operationIdAt(position) {
73
+ async #operationIdAt(position, record) {
74
74
  if (position === 0) {
75
75
  const genesis = await readJsonOrNull(join(this.#root, genesisFile));
76
76
  return typeof genesis?.operationId === "string"
77
77
  ? genesis.operationId
78
78
  : null;
79
79
  }
80
- return (await this.#commitAtPosition(position)).operationId;
80
+ const commit = record?.payload;
81
+ if (commit?.sequence !== position)
82
+ throw new WorldAuthorityError("corrupt", `第 ${position} 轮缺少提交元数据`);
83
+ return commit.operationId;
81
84
  }
82
85
  async #positionOf(head) {
83
86
  if (head === undefined)
@@ -154,6 +154,10 @@ export class FileNativeWorldOperationCoordinator {
154
154
  isWorldHeld(worldId) {
155
155
  return this.#queue.isHeld(worldId);
156
156
  }
157
+ /** World operations for this world that are running or waiting for their turn. */
158
+ pendingWorldOperations(worldId) {
159
+ return this.#queue.pendingCount(worldId);
160
+ }
157
161
  #releaseClaim(handle) {
158
162
  const current = this.#claims.get(handle.worldId);
159
163
  if (current?.operationId === handle.operationId &&
@@ -11,6 +11,7 @@ import { resolve } from "node:path";
11
11
  */
12
12
  export class WorldQueue {
13
13
  #tails = new Map();
14
+ #pending = new Map();
14
15
  #held = new AsyncLocalStorage();
15
16
  run(worldId, action) {
16
17
  const held = this.#held.getStore();
@@ -19,8 +20,9 @@ export class WorldQueue {
19
20
  const previous = this.#tails.get(worldId) ?? Promise.resolve();
20
21
  const nextHeld = new Set(held ?? []);
21
22
  nextHeld.add(worldId);
23
+ this.#pending.set(worldId, this.pendingCount(worldId) + 1);
22
24
  const result = previous.then(() => this.#held.run(nextHeld, action));
23
- const tail = result.then(() => undefined, () => undefined);
25
+ const tail = result.then(() => this.#finish(worldId), () => this.#finish(worldId));
24
26
  this.#tails.set(worldId, tail);
25
27
  void tail.then(() => {
26
28
  if (this.#tails.get(worldId) === tail)
@@ -28,10 +30,21 @@ export class WorldQueue {
28
30
  });
29
31
  return result;
30
32
  }
33
+ /** Operations for worldId that are running or waiting for their turn. */
34
+ pendingCount(worldId) {
35
+ return this.#pending.get(worldId) ?? 0;
36
+ }
31
37
  /** True while the current async context is inside an operation for worldId. */
32
38
  isHeld(worldId) {
33
39
  return this.#held.getStore()?.has(worldId) ?? false;
34
40
  }
41
+ #finish(worldId) {
42
+ const remaining = this.pendingCount(worldId) - 1;
43
+ if (remaining > 0)
44
+ this.#pending.set(worldId, remaining);
45
+ else
46
+ this.#pending.delete(worldId);
47
+ }
35
48
  }
36
49
  const queuesByDataRoot = new Map();
37
50
  /** The queue shared by every store that changes a world under one data root. */
@@ -78,22 +78,32 @@ async function readAllRecords(root) {
78
78
  return [];
79
79
  throw error;
80
80
  }
81
- const records = [];
81
+ const turns = [];
82
82
  for (const name of names) {
83
83
  if (!name.endsWith(".json"))
84
84
  continue;
85
- const text = await readFile(join(root, turnsDirectory, name), "utf8");
86
- records.push(JSON.parse(text));
85
+ // 回退在发布端点之后才删除被丢弃的轮记录:读到一半消失的记录一定已被丢弃,跳过即可。
86
+ // 端点若在读取期间变化,loadView 会重新读取。
87
+ const text = await readTextOrNull(join(root, turnsDirectory, name));
88
+ if (text === null)
89
+ continue;
90
+ turns.push({
91
+ record: JSON.parse(text),
92
+ digest: createHash("sha256").update(text).digest("hex"),
93
+ });
87
94
  }
88
- return records.sort((left, right) => left.sequence - right.sequence);
95
+ return turns.sort((left, right) => left.record.sequence - right.record.sequence);
89
96
  }
90
97
  // 被某个 rewind 丢弃的轮是 (restores, rewind) 之间的位置;它们可能仍在磁盘上,
91
98
  // 直到清理完成,但读取时一律忽略。
92
- function viewOf(head, records) {
99
+ function viewOf(head, turns) {
93
100
  const byPosition = new Map();
94
- for (const record of records) {
95
- if (record.sequence <= head)
96
- byPosition.set(record.sequence, record);
101
+ const digests = new Map();
102
+ for (const { record, digest } of turns) {
103
+ if (record.sequence > head)
104
+ continue;
105
+ byPosition.set(record.sequence, record);
106
+ digests.set(record.sequence, digest);
97
107
  }
98
108
  const discarded = new Set();
99
109
  for (const record of byPosition.values()) {
@@ -103,21 +113,28 @@ function viewOf(head, records) {
103
113
  discarded.add(position);
104
114
  }
105
115
  }
106
- return { head, byPosition, discarded };
116
+ return { head, byPosition, digests, discarded };
107
117
  }
108
118
  // 进程内视图缓存,按端点失效。一个数据目录只被一个进程操作(ADR-0044),
109
119
  // 所以 head 相同即记录相同:任何改变可见记录的写入都会先推进 head。
110
120
  // 只有 beginTurn 与 recoverIncomplete 会在 head 之上写入或删除记录,它们不影响视图
111
121
  // (视图只包含序号不大于 head 的记录,且 recoverIncomplete 本身会在结束时失效缓存)。
112
122
  const viewCache = new Map();
123
+ // 一次读取只解析一个视图。回退先发布新端点、再删除被丢弃的轮记录:读完记录后端点仍未变化,
124
+ // 说明读到的记录与这个端点一致;端点变了就用新端点重新读取。
113
125
  async function loadView(root) {
114
- const head = await readHead(root);
115
- const cached = viewCache.get(resolve(root));
116
- if (cached?.head === head)
117
- return cached;
118
- const view = viewOf(head, await readAllRecords(root));
119
- viewCache.set(resolve(root), view);
120
- return view;
126
+ for (;;) {
127
+ const head = await readHead(root);
128
+ const cached = viewCache.get(resolve(root));
129
+ if (cached?.head === head)
130
+ return cached;
131
+ const turns = await readAllRecords(root);
132
+ if ((await readHead(root)) !== head)
133
+ continue;
134
+ const view = viewOf(head, turns);
135
+ viewCache.set(resolve(root), view);
136
+ return view;
137
+ }
121
138
  }
122
139
  // 世界目录被删除、归档导入或分叉发布到已存在路径之前调用:下一次读取从磁盘重建视图。
123
140
  export function forgetLedger(root) {
@@ -133,7 +150,7 @@ function resolvePosition(view, sequence) {
133
150
  if (position === 0)
134
151
  return 0;
135
152
  if (view.discarded.has(position)) {
136
- throw new WorldTurnLedgerError("unreachable", `端点 ${sequence} 已被回退丢弃`);
153
+ throw new WorldTurnLedgerError("discarded", `端点 ${sequence} 已被回退丢弃`);
137
154
  }
138
155
  const record = view.byPosition.get(position);
139
156
  if (!record) {
@@ -217,8 +234,9 @@ export async function appendTurn(root, input) {
217
234
  // 被丢弃的轮记录(读取时已忽略)在这里清理。返回是否回滚了未完成的轮。
218
235
  export async function recoverIncomplete(root) {
219
236
  const head = await readHead(root);
220
- const records = await readAllRecords(root);
221
- const incomplete = records
237
+ const turns = await readAllRecords(root);
238
+ const incomplete = turns
239
+ .map(({ record }) => record)
222
240
  .filter((record) => record.sequence > head)
223
241
  .sort((left, right) => right.sequence - left.sequence);
224
242
  for (const record of incomplete) {
@@ -234,7 +252,7 @@ export async function recoverIncomplete(root) {
234
252
  }
235
253
  await rm(join(root, turnFileName(record.sequence)), { force: true });
236
254
  }
237
- const view = viewOf(head, records);
255
+ const view = viewOf(head, turns);
238
256
  for (const position of view.discarded) {
239
257
  await rm(join(root, turnFileName(position)), { force: true });
240
258
  }
@@ -259,6 +277,16 @@ export async function listTurns(root) {
259
277
  export async function resolveEndpoint(root, sequence) {
260
278
  return resolvePosition(await loadView(root), sequence);
261
279
  }
280
+ // 当前端点的逻辑位置、它的轮记录与记录摘要,三者来自同一个视图。
281
+ export async function readEndpoint(root) {
282
+ const view = await loadView(root);
283
+ const sequence = resolvePosition(view, view.head);
284
+ return {
285
+ sequence,
286
+ record: view.byPosition.get(sequence) ?? null,
287
+ digest: view.digests.get(sequence) ?? null,
288
+ };
289
+ }
262
290
  // 第 sequence 轮记录文件的 sha256;0 轮没有记录,返回 null。
263
291
  export async function readTurnDigest(root, sequence) {
264
292
  if (sequence === 0)
@@ -160,7 +160,9 @@ export async function createServer(input) {
160
160
  }
161
161
  catch (error) {
162
162
  if (error instanceof V1ProtocolError) {
163
- return reply.status(400).send({
163
+ // 端点已被回退丢弃:客户端应重新读取当前世界,不是服务端错误。
164
+ const status = error.code === "endpoint_discarded" ? 409 : 400;
165
+ return reply.status(status).send({
164
166
  protocol: "narraeon.runtime/v1",
165
167
  error: {
166
168
  code: error.code,
@@ -1,11 +1,11 @@
1
- import { acquireDataRootLock } from "../runtime/config/dataRootLock.js";
1
+ import { prepareDataRoot } from "../runtime/config/dataRootCleanup.js";
2
2
  import { createRuntime } from "./createRuntime.js";
3
3
  import { createServer } from "./createServer.js";
4
4
  export const defaultWebPort = 4317;
5
5
  export const defaultWebHost = "127.0.0.1";
6
6
  export const containerWebHost = "0.0.0.0";
7
7
  export async function startWebServer(input) {
8
- const lock = await acquireDataRootLock(input.paths.dataRoot);
8
+ await prepareDataRoot(input.paths.dataRoot);
9
9
  let server;
10
10
  try {
11
11
  const runtime = await createRuntime(input.paths);
@@ -15,15 +15,12 @@ export async function startWebServer(input) {
15
15
  port: input.port,
16
16
  ...(input.logger === undefined ? {} : { logger: input.logger }),
17
17
  });
18
- server.addHook("onClose", () => lock.release());
19
18
  await server.listen({ host: input.host, port: input.port });
20
19
  return server;
21
20
  }
22
21
  catch (error) {
23
22
  if (server !== undefined)
24
23
  await server.close().catch(() => undefined);
25
- else
26
- await lock.release();
27
24
  throw error;
28
25
  }
29
26
  }