@morlay/session-rdb 0.0.14 → 0.0.16-alpha.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/README.md +11 -13
- package/dist/artifact.d.mts +6 -16
- package/dist/artifact.mjs +3 -3
- package/dist/{import-B-tf5vQn.mjs → import-CKrzjXVB.mjs} +73 -83
- package/dist/import.d.mts +2 -3
- package/dist/import.mjs +2 -2
- package/dist/index-CgGDHQSK.d.mts +225 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +694 -169
- package/dist/log-DTJsxMud.mjs +314 -0
- package/dist/{schema-CFXZURX7.d.mts → schema-COK7-wRV.d.mts} +3 -15
- package/dist/sqlite-DCy4VTD8.mjs +698 -0
- package/dist/storage.d.mts +2 -7
- package/dist/storage.mjs +2 -3
- package/dist/testing.d.mts +30 -1
- package/dist/testing.mjs +676 -1991
- package/drizzle/postgres/20260908072805_v2_initial/migration.sql +58 -0
- package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +686 -0
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +1 -0
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +673 -0
- package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +53 -0
- package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +492 -0
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +6 -0
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +513 -0
- package/package.json +17 -12
- package/src/adapters/index.ts +10 -2
- package/src/adapters/to-postgres.ts +19 -15
- package/src/adapters/to-sqlite.ts +20 -14
- package/src/{entities → adapters}/types.ts +7 -8
- package/src/backend.ts +0 -7
- package/src/branch.ts +29 -110
- package/src/drizzle/postgres-v2.ts +11 -0
- package/src/drizzle/postgres-v3.ts +11 -0
- package/src/drizzle/sqlite-v2.ts +10 -0
- package/src/drizzle/sqlite-v3.ts +11 -0
- package/src/entities/index.ts +2 -30
- package/src/entities/v2/index.ts +20 -0
- package/src/entities/v2/session-events.ts +11 -0
- package/src/entities/v3/events.ts +27 -0
- package/src/entities/v3/index.ts +27 -0
- package/src/entities/v3/persistence-state.ts +10 -0
- package/src/entities/v3/schema-meta.ts +9 -0
- package/src/entities/v3/session-events.ts +26 -0
- package/src/entities/v3/sessions.ts +20 -0
- package/src/import.ts +65 -57
- package/src/index.ts +868 -223
- package/src/invariant.ts +0 -2
- package/src/legacy.ts +67 -0
- package/src/log.ts +41 -81
- package/src/postgres.ts +75 -93
- package/src/schema.ts +3 -14
- package/src/sqlite.ts +59 -46
- package/src/testing/contract.ts +460 -375
- package/src/testing/coordinator-contract.ts +108 -1374
- package/src/testing.ts +1 -6
- package/dist/index-uUvn6gYp.d.mts +0 -111
- package/dist/schema-BfPVmr1X.mjs +0 -875
- package/dist/sqlite-BUWnS0so.mjs +0 -356
- package/src/adapters/ddl.ts +0 -77
- package/src/entities/events.ts +0 -27
- package/src/entities/persistence-state.ts +0 -10
- package/src/entities/schema-meta.ts +0 -9
- package/src/entities/session-events.ts +0 -29
- package/src/entities/sessions.ts +0 -20
- package/src/migrate.ts +0 -137
package/src/testing/contract.ts
CHANGED
|
@@ -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 {
|
|
18
|
-
import
|
|
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
|
-
|
|
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
|
|
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("
|
|
150
|
+
const m = meta("round-trip", "/work");
|
|
102
151
|
const log = oneTurnLog();
|
|
103
|
-
await persistence.create(m);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
177
|
+
it("read rejects negative or fractional offsets and lengths", async () => {
|
|
119
178
|
const { persistence, dispose } = await make();
|
|
120
179
|
try {
|
|
121
|
-
const
|
|
122
|
-
await expect(
|
|
123
|
-
|
|
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("
|
|
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
|
|
139
|
-
await persistence.create(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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("
|
|
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("
|
|
215
|
-
await persistence.create(m);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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("
|
|
285
|
-
const
|
|
225
|
+
it("duplicate create rejects against a materialized artifact seen by a fresh instance", async () => {
|
|
226
|
+
const backend = await make();
|
|
286
227
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await persistence.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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("
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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("
|
|
286
|
+
it("a read handle refuses append and flush with SessionReadOnlyError", async () => {
|
|
383
287
|
const { persistence, dispose } = await make();
|
|
384
288
|
try {
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
await
|
|
388
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
await expect(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
await
|
|
396
|
-
|
|
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("
|
|
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("
|
|
411
|
-
const
|
|
412
|
-
await
|
|
413
|
-
await
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
await
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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("
|
|
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("
|
|
458
|
-
await persistence.create(m);
|
|
459
|
-
await
|
|
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.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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("
|
|
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("
|
|
491
|
-
await persistence.create(m);
|
|
492
|
-
await
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
await
|
|
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("
|
|
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("
|
|
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(
|
|
508
|
-
{
|
|
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(
|
|
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
|
|
506
|
+
it("append rejects non-JSON-serializable event data without storing anything", async () => {
|
|
517
507
|
const { persistence, dispose } = await make();
|
|
518
508
|
try {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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: "
|
|
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
|
-
|
|
554
|
-
|
|
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
|
}
|