@morlay/session-rdb 0.0.19 → 0.0.21-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.
Files changed (70) hide show
  1. package/README.md +59 -31
  2. package/dist/artifact.d.mts +25 -65
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{schema-DPcuEh_a.d.mts → backend-DpdtxYpz.d.mts} +360 -107
  5. package/dist/branch-5JzX9rUq.mjs +390 -0
  6. package/dist/deletion.d.mts +7 -0
  7. package/dist/deletion.mjs +2 -0
  8. package/dist/dist-vIVO6bA-.mjs +1524 -0
  9. package/dist/import.d.mts +4 -4
  10. package/dist/import.mjs +214 -1
  11. package/dist/index.d.mts +2 -3
  12. package/dist/index.mjs +4 -1630
  13. package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
  14. package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
  15. package/dist/src-CWTWV7vx.mjs +1904 -0
  16. package/dist/storage.d.mts +14 -5
  17. package/dist/storage.mjs +2 -2
  18. package/dist/testing.d.mts +2 -26
  19. package/dist/testing.mjs +10504 -9614
  20. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
  21. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
  22. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
  23. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
  24. package/package.json +29 -30
  25. package/src/adapters/to-postgres.ts +1 -3
  26. package/src/adapters/to-sqlite.ts +0 -2
  27. package/src/adapters/types.ts +0 -3
  28. package/src/artifact.ts +0 -2
  29. package/src/backend.ts +31 -2
  30. package/src/branch.ts +223 -135
  31. package/src/deletion.ts +87 -0
  32. package/src/drizzle/postgres-v2.ts +0 -1
  33. package/src/drizzle/postgres-v3.ts +0 -1
  34. package/src/drizzle/sqlite-v2.ts +0 -1
  35. package/src/drizzle/sqlite-v3.ts +0 -1
  36. package/src/entities/v2/session-events.ts +0 -1
  37. package/src/entities/v3/event-usage.ts +25 -0
  38. package/src/entities/v3/events.ts +1 -3
  39. package/src/entities/v3/index.ts +4 -0
  40. package/src/entities/v3/session-events.ts +1 -2
  41. package/src/entities/v3/session-projcache-rows.ts +0 -8
  42. package/src/entities/v3/sessions.ts +3 -3
  43. package/src/entities/v3/storage-units.ts +0 -1
  44. package/src/entities/v3/workspace-sessions.ts +0 -5
  45. package/src/entities/v3/workspace-state.ts +0 -6
  46. package/src/entities/v3/workspaces.ts +0 -5
  47. package/src/export.ts +103 -0
  48. package/src/gc.ts +76 -0
  49. package/src/import-storages.ts +4 -37
  50. package/src/import.ts +54 -31
  51. package/src/index.ts +150 -114
  52. package/src/legacy.ts +4 -42
  53. package/src/log.ts +63 -106
  54. package/src/postgres.ts +249 -22
  55. package/src/schema.ts +6 -6
  56. package/src/session-query.ts +0 -4
  57. package/src/sqlite.ts +231 -55
  58. package/src/storage-takeover/index.ts +2 -28
  59. package/src/storage-takeover/projection-cache.ts +30 -134
  60. package/src/storage-takeover/repository.ts +20 -59
  61. package/src/storage-takeover/storage-backend.ts +3 -33
  62. package/src/storage-takeover/types.ts +14 -56
  63. package/src/storage.ts +0 -2
  64. package/src/testing/contract.ts +19 -50
  65. package/src/testing/coordinator-contract.ts +10 -25
  66. package/src/testing.ts +2 -2
  67. package/src/usage.ts +191 -0
  68. package/dist/import-Bc2QQa5G.mjs +0 -473
  69. package/dist/index-CCcK9tia.d.mts +0 -270
  70. package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
@@ -1,37 +1,20 @@
1
- /**
2
- * storages 接管:注册在 storage hub 上的 `rdb` KV 后端。它把 workspace 域
3
- * (上游 single 布局:一张 `workspaces` 表 + global 单例)映射到 session-rdb
4
- * 的语义专用表,与事件日志同库同连接;域层(`ctx.storageDomain`)通过
5
- * `routes` 把 `workspace` 域路由到本后端。
6
- *
7
- * 本后端只服务已知域的专用表:未知 unit 名 fail loud,而不是静默落到通用
8
- * 存储——表结构是显式维护的(见 docs/schema.md)。
9
- */
10
-
11
1
  import { StorageError } from "@deepseek-ai/dsh-storage";
