@morlay/session-rdb 0.0.15 → 0.0.16-alpha.1

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 (65) hide show
  1. package/README.md +11 -13
  2. package/dist/artifact.d.mts +30 -16
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{import-DFed8DBt.mjs → import-mZZgDBLd.mjs} +73 -83
  5. package/dist/import.d.mts +2 -3
  6. package/dist/import.mjs +2 -2
  7. package/dist/index-GdKkSSb-.d.mts +239 -0
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +763 -168
  10. package/dist/log-DBFUBPhv.mjs +394 -0
  11. package/dist/{schema-CFXZURX7.d.mts → schema-COK7-wRV.d.mts} +3 -15
  12. package/dist/sqlite-CnWwGToZ.mjs +698 -0
  13. package/dist/storage.d.mts +2 -7
  14. package/dist/storage.mjs +2 -3
  15. package/dist/testing.d.mts +30 -1
  16. package/dist/testing.mjs +676 -1991
  17. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +58 -0
  18. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +686 -0
  19. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +1 -0
  20. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +673 -0
  21. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +53 -0
  22. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +492 -0
  23. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +6 -0
  24. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +513 -0
  25. package/package.json +17 -12
  26. package/src/adapters/index.ts +10 -2
  27. package/src/adapters/to-postgres.ts +19 -15
  28. package/src/adapters/to-sqlite.ts +20 -14
  29. package/src/{entities → adapters}/types.ts +7 -8
  30. package/src/backend.ts +0 -7
  31. package/src/branch.ts +29 -110
  32. package/src/drizzle/postgres-v2.ts +11 -0
  33. package/src/drizzle/postgres-v3.ts +11 -0
  34. package/src/drizzle/sqlite-v2.ts +10 -0
  35. package/src/drizzle/sqlite-v3.ts +11 -0
  36. package/src/entities/index.ts +2 -30
  37. package/src/entities/v2/index.ts +20 -0
  38. package/src/entities/v2/session-events.ts +11 -0
  39. package/src/entities/v3/events.ts +27 -0
  40. package/src/entities/v3/index.ts +27 -0
  41. package/src/entities/v3/persistence-state.ts +10 -0
  42. package/src/entities/v3/schema-meta.ts +9 -0
  43. package/src/entities/v3/session-events.ts +26 -0
  44. package/src/entities/v3/sessions.ts +20 -0
  45. package/src/import.ts +65 -57
  46. package/src/index.ts +929 -227
  47. package/src/invariant.ts +0 -2
  48. package/src/legacy.ts +110 -0
  49. package/src/log.ts +143 -91
  50. package/src/postgres.ts +75 -93
  51. package/src/schema.ts +3 -14
  52. package/src/sqlite.ts +59 -46
  53. package/src/testing/contract.ts +460 -375
  54. package/src/testing/coordinator-contract.ts +108 -1374
  55. package/src/testing.ts +1 -6
  56. package/dist/index-uUvn6gYp.d.mts +0 -111
  57. package/dist/schema-vwzwEXNE.mjs +0 -878
  58. package/dist/sqlite-CG_Qcx5C.mjs +0 -356
  59. package/src/adapters/ddl.ts +0 -77
  60. package/src/entities/events.ts +0 -27
  61. package/src/entities/persistence-state.ts +0 -10
  62. package/src/entities/schema-meta.ts +0 -9
  63. package/src/entities/session-events.ts +0 -29
  64. package/src/entities/sessions.ts +0 -20
  65. package/src/migrate.ts +0 -137
@@ -1,27 +1,46 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import {
3
- SESSION_FORMAT_VERSION,
4
- Session,
5
- SessionId,
6
- SessionLogOffset,
7
- SessionSeq,
8
- TOOL_NOT_STARTED,
9
- TOOL_OUTCOME_UNKNOWN,
10
- } from "@deepseek-ai/dsh-session";
2
+ import { SESSION_FORMAT_VERSION, Session, SessionId, SessionSeq } from "@deepseek-ai/dsh-session";
11
3
  import type {
12
4
  SessionEvent,
13
5
  SessionHeader,
14
6
  SurfaceEventType,
15
7
  SurfaceIntent,
16
8
  } from "@deepseek-ai/dsh-session";
17
- import { ToolCallId, MessageId, createMessage, freezeMessage } from "@deepseek-ai/dsh-llm";
18
- import type { SessionPersistence } from "@deepseek-ai/dsh-session-persistence";
9
+ import { MessageId, freezeMessage } from "@deepseek-ai/dsh-llm";
10
+ import {
11
+ SessionAlreadyExistsError,
12
+ SessionAlreadyOwnedError,
13
+ SessionFormatUnsupportedError,
14
+ SessionHandleClosedError,
15
+ SessionPersistenceNotFoundError,
16
+ SessionReadOnlyError,
17
+ type SessionHandle,
18
+ type SessionPersistence,
19
+ } from "@deepseek-ai/dsh-session-persistence";
19
20
 