12
2
  import type { SessionId } from "@deepseek-ai/dsh-session";
13
3
  import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
14
4
  import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
15
5
  import type { StorageRepository, WorkspaceRecord, WorkspaceDomainState } from "./types.ts";
16
6
 
17
- /** 注册到 storage hub 的后端名(storage-domain 的 routes 引用它)。 */
18
7
  export const RDB_STORAGE_BACKEND = "rdb";
19
8
 
20
- /** 本后端唯一服务的域:上游 `workspaceDomainSpec`(v2,single 布局)。 */
21
9
  const WORKSPACE_UNIT = "workspace";
22
10
  const WORKSPACE_TABLE = "workspaces";
23
11
 
24
- /** 只服务 workspace 域的 KV 后端。 */
25
12
  export class RdbStorageBackend implements StorageBackend {
26
13
  readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
27
14
 
28
- /** 已打开(或打开中)的 unit;重复 open 是调用方 bug。 */
29
15
  private readonly open = new Map<string, WorkspaceKvUnit>();
30
16
  private closed = false;
31
17
 
32
- /**
33
- * @param repository - storages 接管表访问层(与事件日志同介质)。
34
- */
35
18
  constructor(private readonly repository: StorageRepository) {}
36
19
 
37
20
  private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
@@ -47,8 +30,7 @@ export class RdbStorageBackend implements StorageBackend {
47
30
  `(table '${WORKSPACE_TABLE}' plus a global slot)`,
48
31
  );
49
32
  }