20
- export interface ContractBackend {
21
+ /** One backend service instance under test plus its teardown. */
22
+ interface ContractBackendInstance {
21
23
  persistence: SessionPersistence;
22
24
  dispose: () => Promise<void>;
23
25
  }
24
26
 
27
+ /** A backend under test: the primary instance plus optional storage-level capabilities. */
28
+ export interface ContractBackend extends ContractBackendInstance {
29
+ /**
30
+ * Open a FRESH backend instance over the SAME storage, as another process
31
+ * would after this one exits. Enables the cross-instance visibility and
32
+ * reopen-continuation tests; a backend without shared storage omits it and
33
+ * those tests self-skip.
34
+ */
35
+ reopen?: () => Promise<ContractBackendInstance>;
36
+ /**
37
+ * Inject a torn physical tail after the committed log of one stored session,
38
+ * simulating a crash mid-write. Enables the torn-tail tests.
39
+ */
40
+ corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>;
41
+ }
42
+
43
+ /** Build a minimal {@link SessionHeader} for a session id. */
25
44
  export function meta(id: string, cwd?: string): SessionHeader {
26
45
  return {
27
46
  version: SESSION_FORMAT_VERSION,
@@ -32,6 +51,7 @@ export function meta(id: string, cwd?: string): SessionHeader {
32
51
  };
33
52
  }
34
53
 
54
+ /** A well-formed one-turn event log (contiguous seqs from 0). */
35
55
  export function oneTurnLog(): SessionEvent[] {
36
56
  return [
37
57
  { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
@@ -65,6 +85,16 @@ export function oneTurnLog(): SessionEvent[] {
65
85
  model: "mock",
66
86
  },
67
87
  }),
88
+ stream: [
89
+ { type: "chunk", time: 3, chunk: { type: "block-start", index: 0, blockType: "text" } },
90
+ { type: "text-chunks", time0: 3, index: 0, dt: [], texts: ["hello"] },
91
+ {
92
+ type: "chunk",
93
+ time: 4,
94
+ chunk: { type: "block-end", index: 0, block: { type: "text", text: "hello" } },
95
+ },
96
+ { type: "chunk", time: 4, chunk: { type: "finish", reason: { kind: "stop" } } },
97
+ ],
68
98
  },
69
99
  surfaceOp: "append",
70
100
  },
@@ -78,6 +108,19 @@ export function oneTurnLog(): SessionEvent[] {
78
108
  ];
79
109
  }
80
110
 
111
+ /** A contiguous second-turn batch continuing {@link oneTurnLog}. */
112
+ function secondTurn(startSeq = 6): SessionEvent[] {
113
+ return [
114
+ { type: "turn/start", seq: SessionSeq(startSeq), time: 9, data: { turn: 2 } },
115
+ {
116
+ type: "turn/end",
117
+ seq: SessionSeq(startSeq + 1),
118
+ time: 10,
119
+ data: { turn: 2, reason: { kind: "completed" } },
120
+ },
121
+ ];
122
+ }
123
+
81
124
  export function appendLog(session: Session, events: readonly SessionEvent[]): void {
82
125
  for (const e of events) {
83
126
  const se = e as SessionEvent<SurfaceEventType>;
@@ -93,467 +136,509 @@ export function appendLog(session: Session, events: readonly SessionEvent[]): vo
93
136
  }
94
137
  }
95
138
 
139
+ /**
140
+ * Run the backend-agnostic handle contract suite. `make()` MUST return a
141
+ * fresh backend over fresh, empty storage each call.
142
+ * @param name - suite label, e.g. `jsonl-none` / `sqlite`.
143
+ * @param make - factory producing one fresh {@link ContractBackend} per test.
144
+ */
96
145
  export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
97
146
  describe(`SessionPersistence contract: ${name}`, () => {
98
- it("round-trips a session: create + append load returns identical meta and byte-identical events", async () => {
147
+ it("round-trips through one write handle: append, self-read, offset/length defaults", async () => {
99
148
  const { persistence, dispose } = await make();
100
149
  try {
101
- const m = meta("s1", "/work");
150
+ const m = meta("round-trip", "/work");
102
151
  const log = oneTurnLog();
103
- await persistence.create(m);
104
- await persistence.append(m.id, log);
105
-
106
- const loaded = await persistence.load(m.id);
107
- expect(loaded.meta).toMatchObject({
108
- version: SESSION_FORMAT_VERSION,
109
- id: m.id,
110
- cwd: "/work",
111
- });
112
- expect(loaded.events).toEqual(log);
152
+ const handle = await persistence.create(m);
153
+ expect(handle.access).toBe("write");
154
+ expect(handle.id).toBe(m.id);
155
+ expect(handle.header).toMatchObject(m);
156
+
157
+ await handle.append(log);
158
+ // An empty batch is a no-op, not an error.
159
+ await handle.append([]);
160
+ // A write handle reads its own successful appends.
161
+ const full = await handle.read();
162
+ expect(full.events).toEqual(log);
163
+ expect((await handle.read(3)).events).toEqual(log.slice(3));
164
+ expect((await handle.read(0, 2)).events).toEqual(log.slice(0, 2));
165
+ expect((await handle.read(1, 3)).events).toEqual(log.slice(1, 4));
166
+ // At/past the stored end: an empty list, never an error.
167
+ expect((await handle.read(log.length)).events).toEqual([]);
168
+ expect((await handle.read(log.length + 100)).events).toEqual([]);
169
+ // flush after a durable append is a satisfied barrier, not an error.
170
+ await handle.flush();
171
+ await handle.close();
113
172
  } finally {
114
173
  await dispose();
115
174
  }
116
175
  });
117
176
 
118
- it("rejects a fractional creation timestamp without reserving its session id", async () => {
177
+ it("read rejects negative or fractional offsets and lengths", async () => {
119
178
  const { persistence, dispose } = await make();
120
179
  try {
121
- const m = { ...meta("fractional-created-at"), createdAt: 1.5 };
122
- await expect(persistence.create(m)).rejects.toThrow(
123
- "session metadata createdAt must be a non-negative safe integer",
124
- );
125
-
126
- const valid = meta("fractional-created-at");
127
- await persistence.create(valid);
128
- await persistence.append(valid.id, oneTurnLog());
129
- expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt);
180
+ const handle = await persistence.create(meta("read-args"));
181
+ await expect(handle.read(-1)).rejects.toThrow(/non-negative safe integer/);
182
+ await expect(handle.read(1.5)).rejects.toThrow(/non-negative safe integer/);
183
+ await expect(handle.read(0, -1)).rejects.toThrow(/non-negative safe integer/);
184
+ await handle.close();
130
185
  } finally {
131
186
  await dispose();
132
187
  }
133
188
  });
134
189
 
135
- it("crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}", async () => {
190
+ it("duplicate create rejects against a live pending session and allows the id after an erasing close", async () => {
136
191
  const { persistence, dispose } = await make();
137
192
  try {
138
- const m = meta("interrupted");
139
- await persistence.create(m);
140
- await persistence.append(m.id, oneTurnLog()); // turn 1, committed (seqs 0..5)
141
- // A second turn that crashed mid-flight: turn/start + step/start were
142
- // durably written, but no step/end / turn/end ever arrived.
143
- await persistence.append(m.id, [
144
- { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
145
- { type: "step/start", seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } },
146
- ]);
147
- const beforeRepair = (await persistence.listSnapshots()).find(
148
- (snapshot) => snapshot.header.id === m.id,
149
- )?.revision;
150
-
151
- const inspected = await persistence.inspect(m.id);
152
- const afterInspect = (await persistence.listSnapshots()).find(
153
- (snapshot) => snapshot.header.id === m.id,
154
- )?.revision;
155
- expect(afterInspect).toBe(beforeRepair);
156
- expect(inspected.events.map((e) => e.type)).toEqual([
157
- "turn/start",
158
- "user/message",
159
- "step/start",
160
- "assistant/message",
161
- "step/end",
162
- "turn/end",
163
- "turn/start",
164
- "step/start",
165
- "step/end",
166
- "turn/end",
167
- ]);
168
-
169
- // load PRESERVES the interrupted turn's events (a turn can be huge — they
170
- // must not be truncated) and closes the orphaned turn with synthetic
171
- // boundary events: step/end (the step was open) then turn/end {interrupted}.
172
- const loaded = await persistence.load(m.id);
173
- const afterRepair = (await persistence.listSnapshots()).find(
174
- (snapshot) => snapshot.header.id === m.id,
175
- )?.revision;
176
- expect(afterRepair).not.toBe(beforeRepair);
177
- expect(loaded.events.map((e) => e.type)).toEqual([
178
- "turn/start",
179
- "user/message",
180
- "step/start",
181
- "assistant/message",
182
- "step/end",
183
- "turn/end", // turn 1
184
- "turn/start",
185
- "step/start",
186
- "step/end",
187
- "turn/end", // turn 2: real events + synthetic closers
188
- ]);
189
- expect(loaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
190
- const last = loaded.events.at(-1)!;
191
- expect(last.type === "turn/end" && last.data.reason).toEqual({ kind: "interrupted" });
192
-
193
- // The closed log is durable and continuable: a fresh append continues at
194
- // the balanced length (seq 10), and a reload round-trips identically.
195
- await persistence.append(m.id, [
196
- { type: "turn/start", seq: SessionSeq(10), time: 9, data: { turn: 3 } },
197
- {
198
- type: "turn/end",
199
- seq: SessionSeq(11),
200
- time: 10,
201
- data: { turn: 3, reason: { kind: "completed" } },
202
- },
203
- ]);
204
- const reloaded = await persistence.load(m.id);
205
- expect(reloaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
193
+ const first = await persistence.create(meta("dup-pending"));
194
+ await expect(persistence.create(meta("dup-pending"))).rejects.toBeInstanceOf(
195
+ SessionAlreadyExistsError,
196
+ );
197
+ // Closing the creator without ever appending erases the session, so
198
+ // the id is free again.
199
+ await first.close();
200
+ const second = await persistence.create(meta("dup-pending"));
201
+ await second.close();
206
202
  } finally {
207
203
  await dispose();
208
204
  }
209
205
  });
210
206
 
211
- it("crash recovery: an unstarted assistant tool request gets a retryable synthetic result", async () => {
207
+ it("concurrent duplicate creates: one wins, the loser rejects SessionAlreadyExistsError", async () => {
212
208
  const { persistence, dispose } = await make();
213
209
  try {
214
- const m = meta("interrupted-toolcall");
215
- await persistence.create(m);
216
- await persistence.append(m.id, oneTurnLog()); // turn 1, committed (seqs 0..5)
217
- // Turn 2 crashed AFTER the assistant message asked for a tool call but
218
- // BEFORE the tool/result was written (the loop runs tools after logging
219
- // the assistant message — a process killed mid-tool lands exactly here).
220
- await persistence.append(m.id, [
221
- { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
222
- { type: "step/start", seq: SessionSeq(7), time: 8, data: { turn: 2, step: 1 } },
223
- {
224
- type: "assistant/message",
225
- seq: SessionSeq(8),
226
- time: 9,
227
- data: {
228
- turn: 2,
229
- step: 1,
230
- message: createMessage({
231
- role: "assistant",
232
- content: [
233
- { type: "tool-call", id: ToolCallId("call-x"), name: "bash", arguments: "{}" },
234
- ],
235
- source: {
236
- kind: "model",
237
- provider: "mock",
238
- model: "mock",
239
- },
240
- }),
241
- },
242
- surfaceOp: "append",
243
- },
244
- ]);
245
-
246
- const loaded = await persistence.load(m.id);
247
- // The orphaned call is answered by a synthetic error tool/result BEFORE
248
- // step/end + turn/end {interrupted}, so the step (and turn) are balanced
249
- // and a resumed session derives a valid transcript (no dangling call).
250
- expect(loaded.events.map((e) => e.type)).toEqual([
251
- "turn/start",
252
- "user/message",
253
- "step/start",
254
- "assistant/message",
255
- "step/end",
256
- "turn/end", // turn 1
257
- "turn/start",
258
- "step/start",
259
- "assistant/message",
260
- "tool/result",
261
- "step/end",
262
- "turn/end", // turn 2
263
- ]);
264
- const synthetic = loaded.events.find((e) => e.type === "tool/result");
265
- expect(synthetic?.type === "tool/result" && synthetic.data).toMatchObject({
266
- message: {
267
- source: { kind: "tool", callId: ToolCallId("call-x") },
268
- content: [{ type: "tool-result", toolCallId: ToolCallId("call-x"), isError: true }],
269
- },
270
- error: { code: TOOL_NOT_STARTED },
271
- });
272
- // The synthetic result carries the SAME callId as the orphaned tool-call,
273
- // so deriveMessages() pairs them — no provider-invalid dangling call.
274
- const call = loaded.events.findLast((e) => e.type === "assistant/message");
275
- const callId =
276
- call?.type === "assistant/message" &&
277
- call.data.message.content.find((b) => b.type === "tool-call");
278
- expect(callId && callId.type === "tool-call" && callId.id).toBe(ToolCallId("call-x"));
210
+ const m = meta("dup-race");
211
+ const results = await Promise.allSettled([persistence.create(m), persistence.create(m)]);
212
+ const winners = results.filter((r) => r.status === "fulfilled");
213
+ const losers = results.filter((r) => r.status === "rejected");
214
+ expect(winners).toHaveLength(1);
215
+ expect(losers).toHaveLength(1);
216
+ expect((losers[0] as PromiseRejectedResult).reason).toBeInstanceOf(
217
+ SessionAlreadyExistsError,
218
+ );
219
+ await (winners[0] as PromiseFulfilledResult<SessionHandle>).value.close();
279
220
  } finally {
280
221
  await dispose();
281
222
  }
282
223
  });
283
224
 
284
- it("crash recovery: a recorded tool call with no result tells the model to assess retry risk", async () => {
285
- const { persistence, dispose } = await make();
225
+ it("duplicate create rejects against a materialized artifact seen by a fresh instance", async () => {
226
+ const backend = await make();
286
227
  try {
287
- const m = meta("unknown-tool-outcome");
288
- await persistence.create(m);
289
- await persistence.append(m.id, [
290
- { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
291
- { type: "step/start", seq: SessionSeq(1), time: 2, data: { turn: 1, step: 1 } },
292
- {
293
- type: "assistant/message",
294
- seq: SessionSeq(2),
295
- time: 3,
296
- data: {
297
- turn: 1,
298
- step: 1,
299
- message: createMessage({
300
- role: "assistant",
301
- content: [
302
- {
303
- type: "tool-call",
304
- id: ToolCallId("call-risk"),
305
- name: "write",
306
- arguments: "{}",
307
- },
308
- ],
309
- source: {
310
- kind: "model",
311
- provider: "mock",
312
- model: "mock",
313
- },
314
- }),
315
- },
316
- surfaceOp: "append",
317
- },
318
- {
319
- type: "tool/call",
320
- seq: SessionSeq(3),
321
- time: 4,
322
- data: {
323
- turn: 1,
324
- step: 1,
325
- callId: ToolCallId("call-risk"),
326
- name: "write",
327
- arguments: "{}",
328
- },
329
- },
330
- ]);
331
-
332
- const loaded = await persistence.load(m.id);
333
- const synthetic = loaded.events.find((e) => e.type === "tool/result");
334
- expect(synthetic?.type === "tool/result" && synthetic.data.error).toEqual({
335
- name: "ToolOutcomeUnknownError",
336
- code: TOOL_OUTCOME_UNKNOWN,
337
- });
338
- if (
339
- synthetic?.type !== "tool/result" ||
340
- synthetic.data.message.content[0].content[0]?.type !== "text"
341
- ) {
342
- throw new Error("expected a text tool result");
228
+ if (backend.reopen === undefined) return;
229
+ const m = meta("dup-stored", "/work");
230
+ const handle = await backend.persistence.create(m);
231
+ await handle.append(oneTurnLog());
232
+ await handle.close();
233
+
234
+ const reopened = await backend.reopen();
235
+ try {
236
+ await expect(
237
+ reopened.persistence.create(meta("dup-stored", "/work")),
238
+ ).rejects.toBeInstanceOf(SessionAlreadyExistsError);
239
+ } finally {
240
+ await reopened.dispose();
343
241
  }
344
- expect(synthetic.data.message.content[0].content[0].text).toContain(
345
- "retry only if the operation is read-only or idempotent",
346
- );
347
- expect(synthetic.data.message.content[0].content[0].text).toContain(
348
- "if it may have side effects, first verify external state or ask the user",
242
+ } finally {
243
+ await backend.dispose();
244
+ }
245
+ });
246
+
247
+ it("open of an absent session rejects with SessionPersistenceNotFoundError for both accesses", async () => {
248
+ const { persistence, dispose } = await make();
249
+ try {
250
+ await expect(persistence.open(SessionId("absent"), "read")).rejects.toBeInstanceOf(
251
+ SessionPersistenceNotFoundError,
349
252
  );
350
- const resumed = Session.create(
351
- m.id,
352
- loaded.events,
353
- loaded.meta,
354
- loaded.inheritedEventCount,
253
+ await expect(persistence.open(SessionId("absent"), "write")).rejects.toBeInstanceOf(
254
+ SessionPersistenceNotFoundError,
355
255
  );
356
- const resumedResult = resumed
357
- .deriveMessages()
358
- .find((message) => message.content.some((block) => block.type === "tool-result"));
359
- expect(resumedResult?.content[0]).toMatchObject({
360
- type: "tool-result",
361
- toolCallId: ToolCallId("call-risk"),
362
- isError: true,
363
- });
364
256
  } finally {
365
257
  await dispose();
366
258
  }
367
259
  });
368
260
 
369
- it("list() excludes a created-but-never-appended (zero-event) session", async () => {
261
+ it("write ownership is single-holder per instance and released by close", async () => {
370
262
  const { persistence, dispose } = await make();
371
263
  try {
372
- await persistence.create(meta("empty"));
373
- expect((await persistence.list()).map((m) => m.id)).not.toContain(SessionId("empty"));
374
- expect(
375
- (await persistence.listSnapshots()).map((snapshot) => snapshot.header.id),
376
- ).not.toContain(SessionId("empty"));
264
+ const m = meta("owned");
265
+ const creator = await persistence.create(m);
266
+ // The creator holds ownership even before materialization.
267
+ await expect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(
268
+ SessionAlreadyOwnedError,
269
+ );
270
+ await creator.append(oneTurnLog());
271
+ await expect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(
272
+ SessionAlreadyOwnedError,
273
+ );
274
+ await creator.close();
275
+
276
+ // After close, a new write handle continues at the stored next-seq.
277
+ const writer = await persistence.open(m.id, "write");
278
+ await writer.append(secondTurn());
279
+ expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
280
+ await writer.close();
377
281
  } finally {
378
282
  await dispose();
379
283
  }
380
284
  });
381
285
 
382
- it("rejects pre-aborted observation reads with the exact cancellation reason", async () => {
286
+ it("a read handle refuses append and flush with SessionReadOnlyError", async () => {
383
287
  const { persistence, dispose } = await make();
384
288
  try {
385
- const reason = new Error("persistence observation cancelled");
386
- const controller = new AbortController();
387
- await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([]);
388
- controller.abort(reason);
289
+ const m = meta("read-only");
290
+ const writer = await persistence.create(m);
291
+ await writer.append(oneTurnLog());
292
+ await writer.close();
389
293
 
390
- await expect(persistence.list(controller.signal)).rejects.toBe(reason);
391
- await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason);
392
- await expect(
393
- persistence.inspect(SessionId("cancelled-inspect"), controller.signal),
394
- ).rejects.toBe(reason);
395
- await expect(
396
- persistence.readFrom(
397
- SessionId("cancelled-read-from"),
398
- SessionLogOffset(0),
399
- controller.signal,
400
- ),
401
- ).rejects.toBe(reason);
294
+ const reader = await persistence.open(m.id, "read");
295
+ expect(reader.access).toBe("read");
296
+ await expect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError);
297
+ await expect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError);
298
+ // The refusals mutated nothing.
299
+ expect((await reader.read()).events).toEqual(oneTurnLog());
300
+ await reader.close();
402
301
  } finally {
403
302
  await dispose();
404
303
  }
405
304
  });
406
305
 
407
- it("readFrom returns exactly the stored suffix from the requested seq, without mutating the log", async () => {
306
+ it("operations on a closed handle reject; close is idempotent; asyncDispose releases ownership", async () => {
408
307
  const { persistence, dispose } = await make();
409
308
  try {
410
- const m = meta("read-from", "/work");
411
- const log = oneTurnLog();
412
- await persistence.create(m);
413
- await persistence.append(m.id, log);
309
+ const m = meta("closed");
310
+ const handle = await persistence.create(m);
311
+ await handle.append(oneTurnLog());
312
+ await handle.close();
313
+ await handle.close();
314
+ await expect(handle.read()).rejects.toBeInstanceOf(SessionHandleClosedError);
315
+ await expect(handle.append(secondTurn())).rejects.toBeInstanceOf(SessionHandleClosedError);
316
+ await expect(handle.flush()).rejects.toBeInstanceOf(SessionHandleClosedError);
414
317
 
415
- const whole = await persistence.readFrom(m.id, SessionLogOffset(0));
416
- expect(whole.meta).toMatchObject({ id: m.id, cwd: "/work" });
417
- expect(whole.events).toEqual(log);
318
+ {
319
+ await using writer = await persistence.open(m.id, "write");
320
+ await writer.append(secondTurn());
321
+ }
322
+ // Leaving the block disposed the handle, so ownership is free again.
323
+ const reopened = await persistence.open(m.id, "write");
324
+ await reopened.close();
325
+ } finally {
326
+ await dispose();
327
+ }
328
+ });
418
329
 
419
- const suffix = await persistence.readFrom(m.id, SessionLogOffset(3));
420
- expect(suffix.events).toEqual(log.slice(3));
421
- expect(suffix.events[0]?.seq).toBe(3);
330
+ it("service-level flush materializes every active write handle and counts a closing one as flushed", async () => {
331
+ const backend = await make();
332
+ try {
333
+ const materialized = await backend.persistence.create(meta("flush-all"));
334
+ const abandoned = await backend.persistence.create(meta("flush-all-closing"));
335
+ // Close starts before the barrier: the swept handle refuses its flush,
336
+ // which counts as flushed — close itself drained durably.
337
+ const closing = abandoned.close();
338
+ await backend.persistence.flush();
339
+ await closing;
422
340
 
423
- // At/past the stored end: an empty tail, never an error.
424
- await expect(
425
- persistence.readFrom(m.id, SessionLogOffset(log.length)),
426
- ).resolves.toMatchObject({ events: [] });
427
- await expect(
428
- persistence.readFrom(m.id, SessionLogOffset(log.length + 100)),
429
- ).resolves.toMatchObject({
430
- events: [],
431
- });
432
-
433
- // Non-mutating: an interrupted-turn log is served as stored, no closers.
434
- await persistence.append(m.id, [
435
- { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
436
- ]);
437
- const tail = await persistence.readFrom(m.id, SessionLogOffset(6));
438
- expect(tail.events.map((event) => event.type)).toEqual(["turn/start"]);
341
+ if (backend.reopen !== undefined) {
342
+ const reopened = await backend.reopen();
343
+ try {
344
+ // The barrier materialized the empty session durably...
345
+ expect(await reopened.persistence.stat(SessionId("flush-all"))).toBeDefined();
346
+ // ...while the one that closed unappended never existed.
347
+ expect(await reopened.persistence.stat(SessionId("flush-all-closing"))).toBeUndefined();
348
+ } finally {
349
+ await reopened.dispose();
350
+ }
351
+ }
352
+ await materialized.close();
353
+ } finally {
354
+ await backend.dispose();
355
+ }
356
+ });
439
357
 
440
- await expect(
441
- persistence.readFrom(SessionId("absent-read-from"), SessionLogOffset(0)),
442
- ).rejects.toThrow("not found");
443
- await expect(persistence.readFrom(m.id, -1 as never)).rejects.toThrow(
444
- "non-negative safe integer",
445
- );
446
- await expect(persistence.readFrom(m.id, 1.5 as never)).rejects.toThrow(
447
- "non-negative safe integer",
448
- );
358
+ it("a created-but-unappended session is visible to this instance and invisible to a fresh one", async () => {
359
+ const backend = await make();
360
+ try {
361
+ const m = meta("lazy", "/work");
362
+ const creator = await backend.persistence.create(m);
363
+ // The creator's own reads see the empty log before materialization.
364
+ expect((await creator.read()).events).toEqual([]);
365
+
366
+ const snapshot = await backend.persistence.stat(m.id);
367
+ expect(snapshot?.header).toMatchObject(m);
368
+ expect((await backend.persistence.list()).map((s) => s.header.id)).toContain(m.id);
369
+ const reader = await backend.persistence.open(m.id, "read");
370
+ expect((await reader.read()).events).toEqual([]);
371
+ await reader.close();
372
+
373
+ if (backend.reopen !== undefined) {
374
+ const reopened = await backend.reopen();
375
+ try {
376
+ expect(await reopened.persistence.stat(m.id)).toBeUndefined();
377
+ expect((await reopened.persistence.list()).map((s) => s.header.id)).not.toContain(m.id);
378
+ await expect(reopened.persistence.open(m.id, "read")).rejects.toBeInstanceOf(
379
+ SessionPersistenceNotFoundError,
380
+ );
381
+ } finally {
382
+ await reopened.dispose();
383
+ }
384
+ }
385
+ await creator.close();
449
386
  } finally {
450
- await dispose();
387
+ await backend.dispose();
451
388
  }
452
389
  });
453
390
 
454
- it("lists stable lightweight revisions that change after an append", async () => {
391
+ it("close without an append erases the created session from this instance", async () => {
455
392
  const { persistence, dispose } = await make();
456
393
  try {
457
- const m = meta("s2");
458
- await persistence.create(m);
459
- await persistence.append(m.id, oneTurnLog());
460
- expect((await persistence.list()).map((x) => x.id)).toContain(m.id);
461
- const first = (await persistence.listSnapshots()).find(
462
- (snapshot) => snapshot.header.id === m.id,
463
- );
464
- const repeated = (await persistence.listSnapshots()).find(
465
- (snapshot) => snapshot.header.id === m.id,
466
- );
467
- expect(first).toBeDefined();
468
- expect(repeated?.revision).toBe(first?.revision);
394
+ const m = meta("never-was");
395
+ const creator = await persistence.create(m);
396
+ await creator.close();
469
397
 
470
- await persistence.append(m.id, [
471
- {
472
- type: "turn/start",
473
- seq: SessionSeq(6),
474
- time: 7,
475
- data: { turn: 2 },
476
- },
477
- ]);
478
- const changed = (await persistence.listSnapshots()).find(
479
- (snapshot) => snapshot.header.id === m.id,
398
+ expect(await persistence.stat(m.id)).toBeUndefined();
399
+ expect((await persistence.list()).map((s) => s.header.id)).not.toContain(m.id);
400
+ await expect(persistence.open(m.id, "read")).rejects.toBeInstanceOf(
401
+ SessionPersistenceNotFoundError,
480
402
  );
481
- expect(changed?.revision).not.toBe(first?.revision);
482
403
  } finally {
483
404
  await dispose();
484
405
  }
485
406
  });
486
407
 
487
- it("append rejects a batch whose first seq does not match the stored next-seq", async () => {
408
+ it("flush materializes an empty session durably for a fresh instance", async () => {
409
+ const backend = await make();
410
+ try {
411
+ if (backend.reopen === undefined) return;
412
+ const m = meta("durable-empty", "/work");
413
+ const creator = await backend.persistence.create(m);
414
+ await creator.flush();
415
+ await creator.close();
416
+
417
+ const reopened = await backend.reopen();
418
+ try {
419
+ expect((await reopened.persistence.list()).map((s) => s.header.id)).toContain(m.id);
420
+ expect((await reopened.persistence.stat(m.id))?.header).toMatchObject(m);
421
+ const reader = await reopened.persistence.open(m.id, "read");
422
+ expect((await reader.read()).events).toEqual([]);
423
+ await reader.close();
424
+ } finally {
425
+ await reopened.dispose();
426
+ }
427
+ } finally {
428
+ await backend.dispose();
429
+ }
430
+ });
431
+
432
+ it("freshness: reads started after an append resolves observe that prefix on any handle", async () => {
488
433
  const { persistence, dispose } = await make();
489
434
  try {
490
- const m = meta("s3");
491
- await persistence.create(m);
492
- await persistence.append(m.id, oneTurnLog()); // seqs 0..5, next-seq = 6
493
- // A re-append of an already-stored seq must be rejected, not duplicated.
494
- const restated = oneTurnLog();
495
- await expect(persistence.append(m.id, restated)).rejects.toThrow();
435
+ const m = meta("fresh");
436
+ const writer = await persistence.create(m);
437
+ await writer.append(oneTurnLog());
438
+
439
+ const before = await persistence.open(m.id, "read");
440
+ expect((await before.read()).events).toEqual(oneTurnLog());
441
+
442
+ await writer.append(secondTurn());
443
+ // Both a pre-existing read handle and a freshly opened one observe the
444
+ // append once it has resolved.
445
+ expect((await before.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
446
+ const after = await persistence.open(m.id, "read");
447
+ expect((await after.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
448
+ await before.close();
449
+ await after.close();
450
+ await writer.close();
496
451
  } finally {
497
452
  await dispose();
498
453
  }
499
454
  });
500
455
 
501
- it("append rejects a mid-batch seq gap", async () => {
456
+ it("a fresh instance continues the stored log at the committed next-seq", async () => {
457
+ const backend = await make();
458
+ try {
459
+ if (backend.reopen === undefined) return;
460
+ const m = meta("continue", "/work");
461
+ const creator = await backend.persistence.create(m);
462
+ await creator.append(oneTurnLog());
463
+ await creator.close();
464
+
465
+ const reopened = await backend.reopen();
466
+ try {
467
+ const writer = await reopened.persistence.open(m.id, "write");
468
+ await writer.append(secondTurn());
469
+ expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
470
+ await writer.close();
471
+ } finally {
472
+ await reopened.dispose();
473
+ }
474
+ } finally {
475
+ await backend.dispose();
476
+ }
477
+ });
478
+
479
+ it("append rejects a batch that does not contiguously continue the log, naming the expected seq", async () => {
502
480
  const { persistence, dispose } = await make();
503
481
  try {
504
- const m = meta("s4");
505
- await persistence.create(m);
482
+ const m = meta("contiguity");
483
+ const handle = await persistence.create(m);
484
+ await handle.append(oneTurnLog()); // seqs 0..5, next-seq = 6
485
+ // A re-append of an already-stored seq is rejected, not duplicated.
486
+ await expect(handle.append(oneTurnLog())).rejects.toThrow(/expected 6/);
487
+ // A mid-batch gap is rejected as a whole.
506
488
  const gapped: SessionEvent[] = [
507
- { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
508
- { type: "step/start", seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1
489
+ { type: "turn/start", seq: SessionSeq(6), time: 9, data: { turn: 2 } },
490
+ {
491
+ type: "turn/end",
492
+ seq: SessionSeq(8),
493
+ time: 10,
494
+ data: { turn: 2, reason: { kind: "completed" } },
495
+ },
509
496
  ];
510
- await expect(persistence.append(m.id, gapped)).rejects.toThrow();
497
+ await expect(handle.append(gapped)).rejects.toThrow(/expected 7/);
498
+ // Neither rejection changed the stored log.
499
+ expect((await handle.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]);
500
+ await handle.close();
511
501
  } finally {
512
502
  await dispose();
513
503
  }
514
504
  });
515
505
 
516
- it("append rejects non-JSON-serializable event data, naming the event type", async () => {
506
+ it("append rejects non-JSON-serializable event data without storing anything", async () => {
517
507
  const { persistence, dispose } = await make();
518
508
  try {
519
- // Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt —
520
- // otherwise a backend could pass this contract while still accepting values that
521
- // corrupt the durable round-trip. Each value is carried in a plugin-added field on one
522
- // user message so the contract covers the complete JSON-value boundary.
523
- const cyclic: Record<string, unknown> = { type: "text", text: "x" };
524
- cyclic["self"] = cyclic;
525
- const badValues: unknown[] = [
526
- 1n, // BigInt
527
- undefined, // dropped by JSON.stringify
528
- Infinity, // → null
529
- () => 0, // function
530
- Symbol("s"), // symbol
531
- new Map(), // exotic object
532
- cyclic, // circular ref
533
- ];
534
- for (const [i, bad] of badValues.entries()) {
535
- // A fresh session per value isolates each rejection (a rejected append
536
- // must leave no state behind, but isolating keeps the assertion clean).
537
- const mi = meta(`s5-${i}`);
538
- await persistence.create(mi);
539
- const events = [
509
+ const m = meta("non-json");
510
+ const handle = await persistence.create(m);
511
+ const bad = (extra: unknown): SessionEvent[] =>
512
+ [
540
513
  {
541
- type: "user/message",
514
+ type: "turn/start",
542
515
  seq: SessionSeq(0),
543
516
  time: 1,
544
- data: {
545
- id: MessageId(`invalid-json-${i}`),
546
- role: "user",
547
- content: [{ type: "text", text: "x" }],
548
- source: { kind: "user" },
549
- extra: bad,
550
- },
517
+ data: { turn: 1, extra },
551
518
  },
552
519
  ] as unknown as SessionEvent[];
553
- await expect(persistence.append(mi.id, events)).rejects.toThrow(
554
- /losslessly JSON-serializable/,
555
- );
520
+ await expect(handle.append(bad(1n))).rejects.toThrow(TypeError);
521
+ await expect(handle.append(bad(1n))).rejects.toThrow(/losslessly JSON-serializable/);
522
+ await expect(handle.append(bad(undefined))).rejects.toThrow(/losslessly JSON-serializable/);
523
+ // The rejected batches left no events behind: seq 0 is still free.
524
+ await handle.append(oneTurnLog());
525
+ expect((await handle.read()).events).toEqual(oneTurnLog());
526
+ await handle.close();
527
+ } finally {
528
+ await dispose();
529
+ }
530
+ });
531
+
532
+ it("vocabulary fail-closed: an unknown event type refuses append and stored reads", async () => {
533
+ const { persistence, dispose } = await make();
534
+ try {
535
+ const m = meta("foreign-vocabulary");
536
+ const handle = await persistence.create(m);
537
+ await handle.append(oneTurnLog());
538
+ // 写路径 v2 校验:未知类型(非 ignorable)拒绝入库,批次不落库。
539
+ await expect(
540
+ handle.append([
541
+ { type: "mystery/event", seq: SessionSeq(6), time: 7, data: { payload: true } },
542
+ ] as unknown as SessionEvent[]),
543
+ ).rejects.toBeInstanceOf(SessionFormatUnsupportedError);
544
+ await handle.close();
545
+
546
+ // 拒绝的批次未落库:log 只有 oneTurnLog,可正常读写。
547
+ const reader = await persistence.open(m.id, "read");
548
+ try {
549
+ const { events } = await reader.read();
550
+ expect(events).toHaveLength(6);
551
+ } finally {
552
+ await reader.close();
553
+ }
554
+ const writer = await persistence.open(m.id, "write");
555
+ try {
556
+ await writer.append([
557
+ {
558
+ type: "turn/end",
559
+ seq: SessionSeq(6),
560
+ time: 7,
561
+ data: { turn: 2, reason: { kind: "completed" } },
562
+ },
563
+ ] as unknown as SessionEvent[]);
564
+ } finally {
565
+ await writer.close();
566
+ }
567
+ } finally {
568
+ await dispose();
569
+ }
570
+ });
571
+
572
+ it("a torn physical tail is never served and is durably truncated by the write path", async () => {
573
+ const backend = await make();
574
+ try {
575
+ if (backend.reopen === undefined || backend.corruptTail === undefined) return;
576
+ const m = meta("torn", "/work");
577
+ const creator = await backend.persistence.create(m);
578
+ await creator.append(oneTurnLog());
579
+ await creator.close();
580
+ await backend.corruptTail(m.id, m.cwd);
581
+
582
+ // A reader over the corrupted artifact serves only the committed prefix.
583
+ const readerInstance = await backend.reopen();
584
+ try {
585
+ const reader = await readerInstance.persistence.open(m.id, "read");
586
+ expect((await reader.read()).events).toEqual(oneTurnLog());
587
+ await reader.close();
588
+
589
+ // A write open + first append durably truncates the torn tail and
590
+ // continues at the committed next-seq.
591
+ const writer = await readerInstance.persistence.open(m.id, "write");
592
+ await writer.append(secondTurn());
593
+ expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
594
+ await writer.close();
595
+ } finally {
596
+ await readerInstance.dispose();
556
597
  }
598
+
599
+ // The repaired log is intact for the next instance.
600
+ const verifyInstance = await backend.reopen();
601
+ try {
602
+ const verify = await verifyInstance.persistence.open(m.id, "read");
603
+ expect((await verify.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
604
+ await verify.close();
605
+ } finally {
606
+ await verifyInstance.dispose();
607
+ }
608
+ } finally {
609
+ await backend.dispose();
610
+ }
611
+ });
612
+
613
+ it("stat and list agree on stable revisions that change after an append", async () => {
614
+ const { persistence, dispose } = await make();
615
+ try {
616
+ const m = meta("revisions", "/work");
617
+ const writer = await persistence.create(m);
618
+ await writer.append(oneTurnLog());
619
+
620
+ const statFirst = await persistence.stat(m.id);
621
+ const statAgain = await persistence.stat(m.id);
622
+ const listFirst = (await persistence.list()).find((s) => s.header.id === m.id);
623
+ expect(statFirst).toBeDefined();
624
+ expect(statAgain?.revision).toBe(statFirst?.revision);
625
+ expect(listFirst?.revision).toBe(statFirst?.revision);
626
+
627
+ await writer.append(secondTurn());
628
+ const statChanged = await persistence.stat(m.id);
629
+ expect(statChanged?.revision).not.toBe(statFirst?.revision);
630
+ const listChanged = (await persistence.list()).find((s) => s.header.id === m.id);
631
+ expect(listChanged?.revision).toBe(statChanged?.revision);
632
+
633
+ // Snapshot headers carry the stored header, identically everywhere.
634
+ const reader = await persistence.open(m.id, "read");
635
+ expect(statChanged?.header).toEqual(reader.header);
636
+ expect(listChanged?.header).toEqual(reader.header);
637
+ expect(statChanged?.header).toMatchObject(m);
638
+ await reader.close();
639
+ await writer.close();
640
+
641
+ expect(await persistence.stat(SessionId("absent-stat"))).toBeUndefined();
557
642
  } finally {
558
643
  await dispose();
559
644
  }