50
- // 本后端是 single 布局的专用表实现:per-record 布局(及其 compatibleVersions
51
- // 读语义)无法表达,宁可 fail loud 也不静默按 single 读。
33
+
52
34
  if (descriptor.layout !== undefined && descriptor.layout !== "single") {
53
35
  throw new Error(
54
36
  `rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
@@ -58,7 +40,7 @@ export class RdbStorageBackend implements StorageBackend {
58
40
  if (this.open.has(descriptor.name)) {
59
41
  throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
60
42
  }
61
- // 名字在同步段预留:并发第二个 open 必须在任何 await 之前被拒。
43
+
62
44
  const unit = new WorkspaceKvUnit(this.repository, descriptor);
63
45
  this.open.set(descriptor.name, unit);
64
46
  try {
@@ -82,13 +64,6 @@ export class RdbStorageBackend implements StorageBackend {
82
64
  return unit;
83
65
  }
84
66
 
85
- /**
86
- * Release the backend. New writes are rejected immediately; already-queued
87
- * unit writes drain first (upstream backend contract). The medium (the
88
- * session database) belongs to the owning session-rdb plugin, so closing
89
- * open units here does not close it.
90
- * @returns resolution after every open unit drained.
91
- */
92
67
  async close(): Promise<void> {
93
68
  this.closed = true;
94
69
  const units = [...this.open.values()];
@@ -97,10 +72,9 @@ export class RdbStorageBackend implements StorageBackend {
97
72
  }
98
73
  }
99
74
 
100
- /** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
101
75
  class WorkspaceKvUnit implements KvUnit {
102
76
  private closed = false;
103
- /** 在途写操作:close 先拒绝新写,再 drain 它们(不丢已受理的写)。 */
77
+
104
78
  private readonly inflight = new Set<Promise<void>>();
105
79
  private onClosed: (() => void) | undefined;
106
80
 
@@ -109,7 +83,6 @@ class WorkspaceKvUnit implements KvUnit {
109
83
  private readonly descriptor: KvUnitDescriptor,
110
84
  ) {}
111
85
 
112
- /** Install the name-release callback after the backend registered this unit. */
113
86
  onClose(release: () => void): void {
114
87
  this.onClosed = release;
115
88
  }
@@ -150,7 +123,6 @@ class WorkspaceKvUnit implements KvUnit {
150
123
  this.onClosed?.();
151
124
  }
152
125
 
153
- /** Register one accepted write so close can drain it. */
154
126
  private async track(operation: Promise<unknown>): Promise<void> {
155
127
  const settled = operation.then(
156
128
  () => undefined,
@@ -177,7 +149,6 @@ class WorkspaceKvUnit implements KvUnit {
177
149
  }
178
150
  }
179
151
 
180
- /** Narrow one opaque KV record to the workspace record the domain shipped. */
181
152
  function workspaceRecordOf(value: unknown): WorkspaceRecord {
182
153
  const record = value as Partial<WorkspaceRecord> | null;
183
154
  if (
@@ -200,7 +171,6 @@ function workspaceRecordOf(value: unknown): WorkspaceRecord {
200
171
  };
201
172
  }
202
173
 
203
- /** Narrow one opaque KV global to the workspace registry state the domain shipped. */
204
174
  function workspaceStateOf(value: unknown): WorkspaceDomainState {
205
175
  const state = value as Partial<WorkspaceDomainState> | null;
206
176
  if (
@@ -1,14 +1,3 @@
1
- /**
2
- * storages 接管的类型:**能复用上游契约的一律复用**——记录形状来自上游
3
- * domain/projection 的 `z.infer` 产物(`WorkspaceRecord` / `WorkspaceDomainState`
4
- * / `CheckpointIdentity` / `ProjectionCheckpoint`),本文件只声明存储层的访问
5
- * 接口与"上游类型 + 会话键"的组合。上游 spec 变化时这里直接编译报错,不会
6
- * 静默偏移。
7
- *
8
- * 这些 import 全是类型导入(无运行时依赖):rdb 是通用插件,不能硬依赖
9
- * web 层的 workspace 包或已被替换的 projcache 包。
10
- */
11
-
12
1
  import type { SessionId } from "@deepseek-ai/dsh-session";
13
2
  import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
14
3
  import type {
@@ -25,66 +14,35 @@ export type {
25
14
  WorkspaceRecord,
26
15
  };
27
16
 
28
- /**
29
- * 一个会话的完整 checkpoint:上游 `CheckpointIdentity`(日志身份,唯一权威定义
30
- * 在被替换的 projcache spec 里)+ 上游 `ProjectionCheckpoint`(逐 key 行)+ 会话键。
31
- */
32
17
  export interface StoredProjcacheEntry {
33
18
  sessionId: SessionId;
34
19
  identity: CheckpointIdentity;
35
20
  rows: ProjectionCheckpoint;
36
21
  }
37
22
 
38
- /**
39
- * storages 接管表的方言无关访问层:SQLite 与 PostgreSQL 两个 Backend 各提供
40
- * 同一实现,调用方(KV 后端与投影缓存服务)不感知介质差异。读方法返回持久
41
- * 值的语义视图,workspace 的写入在介质事务内整体替换。
42
- */
43
23
  export interface StorageRepository {
44
- /** 读一个域的版本戳;域从未打开时 undefined。 */
45
24
  readUnitVersion(name: string): Promise<number | undefined>;
46
- /** 写入域的首次版本戳;已存在时保持原值。 */
25
+
47
26
  insertUnitVersion(name: string, version: number): Promise<void>;
48
- /** 读全部 workspace 记录(按 workspace id)。 */
27
+
49
28
  listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>>;
50
- /** 覆盖写一条 workspace 记录(含归属行)。 */
29
+
51
30
  putWorkspace(id: string, record: WorkspaceRecord): Promise<void>;
52
- /** 删除一条 workspace 记录(缺失为 no-op)。 */
31
+
53
32
  deleteWorkspace(id: string): Promise<void>;
54
- /** 读 workspace 单例;从未写入时 null。 */
33
+
55
34
  readWorkspaceState(): Promise<WorkspaceDomainState | null>;
56
- /** 覆盖写 workspace 单例(显示顺序与归档集一并整体替换)。 */
35
+
57
36
  writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
58
- /** 读全部投影 checkpoint(含逐 key 行)。 */
37
+
59
38
  loadProjcache(): Promise<StoredProjcacheEntry[]>;
60
- /**
61
- * 同步读一个会话的 checkpoint:只有同步驱动(SQLite)提供,读路径直接落到
62
- * 介质;异步驱动(PostgreSQL)缺席,调用方退化为写穿镜像。上游的
63
- * `cachedSnapshot` 系列是同步签名,这里的存在性就是"能否直读库"的开关。
64
- * @param sessionId - 会话 id。
65
- * @returns 该会话的 checkpoint,缺失时 `undefined`。
66
- */
67
- readProjcacheSync?(sessionId: string): StoredProjcacheEntry | undefined;
68
- /**
69
- * 同步读一个会话的标题列(`t_sessions.f_title` / `f_title_seq`,rdb 写路径与
70
- * rewind 维护):只有同步驱动提供,列表消费直接取会话数据,不依赖 checkpoint
71
- * 行是否存在。
72
- * @param sessionId - 会话 id。
73
- * @returns 标题与其事件 seq,未写过标题时 `undefined`。
74
- */
75
- readSessionTitleSync?(sessionId: string): { title: string; seq: number } | undefined;
76
- /**
77
- * 覆盖写一个会话的 checkpoint 行(先清旧行再写新行,同一事务)。记录头
78
- * 不在这里:它与 `t_sessions` 行 1:1,identity 直接复用该行的列。
79
- * @param sessionId - 会话 id。
80
- * @param rows - 每个投影 key 一行。
81
- */
39
+
40
+ /** 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。 */
41
+ readProjcacheDirect?(sessionId: string): StoredProjcacheEntry | undefined;
42
+
43
+ readSessionTitleDirect?(sessionId: string): { title: string; seq: number } | undefined;
44
+
82
45
  putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
83
- /**
84
- * 清理陈旧 checkpoint 行:水位为负却已有事件的会话(旧版写入路径的产物,
85
- * 会把有对话的会话误判为空白)。缓存是派生数据,删除只让该会话回到
86
- * 「未知即可见」并等待下一次强制点重写。
87
- * @returns 删除的行数。
88
- */
46
+
89
47
  pruneStaleProjcache(): Promise<number>;
90
48
  }
package/src/storage.ts CHANGED
@@ -1,5 +1,3 @@
1
- // 存储引擎层:schema 常量/表定义、sqlite 后端(openDatabase / SqliteBackend)与
2
- // 并发写保护(WriteGuard)。文件物理分开,经本入口聚合导出。
3
1
  export * from "./schema.ts";
4
2
  export * from "./sqlite.ts";
5
3
  export * from "./write-guard.ts";
@@ -18,29 +18,17 @@ import {
18
18
  type SessionPersistence,
19
19
  } from "@deepseek-ai/dsh-session-persistence";
20
20
 
21
- /** One backend service instance under test plus its teardown. */
22
21
  interface ContractBackendInstance {
23
22
  persistence: SessionPersistence;
24
23
  dispose: () => Promise<void>;
25
24
  }
26
25
 
27
- /** A backend under test: the primary instance plus optional storage-level capabilities. */
28
26
  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
27
  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
- */
28
+
40
29
  corruptTail?: (id: SessionId, cwd: string | undefined) => Promise<void>;
41
30
  }
42
31
 
43
- /** Build a minimal {@link SessionHeader} for a session id. */
44
32
  export function meta(id: string, cwd?: string): SessionHeader {
45
33
  return {
46
34
  version: SESSION_FORMAT_VERSION,
@@ -51,7 +39,6 @@ export function meta(id: string, cwd?: string): SessionHeader {
51
39
  };
52
40
  }
53
41
 
54
- /** A well-formed one-turn event log (contiguous seqs from 0). */
55
42
  export function oneTurnLog(): SessionEvent[] {
56
43
  return [
57
44
  { type: "turn/start", seq: SessionSeq(0), time: 1, data: { turn: 1 } },
@@ -108,7 +95,6 @@ export function oneTurnLog(): SessionEvent[] {
108
95
  ];
109
96
  }
110
97
 
111
- /** A contiguous second-turn batch continuing {@link oneTurnLog}. */
112
98
  function secondTurn(startSeq = 6): SessionEvent[] {
113
99
  return [
114
100
  { type: "turn/start", seq: SessionSeq(startSeq), time: 9, data: { turn: 2 } },
@@ -136,12 +122,6 @@ export function appendLog(session: Session, events: readonly SessionEvent[]): vo
136
122
  }
137
123
  }
138
124
 
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
- */
145
125
  export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
146
126
  describe(`SessionPersistence contract: ${name}`, () => {
147
127
  it("round-trips through one write handle: append, self-read, offset/length defaults", async () => {
@@ -155,18 +135,18 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
155
135
  expect(handle.header).toMatchObject(m);
156
136
 
157
137
  await handle.append(log);
158
- // An empty batch is a no-op, not an error.
138
+
159
139
  await handle.append([]);
160
- // A write handle reads its own successful appends.
140
+
161
141
  const full = await handle.read();
162
142
  expect(full.events).toEqual(log);
163
143
  expect((await handle.read(3)).events).toEqual(log.slice(3));
164
144
  expect((await handle.read(0, 2)).events).toEqual(log.slice(0, 2));
165
145
  expect((await handle.read(1, 3)).events).toEqual(log.slice(1, 4));
166
- // At/past the stored end: an empty list, never an error.
146
+
167
147
  expect((await handle.read(log.length)).events).toEqual([]);
168
148
  expect((await handle.read(log.length + 100)).events).toEqual([]);
169
- // flush after a durable append is a satisfied barrier, not an error.
149
+
170
150
  await handle.flush();
171
151
  await handle.close();
172
152
  } finally {
@@ -194,8 +174,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
194
174
  await expect(persistence.create(meta("dup-pending"))).rejects.toBeInstanceOf(
195
175
  SessionAlreadyExistsError,
196
176
  );
197
- // Closing the creator without ever appending erases the session, so
198
- // the id is free again.
177
+
199
178
  await first.close();
200
179
  const second = await persistence.create(meta("dup-pending"));
201
180
  await second.close();
@@ -263,7 +242,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
263
242
  try {
264
243
  const m = meta("owned");
265
244
  const creator = await persistence.create(m);
266
- // The creator holds ownership even before materialization.
245
+
267
246
  await expect(persistence.open(m.id, "write")).rejects.toBeInstanceOf(
268
247
  SessionAlreadyOwnedError,
269
248
  );
@@ -273,7 +252,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
273
252
  );
274
253
  await creator.close();
275
254
 
276
- // After close, a new write handle continues at the stored next-seq.
277
255
  const writer = await persistence.open(m.id, "write");
278
256
  await writer.append(secondTurn());
279
257
  expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
@@ -295,7 +273,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
295
273
  expect(reader.access).toBe("read");
296
274
  await expect(reader.append(secondTurn())).rejects.toBeInstanceOf(SessionReadOnlyError);
297
275
  await expect(reader.flush()).rejects.toBeInstanceOf(SessionReadOnlyError);
298
- // The refusals mutated nothing.
276
+
299
277
  expect((await reader.read()).events).toEqual(oneTurnLog());
300
278
  await reader.close();
301
279
  } finally {
@@ -319,7 +297,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
319
297
  await using writer = await persistence.open(m.id, "write");
320
298
  await writer.append(secondTurn());
321
299
  }
322
- // Leaving the block disposed the handle, so ownership is free again.
300
+
323
301
  const reopened = await persistence.open(m.id, "write");
324
302
  await reopened.close();
325
303
  } finally {
@@ -332,8 +310,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
332
310
  try {
333
311
  const materialized = await backend.persistence.create(meta("flush-all"));
334
312
  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.
313
+
337
314
  const closing = abandoned.close();
338
315
  await backend.persistence.flush();
339
316
  await closing;
@@ -341,9 +318,8 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
341
318
  if (backend.reopen !== undefined) {
342
319
  const reopened = await backend.reopen();
343
320
  try {
344
- // The barrier materialized the empty session durably...
345
321
  expect(await reopened.persistence.stat(SessionId("flush-all"))).toBeDefined();
346
- // ...while the one that closed unappended never existed.
322
+
347
323
  expect(await reopened.persistence.stat(SessionId("flush-all-closing"))).toBeUndefined();
348
324
  } finally {
349
325
  await reopened.dispose();
@@ -360,7 +336,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
360
336
  try {
361
337
  const m = meta("lazy", "/work");
362
338
  const creator = await backend.persistence.create(m);
363
- // The creator's own reads see the empty log before materialization.
339
+
364
340
  expect((await creator.read()).events).toEqual([]);
365
341
 
366
342
  const snapshot = await backend.persistence.stat(m.id);
@@ -440,8 +416,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
440
416
  expect((await before.read()).events).toEqual(oneTurnLog());
441
417
 
442
418
  await writer.append(secondTurn());
443
- // Both a pre-existing read handle and a freshly opened one observe the
444
- // append once it has resolved.
419
+
445
420
  expect((await before.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
446
421
  const after = await persistence.open(m.id, "read");
447
422
  expect((await after.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
@@ -481,10 +456,10 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
481
456
  try {
482
457
  const m = meta("contiguity");
483
458
  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.
459
+ await handle.append(oneTurnLog());
460
+
486
461
  await expect(handle.append(oneTurnLog())).rejects.toThrow(/expected 6/);
487
- // A mid-batch gap is rejected as a whole.
462
+
488
463
  const gapped: SessionEvent[] = [
489
464
  { type: "turn/start", seq: SessionSeq(6), time: 9, data: { turn: 2 } },
490
465
  {
@@ -495,7 +470,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
495
470
  },
496
471
  ];
497
472
  await expect(handle.append(gapped)).rejects.toThrow(/expected 7/);
498
- // Neither rejection changed the stored log.
473
+
499
474
  expect((await handle.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5]);
500
475
  await handle.close();
501
476
  } finally {
@@ -520,7 +495,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
520
495
  await expect(handle.append(bad(1n))).rejects.toThrow(TypeError);
521
496
  await expect(handle.append(bad(1n))).rejects.toThrow(/losslessly JSON-serializable/);
522
497
  await expect(handle.append(bad(undefined))).rejects.toThrow(/losslessly JSON-serializable/);
523
- // The rejected batches left no events behind: seq 0 is still free.
498
+
524
499
  await handle.append(oneTurnLog());
525
500
  expect((await handle.read()).events).toEqual(oneTurnLog());
526
501
  await handle.close();
@@ -535,7 +510,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
535
510
  const m = meta("foreign-vocabulary");
536
511
  const handle = await persistence.create(m);
537
512
  await handle.append(oneTurnLog());
538
- // 写路径当前格式校验:未知类型(非 ignorable)拒绝入库,批次不落库。
513
+
539
514
  await expect(
540
515
  handle.append([
541
516
  { type: "mystery/event", seq: SessionSeq(6), time: 7, data: { payload: true } },
@@ -543,7 +518,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
543
518
  ).rejects.toBeInstanceOf(SessionFormatUnsupportedError);
544
519
  await handle.close();
545
520
 
546
- // 拒绝的批次未落库:log 只有 oneTurnLog,可正常读写。
547
521
  const reader = await persistence.open(m.id, "read");
548
522
  try {
549
523
  const { events } = await reader.read();
@@ -579,15 +553,12 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
579
553
  await creator.close();
580
554
  await backend.corruptTail(m.id, m.cwd);
581
555
 
582
- // A reader over the corrupted artifact serves only the committed prefix.
583
556
  const readerInstance = await backend.reopen();
584
557
  try {
585
558
  const reader = await readerInstance.persistence.open(m.id, "read");
586
559
  expect((await reader.read()).events).toEqual(oneTurnLog());
587
560
  await reader.close();
588
561
 
589
- // A write open + first append durably truncates the torn tail and
590
- // continues at the committed next-seq.
591
562
  const writer = await readerInstance.persistence.open(m.id, "write");
592
563
  await writer.append(secondTurn());
593
564
  expect((await writer.read()).events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
@@ -596,7 +567,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
596
567
  await readerInstance.dispose();
597
568
  }
598
569
 
599
- // The repaired log is intact for the next instance.
600
570
  const verifyInstance = await backend.reopen();
601
571
  try {
602
572
  const verify = await verifyInstance.persistence.open(m.id, "read");
@@ -630,7 +600,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
630
600
  const listChanged = (await persistence.list()).find((s) => s.header.id === m.id);
631
601
  expect(listChanged?.revision).toBe(statChanged?.revision);
632
602
 
633
- // Snapshot headers carry the stored header, identically everywhere.
634
603
  const reader = await persistence.open(m.id, "read");
635
604
  expect(statChanged?.header).toEqual(reader.header);
636
605
  expect(listChanged?.header).toEqual(reader.header);
@@ -25,11 +25,6 @@ function send(session: Session, events: readonly SessionEvent[]): void {
25
25
  appendLog(session, events);
26
26
  }
27
27
 
28
- /**
29
- * RDB 特有行为契约:live 会话驱动持久化、seed 边界、fork/resume、HMR
30
- * dispose drain、冲突拒绝、torn-tail。coordinator 内部状态(ownerless /
31
- * per-id chain / legacy 迁移)已由新版 handle 模型承担,不再单独测试。
32
- */
33
28
  export function runCoordinatorContract(
34
29
  name: string,
35
30
  makeFixture: () => Promise<CoordinatorFixture>,
@@ -169,15 +164,14 @@ export function runCoordinatorContract(
169
164
  const { ctx, fiber } = await freshCtx(fix);
170
165
  try {
171
166
  const seed = oneTurnLog();
172
- // A fork: a brand-new id whose seed came from elsewhere.
167
+
173
168
  const forked = ctx.sessions.create(SessionId("forked"), { seed, meta: { cwd: WORK } });
174
- await ctx.sessions.flush(forked); // onCreated persisted the seed
169
+ await ctx.sessions.flush(forked);
175
170
  const loaded = await readAll(ctx, SessionId("forked"));
176
- // Fork is where the marker earns its keep: the inherited prefix may
177
- // carry a bracket the still-running parent owns.
171
+
178
172
  expect(loaded.events.slice(0, seed.length)).toEqual(seed);
179
173
  expect(loaded.events.at(-1)).toMatchObject({ type: "session/end-seed", seq: seed.length });
180
- // A flush with no NEW events must not double-write.
174
+
181
175
  await ctx.sessions.flush(forked);
182
176
  const reloaded = await readAll(ctx, SessionId("forked"));
183
177
  expect(reloaded.events).toEqual(loaded.events);
@@ -205,13 +199,13 @@ export function runCoordinatorContract(
205
199
  seed: loaded.events,
206
200
  meta: { cwd: WORK },
207
201
  });
208
- await second.ctx.sessions.flush(s2); // let onCreated adopt
202
+ await second.ctx.sessions.flush(s2);
209
203
  s2.append("turn/start", { turn: 2 });
210
204
  s2.append("turn/end", { turn: 2, reason: { kind: "completed" } });
211
205
  await second.ctx.sessions.flush(s2);
212
206
 
213
207
  const reloaded = await readAll(second.ctx, SessionId("resumed"));
214
- // 0-5 the resumed seed, 6 end-seed, 7-8 the new turn.
208
+
215
209
  expect(reloaded.events.map((e) => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
216
210
  expect(reloaded.events[6]).toMatchObject({ type: "session/end-seed" });
217
211
  } finally {
@@ -224,7 +218,7 @@ export function runCoordinatorContract(
224
218
  const fix = await makeFixture();
225
219
  const ctx = new Context();
226
220
  await ctx.plugin(SessionStore);
227
- // A session exists BEFORE the persistence plugin is applied.
221
+
228
222
  const session = ctx.sessions.create(SessionId("pre-existing"), { meta: { cwd: WORK } });
229
223
  session.append("turn/start", { turn: 1 });
230
224
  session.append(
@@ -239,7 +233,6 @@ export function runCoordinatorContract(
239
233
 
240
234
  const fiber = await fix.mount(ctx);
241
235
  try {
242
- // The plugin seeded it on apply; a subsequent flush persists its events.
243
236
  await ctx.sessions.flush(session);
244
237
  const loaded = await readAll(ctx, SessionId("pre-existing"));
245
238
  expect(loaded.events.length).toBeGreaterThanOrEqual(2);
@@ -265,10 +258,9 @@ export function runCoordinatorContract(
265
258
  { surfaceOp: "append" },
266
259
  );
267
260
  session.append("turn/end", { turn: 1, reason: { kind: "completed" } });
268
- // No explicit flush — dispose must drain.
261
+
269
262
  await fiber.dispose();
270
263
 
271
- // A fresh backend instance reads what the disposed one drained.
272
264
  const second = await freshCtx(fix);
273
265
  try {
274
266
  const loaded = await readAll(second.ctx, SessionId("drain"));
@@ -283,7 +275,6 @@ export function runCoordinatorContract(
283
275
  const fix = await makeFixture();
284
276
  const { ctx, fiber } = await freshCtx(fix);
285
277
  try {
286
- // Persist a session with cwd WORK.
287
278
  let first!: Session;
288
279
  const firstFiber = await ctx.plugin(
289
280
  Object.assign(
@@ -297,7 +288,6 @@ export function runCoordinatorContract(
297
288
  await ctx.sessions.flush(first);
298
289
  await firstFiber.dispose();
299
290
 
300
- // A NEW live session with the same id but a DIFFERENT cwd must be rejected.
301
291
  let second!: Session;
302
292
  await ctx.plugin(
303
293
  Object.assign(
@@ -318,8 +308,6 @@ export function runCoordinatorContract(
318
308
  const fix = await makeFixture();
319
309
  const { ctx, fiber } = await freshCtx(fix);
320
310
  try {
321
- // A live session created then disposed BEFORE its first append: never
322
- // materialized. A new live session reusing the id must reclaim it.
323
311
  let firstSession!: Session;
324
312
  const firstFiber = await ctx.plugin(
325
313
  Object.assign(
@@ -329,8 +317,8 @@ export function runCoordinatorContract(
329
317
  { inject: ["sessions"] },
330
318
  ),
331
319
  );
332
- await ctx.sessions.flush(firstSession); // register the lazy state
333
- await firstFiber.dispose(); // disposed before any append → never materialized
320
+ await ctx.sessions.flush(firstSession);
321
+ await firstFiber.dispose();
334
322
 
335
323
  let reuse!: Session;
336
324
  await ctx.plugin(
@@ -362,7 +350,6 @@ export function runCoordinatorContract(
362
350
  await handle.append(oneTurnLog());
363
351
  await handle.close();
364
352
 
365
- // A fresh backend instance appends through a write handle.
366
353
  const writer = await ctx.sessionPersistence.open(SessionId("storage-only"), "write");
367
354
  await writer.append([
368
355
  { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
@@ -430,12 +417,10 @@ export function runCoordinatorContract(
430
417
  await creator.close();
431
418
  await fix.corruptTail(SessionId("torn"), WORK);
432
419
 
433
- // A reader serves only the committed prefix.
434
420
  const reader = await ctx.sessionPersistence.open(SessionId("torn"), "read");
435
421
  expect((await reader.read()).events).toEqual(oneTurnLog());
436
422
  await reader.close();
437
423
 
438
- // A write open + first append durably truncates the torn tail.
439
424
  const writer = await ctx.sessionPersistence.open(SessionId("torn"), "write");
440
425
  await writer.append([
441
426
  { type: "turn/start", seq: SessionSeq(6), time: 7, data: { turn: 2 } },
package/src/testing.ts CHANGED
@@ -1,7 +1,7 @@
1
- // 测试支撑(跨包测试共享):helpers / contract / coordinator-contract。
2
- // 仅供 vitest 测试引用;不进入运行时 bundle。
3
1
  export { EmptySettings } from "./testing/helpers.ts";
4
2
  export type { ContractBackend } from "./testing/contract.ts";
5
3
  export { appendLog, meta, oneTurnLog, runPersistenceContract } from "./testing/contract.ts";
6
4
  export type { CoordinatorFixture } from "./testing/coordinator-contract.ts";
7
5
  export { runCoordinatorContract } from "./testing/coordinator-contract.ts";
6
+
7
+ export { truncateLiveSession } from "./branch.ts";