@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
package/src/index.ts CHANGED
@@ -5,53 +5,60 @@ import { randomUUID } from "node:crypto";
5
5
  import { Pool } from "pg";
6
6
  import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
7
7
  import {
8
+ SessionAlreadyExistsError,
9
+ SessionAlreadyOwnedError,
10
+ SessionHandleClosedError,
8
11
  SessionPersistence,
12
+ SessionPersistenceNotFoundError,
9
13
  SessionPersistenceRevision,
10
- PersistenceCoordinator,
11
- type PersistenceBackend,
12
- type SessionLocation,
14
+ SessionReadOnlyError,
15
+ assertContiguous,
16
+ assertVersion,
17
+ materializeAppendBatch,
18
+ materializeCreateHeader,
19
+ validateStoredEvents,
20
+ type SessionAccess,
21
+ type SessionHandle,
22
+ type SessionHandleAppendOptions,
23
+ type SessionHandleFlushOptions,
24
+ type SessionHandleReadOptions,
25
+ type SessionHandleReadResult,
26
+ type SessionPersistenceCreateOptions,
27
+ type SessionPersistenceListOptions,
28
+ type SessionPersistenceOpenOptions,
13
29
  type SessionPersistenceSnapshot,
14
- type SessionStorageMetadata,
15
- type StoredPrefix,
16
- type StoredSuffix,
30
+ type SessionPersistenceStatOptions,
17
31
  } from "@deepseek-ai/dsh-session-persistence";
18
32
  import {
19
33
  SessionLogOffset,
34
+ type Session,
20
35
  type SessionEvent,
21
- type SurfaceEventType,
22
- type SessionId,
23
36
  type SessionHeader,
37
+ type SessionId,
38
+ type SurfaceEventType,
24
39
  } from "@deepseek-ai/dsh-session";
25
- import { type Backend, type BackendTx, type EventInsert, type EventRow } from "./backend.ts";
40
+ import { type Backend, type BackendTx, type EventInsert } from "./backend.ts";
26
41
  import { WriteGuard } from "./write-guard.ts";
27
- import {
28
- buildSeqMap,
29
- recomputeReplaceProvenance,
30
- repairOrphanInboxSplices,
31
- repairSurfaceOps,
32
- rowToMeta,
33
- scanRows,
34
- toJsonlArtifact,
35
- } from "./log.ts";
42
+ import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
36
43
  import {
37
44
  DEFAULT_BUSY_TIMEOUT_MS,
38
45
  eventDimensions,
39
46
  EVENT_ENCODING,
40
- isPersistedEvent,
41
47
  type JournalMode,
42
48
  } from "./schema.ts";
43
49
  import { SqliteBackend } from "./sqlite.ts";
44
50
  import { PostgresBackend } from "./postgres.ts";
45
51
  import { SessionBranchRdb } from "./branch.ts";
46
52
  import { registerSessionImport } from "./import.ts";
53
+ import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
47
54
 
48
- export { SCHEMA_VERSION, EPHEMERAL_EVENT_TYPES } from "./schema.ts";
55
+ export { SCHEMA_VERSION } from "./schema.ts";
49
56
  export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
50
57
 
51
58
  export interface SessionPersistenceRdbInternals {
52
59
  readonly backend: Backend;
53
60
  readonly writeGuard: WriteGuard;
54
- create(meta: SessionHeader, inheritedEventCount?: number): Promise<void>;
61
+ create(meta: SessionHeader, inheritedEventCount?: number): Promise<SessionHandle>;
55
62
  append(id: SessionId, events: readonly SessionEvent[]): Promise<void>;
56
63
  load(id: SessionId): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection>;
57
64
  inspect(
@@ -62,7 +69,7 @@ export interface SessionPersistenceRdbInternals {
62
69
  id: SessionId,
63
70
  fromSeq: number,
64
71
  signal?: AbortSignal,
65
- ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionEventSuffix>;
72
+ ): Promise<{ meta: SessionHeader; inheritedEventCount: number; events: readonly SessionEvent[] }>;
66
73
  listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>;
67
74
  readStoredRevision(
68
75
  id: SessionId,
@@ -90,10 +97,342 @@ export type Config =
90
97
  schema?: string;
91
98
  };
92
99
 
93
- export class SessionPersistenceRdb
94
- extends SessionPersistence
95
- implements PersistenceBackend<number>
96
- {
100
+ /** 一个已创建但未 materialize 的会话(本进程可见,其他进程不可见)。 */
101
+ interface PendingSession {
102
+ readonly header: SessionHeader;
103
+ readonly revision: SessionPersistenceRevision;
104
+ readonly inheritedEventCount: SessionLogOffset;
105
+ /** 输入空间 cursor(全 delta 批次也推进)。 */
106
+ readonly cursor: number;
107
+ /** 是否调用过 append(close 时保留 pending)。 */
108
+ readonly everAppended: boolean;
109
+ }
110
+
111
+ /** 会话级写所有权与 live 路由簿记。 */
112
+ class RdbBackendTracker {
113
+ /** 每个 id 的活跃 write handle;`null` 表示 claim 构造中。 */
114
+ private readonly writers = new Map<SessionId, RdbSessionHandle | null>();
115
+ private readonly pending = new Map<SessionId, PendingSession>();
116
+ private readonly openHandles = new Set<RdbSessionHandle>();
117
+ private counter = 0;
118
+
119
+ constructor(private readonly name: string) {}
120
+
121
+ registerCreated(header: SessionHeader, inheritedEventCount: SessionLogOffset): void {
122
+ if (this.writers.has(header.id)) throw new SessionAlreadyExistsError(header.id);
123
+ this.writers.set(header.id, null);
124
+ this.pending.set(header.id, {
125
+ header,
126
+ revision: SessionPersistenceRevision(`memory:${this.name}:${++this.counter}`),
127
+ inheritedEventCount,
128
+ cursor: 0,
129
+ everAppended: false,
130
+ });
131
+ }
132
+
133
+ /** 更新 pending 的 cursor / everAppended(handle append 后同步)。 */
134
+ updatePending(id: SessionId, cursor: number, everAppended: boolean): void {
135
+ const entry = this.pending.get(id);
136
+ if (entry === undefined) return;
137
+ this.pending.set(id, { ...entry, cursor, everAppended });
138
+ }
139
+
140
+ claimWrite(id: SessionId): void {
141
+ if (this.writers.has(id)) throw new SessionAlreadyOwnedError(id);
142
+ this.writers.set(id, null);
143
+ }
144
+
145
+ releaseClaim(id: SessionId): void {
146
+ this.writers.delete(id);
147
+ }
148
+
149
+ pendingOf(id: SessionId): PendingSession | undefined {
150
+ return this.pending.get(id);
151
+ }
152
+
153
+ hasPending(id: SessionId): boolean {
154
+ return this.pending.has(id);
155
+ }
156
+
157
+ pendingEntries(): IterableIterator<[SessionId, PendingSession]> {
158
+ return this.pending.entries();
159
+ }
160
+
161
+ materialized(id: SessionId): void {
162
+ this.pending.delete(id);
163
+ }
164
+
165
+ adopt(handle: RdbSessionHandle): RdbSessionHandle {
166
+ this.openHandles.add(handle);
167
+ if (handle.access === "write") this.writers.set(handle.id, handle);
168
+ return handle;
169
+ }
170
+
171
+ release(handle: RdbSessionHandle, materialized: boolean): void {
172
+ this.openHandles.delete(handle);
173
+ if (handle.access !== "write") return;
174
+ this.writers.delete(handle.id);
175
+ if (!materialized) this.pending.delete(handle.id);
176
+ }
177
+
178
+ writerOf(id: SessionId): RdbSessionHandle | undefined {
179
+ const writer = this.writers.get(id);
180
+ return writer === null ? undefined : writer;
181
+ }
182
+
183
+ async flushAll(): Promise<void> {
184
+ const errors: unknown[] = [];
185
+ for (const writer of this.writers.values()) {
186
+ if (writer === null) continue;
187
+ try {
188
+ await writer.drainLive();
189
+ await writer.flush();
190
+ } catch (error: unknown) {
191
+ if (error instanceof SessionHandleClosedError) continue;
192
+ errors.push(error);
193
+ }
194
+ }
195
+ if (errors.length > 0) throw new AggregateError(errors, `${this.name} flush failed`);
196
+ }
197
+
198
+ async closeAll(): Promise<void> {
199
+ const errors: unknown[] = [];
200
+ for (const handle of this.openHandles) {
201
+ try {
202
+ await handle.close();
203
+ } catch (error: unknown) {
204
+ errors.push(error);
205
+ }
206
+ }
207
+ if (errors.length > 0) throw new AggregateError(errors, `${this.name} dispose failed`);
208
+ }
209
+ }
210
+
211
+ /** 一个打开会话的存储句柄:read / append / flush / close。 */
212
+ class RdbSessionHandle implements SessionHandle {
213
+ private chain: Promise<unknown> = Promise.resolve();
214
+ private closing: Promise<void> | undefined;
215
+ /** 稠密 next-seq(输入空间计数,与旧版 coordinator cursor 同语义)。 */
216
+ private cursor: number;
217
+ private materialized: boolean;
218
+ /** live 路由缓冲(上游 seq 事件,drain 时过滤 delta 后重编号稠密)。 */
219
+ private buffered: SessionEvent[] = [];
220
+ private batchTimer: ReturnType<typeof setTimeout> | undefined;
221
+ private drainPaused = false;
222
+ private draining: Promise<void> | undefined;
223
+ /** write open 时发现的 torn tail 起点(append 前先截断)。 */
224
+ private tornTruncateTo: number | undefined;
225
+ /** 是否调用过 append(即使全 delta 未落库)——close 时保留 pending。 */
226
+ private everAppended = false;
227
+
228
+ constructor(
229
+ private readonly persistence: SessionPersistenceRdb,
230
+ readonly id: SessionId,
231
+ readonly header: SessionHeader,
232
+ readonly access: SessionAccess,
233
+ private readonly state: {
234
+ cursor: number;
235
+ materialized: boolean;
236
+ inheritedEventCount: SessionLogOffset;
237
+ tornTruncateTo?: number;
238
+ },
239
+ ) {
240
+ this.cursor = state.cursor;
241
+ this.materialized = state.materialized;
242
+ this.tornTruncateTo = state.tornTruncateTo;
243
+ }
244
+
245
+ get inheritedEventCount(): SessionLogOffset {
246
+ return this.state.inheritedEventCount;
247
+ }
248
+
249
+ /** 输入空间 cursor(下一个待落库的上游 seq)。 */
250
+ get cursorValue(): number {
251
+ return this.cursor;
252
+ }
253
+
254
+ async read(
255
+ offset = 0,
256
+ length = Number.MAX_SAFE_INTEGER,
257
+ options?: SessionHandleReadOptions,
258
+ ): Promise<SessionHandleReadResult> {
259
+ this.assertOpen("read");
260
+ if (!Number.isSafeInteger(offset) || offset < 0) {
261
+ throw new TypeError(`read offset must be a non-negative safe integer, got ${String(offset)}`);
262
+ }
263
+ if (!Number.isSafeInteger(length) || length < 0) {
264
+ throw new TypeError(`read length must be a non-negative safe integer, got ${String(length)}`);
265
+ }
266
+ options?.signal?.throwIfAborted();
267
+ const log = await this.persistence.readLog(this.id, {}, options?.signal);
268
+ if (log === undefined) {
269
+ if (this.persistence.tracker.hasPending(this.id)) {
270
+ return { eventState: "detached", events: [] };
271
+ }
272
+ throw new SessionPersistenceNotFoundError(this.id);
273
+ }
274
+ // 读取时修复(视图只读,不落库):结算字段补全、非法 surface 替换降级
275
+ // 或按 metering 数量夹取、metering range 对齐、provenance 重算、孤儿
276
+ // inbox splice 改写。
277
+ repairReadView(log.events);
278
+ return { eventState: "detached", events: log.events.slice(offset, offset + length) };
279
+ }
280
+
281
+ async append(
282
+ events: readonly SessionEvent[],
283
+ options?: SessionHandleAppendOptions,
284
+ ): Promise<void> {
285
+ this.assertOpen("append");
286
+ const batch = materializeAppendBatch(events);
287
+ return this.run("append", async () => {
288
+ options?.signal?.throwIfAborted();
289
+ if (this.access !== "write") throw new SessionReadOnlyError(this.id, "append");
290
+ if (batch.length === 0) return;
291
+ this.everAppended = true;
292
+ assertContiguous(this.id, batch, this.cursor);
293
+ await this.persistence.appendBatch(
294
+ this.header,
295
+ this.state.inheritedEventCount,
296
+ batch,
297
+ this.tornTruncateTo,
298
+ );
299
+ this.tornTruncateTo = undefined;
300
+ // 原样存储(与上游 JSONL 一致):cursor 推进 batch.length,全部落库。
301
+ this.cursor += batch.length;
302
+ this.materialized = true;
303
+ this.persistence.tracker.updatePending(this.id, this.cursor, true);
304
+ });
305
+ }
306
+
307
+ async flush(options?: SessionHandleFlushOptions): Promise<void> {
308
+ return this.run("flush", async () => {
309
+ options?.signal?.throwIfAborted();
310
+ if (this.access !== "write") throw new SessionReadOnlyError(this.id, "flush");
311
+ if (this.materialized) return;
312
+ await this.persistence.materializeEmpty(this.header, this.state.inheritedEventCount);
313
+ this.materialized = true;
314
+ });
315
+ }
316
+
317
+ close(): Promise<void> {
318
+ return (this.closing ??= (async () => {
319
+ let drainFailure: unknown;
320
+ for (;;) {
321
+ try {
322
+ await this.drainLive();
323
+ } catch (error: unknown) {
324
+ drainFailure = error;
325
+ break;
326
+ }
327
+ await this.chain;
328
+ if (this.buffered.length === 0) break;
329
+ }
330
+ await this.chain;
331
+ const failures: Error[] = [];
332
+ if (drainFailure !== undefined) {
333
+ failures.push(
334
+ drainFailure instanceof Error ? drainFailure : new Error(JSON.stringify(drainFailure)),
335
+ );
336
+ }
337
+ this.persistence.tracker.release(this, this.materialized || this.everAppended);
338
+ if (failures.length > 1)
339
+ throw new AggregateError(failures, `session "${this.id}": close failed to drain`);
340
+ if (failures[0] !== undefined) throw failures[0];
341
+ })());
342
+ }
343
+
344
+ [Symbol.asyncDispose](): Promise<void> {
345
+ return this.close();
346
+ }
347
+
348
+ /** live 路由:缓冲一个已发布事件(持久化自有副本),并启动批量窗口。 */
349
+ enqueueLive(event: SessionEvent, reportBackgroundFailure: (error: unknown) => void): void {
350
+ this.buffered.push(structuredClone(event));
351
+ if (this.batchTimer !== undefined || this.drainPaused) return;
352
+ this.batchTimer = setTimeout(() => {
353
+ this.batchTimer = undefined;
354
+ this.drainLive().catch(reportBackgroundFailure);
355
+ }, RdbSessionHandle.LIVE_WRITE_BATCH_MAX_DELAY_MS);
356
+ }
357
+
358
+ /** rewind 截断后对齐 handle 的稠密 cursor 与继承前缀(DB 已截断)。 */
359
+ resetAfterRewind(cursor: number, inheritedEventCount?: number): void {
360
+ this.cursor = cursor;
361
+ if (inheritedEventCount !== undefined) {
362
+ (this.state as { inheritedEventCount: SessionLogOffset }).inheritedEventCount =
363
+ SessionLogOffset(inheritedEventCount);
364
+ }
365
+ }
366
+
367
+ /** 排空 live 缓冲(过滤 delta + 重编号稠密 + append)。 */
368
+ drainLive(): Promise<void> {
369
+ return (this.draining ??= this.drainBuffered().finally(() => {
370
+ this.draining = undefined;
371
+ }));
372
+ }
373
+
374
+ private async drainBuffered(): Promise<void> {
375
+ if (this.batchTimer !== undefined) {
376
+ clearTimeout(this.batchTimer);
377
+ this.batchTimer = undefined;
378
+ }
379
+ this.drainPaused = false;
380
+ while (this.buffered.length > 0) {
381
+ await this.enqueueChain(async () => {
382
+ const batch = this.buffered.splice(0);
383
+ try {
384
+ // 过滤已由 ensureLiveHandle 落库的 seed 前缀(上游 seq 空间,
385
+ // 与 public append 的 cursor 同空间)。直接走持久化原语(本函数
386
+ // 已在 chain 内,走 public append 会自锁)。
387
+ const fresh = batch.filter((event) => event.seq >= this.cursor);
388
+ if (fresh.length === 0) return;
389
+ for (const [index, event] of fresh.entries()) {
390
+ if (event.seq !== this.cursor + index) {
391
+ throw new Error(
392
+ `append seq mismatch for "${this.id}": expected ${this.cursor + index} at index ${index}, got ${event.seq}`,
393
+ );
394
+ }
395
+ }
396
+ await this.persistence.appendBatch(
397
+ this.header,
398
+ this.state.inheritedEventCount,
399
+ fresh,
400
+ this.tornTruncateTo,
401
+ );
402
+ this.tornTruncateTo = undefined;
403
+ this.cursor += fresh.length;
404
+ this.materialized = true;
405
+ } catch (error: unknown) {
406
+ this.buffered = batch.concat(this.buffered);
407
+ this.drainPaused = true;
408
+ throw error;
409
+ }
410
+ });
411
+ }
412
+ }
413
+
414
+ private enqueueChain(op: () => Promise<void>): Promise<void> {
415
+ const next = this.chain.then(op);
416
+ this.chain = next.catch(() => {});
417
+ return next;
418
+ }
419
+
420
+ private async run(operation: string, op: () => Promise<void>): Promise<void> {
421
+ this.assertOpen(operation);
422
+ return this.enqueueChain(async () => {
423
+ this.assertOpen(operation);
424
+ return op();
425
+ });
426
+ }
427
+
428
+ private assertOpen(operation: string): void {
429
+ if (this.closing !== undefined) throw new SessionHandleClosedError(this.id, operation);
430
+ }
431
+
432
+ static readonly LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
433
+ }
434
+
435
+ export class SessionPersistenceRdb extends SessionPersistence {
97
436
  static inject = ["sessions", "settings"];
98
437
 
99
438
  static Config: z<Config> = z.union([
@@ -114,37 +453,20 @@ export class SessionPersistenceRdb
114
453
 
115
454
  override readonly name = "session-rdb";
116
455
 
117
- override readonly supportsRawArtifacts = true;
118
-
119
- override async readRaw(
120
- id: SessionId,
121
- signal?: AbortSignal,
122
- ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionRawArtifact | undefined> {
123
- signal?.throwIfAborted();
124
- await this.ready;
125
- signal?.throwIfAborted();
126
- const log = await this.readLog(id, {}, signal);
127
- if (log === undefined) return undefined;
128
- repairSurfaceOps(log.events);
129
- recomputeReplaceProvenance(log.events);
130
- const inheritedEventCount = Math.min(log.inheritedEventCount, log.events.length);
131
- return {
132
- meta: log.meta,
133
- inheritedEventCount: SessionLogOffset(inheritedEventCount),
134
- filename: "session.jsonl",
135
- content: toJsonlArtifact(log.meta, inheritedEventCount, log.events),
136
- };
137
- }
456
+ readonly tracker = new RdbBackendTracker(this.name);
138
457
 
139
458
  private readonly backend: Backend;
140
459
  private storeIdentity!: string;
141
460
  private readonly ready: Promise<void>;
142
- private readonly coordinator: PersistenceCoordinator<number>;
143
461
 
144
462
  private readonly writeGuard = new WriteGuard();
145
463
 
146
464
  private readonly reuseEventIds = new Map<SessionId, Map<number, string>>();
147
465
 
466
+ /** live 路由:session/created 后 handle 就绪前的缓冲。 */
467
+ private readonly liveBuffers = new Map<SessionId, SessionEvent[]>();
468
+ private readonly liveReady = new Map<SessionId, Promise<void>>();
469
+
148
470
  constructor(
149
471
  ctx: Context,
150
472
  public config: Config,
@@ -172,7 +494,7 @@ export class SessionPersistenceRdb
172
494
  this.config = resolved;
173
495
  this.backend = injectedBackend ?? createBackend(resolved);
174
496
  this.ready = this.init();
175
- this.coordinator = new PersistenceCoordinator<number>(this.ctx, this);
497
+ this.installLiveRouting(ctx);
176
498
  // 分支 provider 服务(rewind / forkFrom / timeline),随 fiber 卸载自动回滚。
177
499
  new SessionBranchRdb(this.ctx);
178
500
  // 导入端点:webServer + connection 就绪后注册 `/api/session.import`。
@@ -184,114 +506,245 @@ export class SessionPersistenceRdb
184
506
  this.storeIdentity = this.backend.storeIdentity;
185
507
  }
186
508
 
187
- // --- SessionPersistence service surface (delegated to the coordinator) ---
509
+ // --- SessionPersistence service surface ---
188
510
 
189
- locate(_meta: SessionHeader): SessionLocation | undefined {
190
- return undefined;
191
- }
192
-
193
- create(meta: SessionHeader, inheritedEventCount?: number): Promise<void> {
194
- return this.coordinator.create(
195
- meta,
196
- inheritedEventCount === undefined ? undefined : SessionLogOffset(inheritedEventCount),
511
+ async create(
512
+ header: SessionHeader,
513
+ options?: SessionPersistenceCreateOptions,
514
+ ): Promise<SessionHandle> {
515
+ options?.signal?.throwIfAborted();
516
+ const snapshot = materializeCreateHeader(header);
517
+ if (snapshot.isSeeded && options?.inheritedEventCount === undefined) {
518
+ throw new TypeError("seeded session metadata requires an inherited event count");
519
+ }
520
+ const inheritedEventCount = SessionLogOffset(options?.inheritedEventCount ?? 0);
521
+ if (!snapshot.isSeeded && inheritedEventCount !== 0) {
522
+ throw new TypeError("unseeded session metadata inherited event count must be 0");
523
+ }
524
+ await this.ready;
525
+ options?.signal?.throwIfAborted();
526
+ if (
527
+ this.tracker.hasPending(snapshot.id) ||
528
+ (await this.backend.getSession(snapshot.id)) !== undefined
529
+ ) {
530
+ throw new SessionAlreadyExistsError(snapshot.id);
531
+ }
532
+ this.tracker.registerCreated(snapshot, inheritedEventCount);
533
+ return this.tracker.adopt(
534
+ new RdbSessionHandle(this, snapshot.id, snapshot, "write", {
535
+ cursor: 0,
536
+ materialized: false,
537
+ inheritedEventCount,
538
+ }),
197
539
  );
198
540
  }
199
541
 
200
- append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
201
- return this.coordinator.append(id, events);
202
- }
203
-
204
- load(id: SessionId): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
205
- return this.coordinator.load(id);
206
- }
207
-
208
- inspect(
542
+ async open(
209
543
  id: SessionId,
210
- signal?: AbortSignal,
211
- ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
212
- return this.coordinator.inspect(id, signal);
544
+ access: SessionAccess,
545
+ options?: SessionPersistenceOpenOptions,
546
+ ): Promise<SessionHandle> {
547
+ options?.signal?.throwIfAborted();
548
+ await this.ready;
549
+ options?.signal?.throwIfAborted();
550
+ const pending = this.tracker.pendingOf(id);
551
+ if (access === "read") {
552
+ if (pending !== undefined) {
553
+ return this.tracker.adopt(
554
+ new RdbSessionHandle(this, id, pending.header, "read", {
555
+ cursor: 0,
556
+ materialized: false,
557
+ inheritedEventCount: pending.inheritedEventCount,
558
+ }),
559
+ );
560
+ }
561
+ const log = await this.readLog(id, {}, options?.signal);
562
+ if (log === undefined) throw new SessionPersistenceNotFoundError(id);
563
+ // fail-closed:未知事件类型(非 ignorable)拒绝解释。
564
+ validateStoredEvents(log.meta, log.events);
565
+ return this.tracker.adopt(
566
+ new RdbSessionHandle(this, id, log.meta, "read", {
567
+ cursor: log.events.length,
568
+ materialized: true,
569
+ inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
570
+ }),
571
+ );
572
+ }
573
+ this.tracker.claimWrite(id);
574
+ try {
575
+ // pending(created 未 materialize)会话:write open 接管其所有权。
576
+ if (pending !== undefined) {
577
+ return this.tracker.adopt(
578
+ new RdbSessionHandle(this, id, pending.header, "write", {
579
+ cursor: pending.cursor,
580
+ materialized: false,
581
+ inheritedEventCount: pending.inheritedEventCount,
582
+ }),
583
+ );
584
+ }
585
+ const log = await this.readLog(id, {}, options?.signal);
586
+ if (log === undefined) throw new SessionPersistenceNotFoundError(id);
587
+ validateStoredEvents(log.meta, log.events);
588
+ // 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件坐标与
589
+ // 存储桥接行数不再相等——写打开时把迁移视图整体落库,使读写同坐标;
590
+ // 否则 append 按存储 head 重编号会撞上已有行或写坏 log。
591
+ if (log.migrated && log.events.length !== log.storedCount) {
592
+ await this.rewriteMigratedLog(id, log);
593
+ }
594
+ // 确认 head:本实例已读该会话,后续 append 的并发校验以此为基准。
595
+ this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
596
+ return this.tracker.adopt(
597
+ new RdbSessionHandle(this, id, log.meta, "write", {
598
+ cursor: log.events.length,
599
+ materialized: true,
600
+ inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
601
+ ...(log.tornFrom !== undefined ? { tornTruncateTo: log.tornFrom } : {}),
602
+ }),
603
+ );
604
+ } catch (error: unknown) {
605
+ this.tracker.releaseClaim(id);
606
+ throw error;
607
+ }
213
608
  }
214
609
 
215
- readFrom(
216
- id: SessionId,
217
- fromSeq: number,
218
- signal?: AbortSignal,
219
- ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionEventSuffix> {
220
- // 校验委托给 coordinator;这里只做品牌转换,避免 cordis proxy 下同步 throw 逃逸。
221
- return this.coordinator.readFrom(id, fromSeq as SessionLogOffset, signal);
610
+ flush(): Promise<void> {
611
+ return this.tracker.flushAll();
222
612
  }
223
613
 
224
- override borrowSession(
614
+ async stat(
225
615
  id: SessionId,
226
- signal?: AbortSignal,
227
- ): Promise<import("@deepseek-ai/dsh-session-persistence").BorrowedSessionSource> {
228
- return this.coordinator.borrowSession(id, signal);
616
+ options?: SessionPersistenceStatOptions,
617
+ ): Promise<SessionPersistenceSnapshot | undefined> {
618
+ options?.signal?.throwIfAborted();
619
+ await this.ready;
620
+ options?.signal?.throwIfAborted();
621
+ const pending = this.tracker.pendingOf(id);
622
+ if (pending !== undefined) {
623
+ return { header: pending.header, revision: pending.revision };
624
+ }
625
+ const row = await this.backend.getSession(id);
626
+ if (row === undefined) return undefined;
627
+ return {
628
+ header: rowToMeta(row),
629
+ revision: this.rowRevision(row),
630
+ };
229
631
  }
230
632
 
231
- // --- PersistenceBackend hooks (the storage primitives) ---
232
-
233
- loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
234
- return this.readPrefix(id, signal);
633
+ async list(
634
+ options?: SessionPersistenceListOptions,
635
+ ): Promise<readonly SessionPersistenceSnapshot[]> {
636
+ const signal = options?.signal;
637
+ const snapshots: SessionPersistenceSnapshot[] = [];
638
+ const listed = new Set<SessionId>();
639
+ for (const [id, pending] of this.tracker.pendingEntries()) {
640
+ snapshots.push({ header: pending.header, revision: pending.revision });
641
+ listed.add(id);
642
+ }
643
+ signal?.throwIfAborted();
644
+ await this.ready;
645
+ signal?.throwIfAborted();
646
+ const rows = await this.backend.listSessions();
647
+ signal?.throwIfAborted();
648
+ for (const row of rows) {
649
+ if (listed.has(row.fSessionId as SessionId)) continue;
650
+ snapshots.push({ header: rowToMeta(row), revision: this.rowRevision(row) });
651
+ }
652
+ return snapshots;
235
653
  }
236
654
 
237
- async loadStoredFrom(
655
+ // --- RDB 特有能力(rewind / fork / 导出 / 测试支撑) ---
656
+
657
+ /** 导出 artifact(jsonl v2 文本),视图只读不落库。 */
658
+ async readRaw(
238
659
  id: SessionId,
239
- fromSeq: number,
240
660
  signal?: AbortSignal,
241
- ): Promise<StoredSuffix | undefined> {
242
- const log = await this.readLog(id, { fromSeq }, signal);
661
+ ): Promise<
662
+ | { meta: SessionHeader; inheritedEventCount: number; filename: string; content: string }
663
+ | undefined
664
+ > {
665
+ signal?.throwIfAborted();
666
+ await this.ready;
667
+ signal?.throwIfAborted();
668
+ const log = await this.readLog(id, {}, signal);
243
669
  if (log === undefined) return undefined;
670
+ repairReadView(log.events);
671
+ const inheritedEventCount = Math.min(log.inheritedEventCount, log.events.length);
244
672
  return {
245
673
  meta: log.meta,
246
- inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
247
- events: log.events,
674
+ inheritedEventCount,
675
+ filename: "session.jsonl",
676
+ content: toJsonlArtifact(log.meta, inheritedEventCount, log.events),
248
677
  };
249
678
  }
250
679
 
251
- private async readPrefix(
252
- id: SessionId,
253
- signal?: AbortSignal,
254
- ): Promise<StoredPrefix<number> | undefined> {
255
- const log = await this.readLog(id, {}, signal);
256
- if (log === undefined) {
257
- // 已确认缺席:本实例读过的新会话,后续对已出现行的 append 必须拒绝。
258
- this.writeGuard.confirmHead(id, -1);
259
- return undefined;
260
- }
261
- // 确认 head 是最后一个保留 seq(torn tail 由 commitRepair 删除并重确认)。
262
- this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
263
- // replace 的 provenance 读取时重计算(sourceEventSeqs 不落库)。
264
- recomputeReplaceProvenance(log.events);
265
- // 孤儿 inbox splice(引用已被截断排队消息的插入/消费)会使上游 Inbox
266
- // 增量重放失败、整个会话不可 resume——读取时改写为 no-op 修复。
267
- repairOrphanInboxSplices(log.events);
268
- return {
269
- meta: log.meta,
270
- inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
271
- events: log.events,
272
- // revision 表示必须与 readStoredRevision 的表示一致(见 listSnapshots)。
273
- revision: SessionPersistenceRevision(
274
- `${this.storeIdentity}:incarnation:${log.incarnation}:revision:${log.revision}`,
275
- ),
276
- ...(log.tornFrom !== undefined ? { tornMarker: log.tornFrom } : {}),
277
- };
680
+ /** 空会话 materialize(flush 的持久化屏障)。 */
681
+ async materializeEmpty(
682
+ meta: SessionHeader,
683
+ inheritedEventCount: SessionLogOffset,
684
+ ): Promise<void> {
685
+ await this.ready;
686
+ await this.backend.transaction(async (tx) => {
687
+ await tx.upsertSession({ meta, inheritedEventCount }, randomUUID());
688
+ await tx.bumpRevision(meta.id);
689
+ });
690
+ this.tracker.materialized(meta.id);
691
+ this.writeGuard.confirmHead(meta.id, -1);
278
692
  }
279
693
 
280
- async readStoredRevision(
281
- id: SessionId,
282
- signal?: AbortSignal,
283
- ): Promise<SessionPersistenceRevision | undefined> {
284
- signal?.throwIfAborted();
694
+ /** 原样 append 落库(handle 已校验 contiguity;torn tail 先截断)。
695
+ * 与上游 JSONL 一致:ignorable 事件原样存储,不做过滤。写路径校验 v2
696
+ * 形状(fail-closed):未知类型(非 ignorable)与非法消息形状拒绝入库;
697
+ * 旧格式(v0/v1)数据只在读取时经 legacy 转换链动态转换,不落新库。 */
698
+ async appendBatch(
699
+ meta: SessionHeader,
700
+ inheritedEventCount: SessionLogOffset,
701
+ events: readonly SessionEvent[],
702
+ tornTruncateTo?: number,
703
+ ): Promise<boolean> {
285
704
  await this.ready;
286
- signal?.throwIfAborted();
287
- const row = await this.backend.getSession(id);
288
- if (row === undefined) return undefined;
289
- return SessionPersistenceRevision(
290
- `${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
291
- );
705
+ if (events.length === 0) return false;
706
+ // 写路径 v2 校验:与读路径同契约(validateStoredEvents),保证新入库
707
+ // 数据只能是 v2 形状。拷贝避免 adopt 替换污染调用方数组。
708
+ validateStoredEvents(meta, [...events]);
709
+ // fork 派生会话的 seed 复用源会话事件行(不复制);消费后清除。
710
+ const reuse = this.reuseEventIds.get(meta.id);
711
+ if (reuse !== undefined) this.reuseEventIds.delete(meta.id);
712
+ let confirmedHead = -1;
713
+ await this.backend.transaction(async (tx) => {
714
+ if (tornTruncateTo !== undefined) {
715
+ await tx.deleteBridgeTail(meta.id, tornTruncateTo);
716
+ const prev = await tx.getPrevBridge(meta.id, tornTruncateTo - 1);
717
+ if (prev === undefined) {
718
+ await tx.updateHead(meta.id, "", -1);
719
+ } else {
720
+ await tx.updateHead(meta.id, prev.fEventId, prev.fSequence);
721
+ }
722
+ }
723
+ await tx.upsertSession({ meta, inheritedEventCount }, randomUUID());
724
+ const head = await tx.getHead(meta.id);
725
+ // 重编号前拒绝第二个写入者:多实例共享数据库时,第二个写入者经陈旧
726
+ // 视图 append 会把事件静默重编号到对方尾部、损坏 log。磁盘 head 必须
727
+ // 等于本实例确认过的最后一个 head。
728
+ this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
729
+ const { headEventId, headSequence } = await appendEventTail(
730
+ tx,
731
+ meta,
732
+ events,
733
+ { parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
734
+ reuse,
735
+ );
736
+ await tx.updateHead(meta.id, headEventId, headSequence);
737
+ await tx.bumpRevision(meta.id);
738
+ confirmedHead = headSequence;
739
+ });
740
+ // 提交后才确认新 head:回滚不得留下本实例实际未写的已确认 head。
741
+ this.writeGuard.confirmHead(meta.id, confirmedHead);
742
+ this.tracker.materialized(meta.id);
743
+ return true;
292
744
  }
293
745
 
294
- private async readLog(
746
+ /** 读取一个会话的稠密 log(含 torn tail 检测,不含修复改写)。 */
747
+ async readLog(
295
748
  id: SessionId,
296
749
  options: { fromSeq?: number } = {},
297
750
  signal?: AbortSignal,
@@ -306,6 +759,12 @@ export class SessionPersistenceRdb
306
759
  incarnation: string;
307
760
 
308
761
  revision: number;
762
+
763
+ /** 存储桥接行数(迁移链可能生成/合并事件,与 `events.length` 不同)。 */
764
+ storedCount: number;
765
+
766
+ /** 是否经上游迁移链转换(v0/v1 → 当前格式)。 */
767
+ migrated: boolean;
309
768
  }
310
769
  | undefined
311
770
  > {
@@ -315,126 +774,205 @@ export class SessionPersistenceRdb
315
774
  const row = await this.backend.getSession(id);
316
775
  if (row === undefined) return undefined;
317
776
  const meta = rowToMeta(row);
318
- let eventRows: EventRow[];
319
- let seqMap: ReadonlyMap<number, number>;
320
- if (options.fromSeq === undefined) {
321
- // 全量读:seq map 由同一批行构建(无额外查询)。
322
- eventRows = await this.backend.getEventRows(id);
323
- seqMap = buildSeqMap(eventRows);
324
- } else {
325
- // 后缀读:坐标重映射需要每一行的上游 seq,另读轻量两列映射。
326
- eventRows = await this.backend.getEventRows(id, options.fromSeq);
327
- const seqRows = await this.backend.getSeqMapRows(id);
328
- seqMap = buildSeqMap(seqRows);
329
- }
777
+ const eventRows =
778
+ options.fromSeq === undefined
779
+ ? await this.backend.getEventRows(id)
780
+ : await this.backend.getEventRows(id, options.fromSeq);
330
781
  signal?.throwIfAborted();
331
- const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0, seqMap);
782
+ // 旧格式(v0/v1)历史数据:行重建为物理记录,经上游迁移链转 v2 逻辑事件。
783
+ // 迁移链自带 seq gap / torn tail 校验(strict recovery),无需 scanRows。
784
+ // 混合世代 log(旧写入器跨上游版本追加)不是任何单一已发布格式,迁移链
785
+ // 必然拒绝——回退为当前格式视图(header 版本归一 + 读取视图修复)。
786
+ if (isLegacyVersion(row.fVersion)) {
787
+ try {
788
+ const converted = convertLegacyRows(row, eventRows);
789
+ return {
790
+ meta: converted.meta,
791
+ inheritedEventCount: converted.inheritedEventCount,
792
+ events: converted.events,
793
+ incarnation: row.fIncarnation,
794
+ revision: row.fRevision,
795
+ storedCount: eventRows.length,
796
+ migrated: true,
797
+ };
798
+ } catch (error: unknown) {
799
+ this.ctx.logger.warn(
800
+ `session-rdb: session "${id}" is not a single released format; adopting its stored rows as current-format data (${error instanceof Error ? error.message : String(error)})`,
801
+ );
802
+ const adopted = adoptLegacyRows(row, eventRows);
803
+ return {
804
+ meta: adopted.meta,
805
+ inheritedEventCount: adopted.inheritedEventCount,
806
+ events: adopted.events,
807
+ incarnation: row.fIncarnation,
808
+ revision: row.fRevision,
809
+ storedCount: eventRows.length,
810
+ migrated: false,
811
+ ...(adopted.tornFrom !== undefined ? { tornFrom: adopted.tornFrom } : {}),
812
+ };
813
+ }
814
+ }
815
+ const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
332
816
  return {
333
817
  meta,
334
818
  inheritedEventCount: row.fSeedLength ?? 0,
335
819
  events: preserved,
336
820
  incarnation: row.fIncarnation,
337
821
  revision: row.fRevision,
822
+ storedCount: eventRows.length,
823
+ migrated: false,
338
824
  ...(tornFrom !== undefined ? { tornFrom } : {}),
339
825
  };
340
826
  }
341
827
 
342
- async appendBatch(
343
- storage: SessionStorageMetadata,
344
- events: readonly SessionEvent[],
345
- _isMaterialized: boolean,
828
+ /**
829
+ * 把迁移链读出的 v2 视图整体落库(旧格式会话写打开时的一次性迁移)。
830
+ *
831
+ * 迁移链会生成/合并事件(end-seed / attempt / chunk 合并),事件 seq 空间
832
+ * 与存储桥接行数不再相等;写路径以存储 head 为锚点重编号,二者不一致会让
833
+ * append 撞上已有行。这里在同一事务内删光本会话桥接行、按迁移视图重建
834
+ * (新事件行,完整信封),并更新 head 与 revision;旧事件行保留(可能被
835
+ * fork 子会话引用,孤儿由惰性 GC 处理)。
836
+ */
837
+ private async rewriteMigratedLog(
838
+ id: SessionId,
839
+ log: { meta: SessionHeader; inheritedEventCount: number; events: SessionEvent[] },
346
840
  ): Promise<void> {
347
- await this.ready;
348
- const persisted = events.filter(isPersistedEvent);
349
- if (persisted.length === 0) return;
350
- const meta = storage.meta;
351
- // fork 派生会话的 seed 复用源会话事件行(不复制);消费后清除。
352
- const reuse = this.reuseEventIds.get(meta.id);
353
- if (reuse !== undefined) this.reuseEventIds.delete(meta.id);
354
- let confirmedHead = -1;
355
841
  await this.backend.transaction(async (tx) => {
356
- await tx.upsertSession(storage, randomUUID());
357
- const head = await tx.getHead(meta.id);
358
- // 重编号前拒绝第二个写入者:多实例共享数据库时,第二个写入者经陈旧
359
- // 视图 append 会把事件静默重编号到对方尾部、损坏 log。磁盘 head 必须
360
- // 等于本实例确认过的最后一个 head。
361
- this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
362
- const { headEventId, headSequence } = await appendEventTail(
363
- tx,
364
- meta,
365
- persisted,
366
- { parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
367
- reuse,
842
+ await tx.deleteBridgeTail(id, 0);
843
+ await tx.upsertSession(
844
+ { meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
845
+ randomUUID(),
368
846
  );
369
- await tx.updateHead(meta.id, headEventId, headSequence);
370
- await tx.bumpRevision(meta.id);
371
- confirmedHead = headSequence;
372
- });
373
- // 提交后才确认新 head:回滚不得留下本实例实际未写的已确认 head。
374
- this.writeGuard.confirmHead(meta.id, confirmedHead);
375
- }
376
-
377
- async commitRepair(
378
- storage: SessionStorageMetadata,
379
- tornMarker: number | undefined,
380
- closers: readonly SessionEvent[],
381
- ): Promise<void> {
382
- await this.ready;
383
- const meta = storage.meta;
384
- const persistedClosers = closers.filter(isPersistedEvent);
385
- if (tornMarker === undefined && persistedClosers.length === 0) return;
386
- await this.backend.transaction(async (tx) => {
387
- if (tornMarker !== undefined) {
388
- await tx.deleteBridgeTail(meta.id, tornMarker);
389
- // head 游标回退到最后一个幸存事件(torn tail 从 seq 0 开始时为初始态)。
390
- const prev = await tx.getPrevBridge(meta.id, tornMarker - 1);
391
- if (prev === undefined) {
392
- await tx.updateHead(meta.id, "", -1);
393
- } else {
394
- await tx.updateHead(meta.id, prev.fEventId, prev.fSequence);
395
- }
396
- }
397
- if (persistedClosers.length > 0) {
398
- // 锚定实际尾行:head 游标可能滞后于行(手工 torn tail 不更新游标)。
399
- const last = await tx.getLastBridge(meta.id);
400
- const { headEventId, headSequence } = await appendEventTail(tx, meta, persistedClosers, {
401
- parentId: last?.fEventId ?? "",
402
- nextSeq: (last?.fSequence ?? -1) + 1,
403
- });
404
- await tx.updateHead(meta.id, headEventId, headSequence);
405
- }
406
- await tx.bumpRevision(meta.id);
847
+ const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
848
+ parentId: "",
849
+ nextSeq: 0,
850
+ });
851
+ await tx.updateHead(id, headEventId, headSequence);
852
+ await tx.bumpRevision(id);
407
853
  });
408
- // 修复后重确认 head:截断会回退它、closers 会推进它,下一次 append 不得
409
- // 基于陈旧确认被拒绝(或更糟,被静默重编号)。
410
- const row = await this.backend.getSession(meta.id);
411
- this.writeGuard.confirmHead(meta.id, row?.fHeadSequence ?? -1);
412
854
  }
413
855
 
414
- async list(signal?: AbortSignal): Promise<SessionHeader[]> {
856
+ async listSnapshots(
857
+ signal?: AbortSignal,
858
+ ): Promise<Array<SessionPersistenceSnapshot & { inheritedEventCount: number }>> {
415
859
  signal?.throwIfAborted();
416
860
  await this.ready;
417
861
  signal?.throwIfAborted();
862
+ const snapshots: Array<SessionPersistenceSnapshot & { inheritedEventCount: number }> = [];
863
+ const listed = new Set<SessionId>();
864
+ for (const [id, pending] of this.tracker.pendingEntries()) {
865
+ snapshots.push({
866
+ header: pending.header,
867
+ revision: pending.revision,
868
+ inheritedEventCount: pending.inheritedEventCount,
869
+ });
870
+ listed.add(id);
871
+ }
418
872
  const rows = await this.backend.listSessions();
419
873
  signal?.throwIfAborted();
420
- return rows.map(rowToMeta);
874
+ for (const row of rows) {
875
+ if (listed.has(row.fSessionId as SessionId)) continue;
876
+ snapshots.push({
877
+ header: rowToMeta(row),
878
+ revision: this.rowRevision(row),
879
+ inheritedEventCount: row.fSeedLength ?? 0,
880
+ });
881
+ }
882
+ return snapshots;
421
883
  }
422
884
 
423
- async listSnapshots(
885
+ async readStoredRevision(
886
+ id: SessionId,
424
887
  signal?: AbortSignal,
425
- ): Promise<Array<SessionPersistenceSnapshot & { inheritedEventCount: number }>> {
888
+ ): Promise<SessionPersistenceRevision | undefined> {
426
889
  signal?.throwIfAborted();
427
890
  await this.ready;
428
891
  signal?.throwIfAborted();
429
- const rows = await this.backend.listSessions();
430
- signal?.throwIfAborted();
431
- return rows.map((row) => ({
432
- header: rowToMeta(row),
433
- revision: SessionPersistenceRevision(
434
- `${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
435
- ),
436
- inheritedEventCount: row.fSeedLength ?? 0,
437
- }));
892
+ const row = await this.backend.getSession(id);
893
+ if (row === undefined) return undefined;
894
+ return this.rowRevision(row);
895
+ }
896
+
897
+ /** 便捷:create + append + close(测试与导入路径共用)。 */
898
+ async createAndAppend(
899
+ header: SessionHeader,
900
+ events: readonly SessionEvent[],
901
+ inheritedEventCount?: number,
902
+ ): Promise<void> {
903
+ const handle = await this.create(
904
+ header,
905
+ inheritedEventCount === undefined
906
+ ? undefined
907
+ : { inheritedEventCount: SessionLogOffset(inheritedEventCount) },
908
+ );
909
+ try {
910
+ if (events.length > 0) await handle.append(events);
911
+ } finally {
912
+ await handle.close();
913
+ }
914
+ }
915
+
916
+ /** 便捷:open(read) + read 全量 + close。 */
917
+ async load(
918
+ id: SessionId,
919
+ signal?: AbortSignal,
920
+ ): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
921
+ const handle = await this.open(id, "read", signal === undefined ? undefined : { signal });
922
+ try {
923
+ const { events } = await handle.read(
924
+ 0,
925
+ undefined,
926
+ signal === undefined ? undefined : { signal },
927
+ );
928
+ const row = await this.backend.getSession(id);
929
+ if (row === undefined) throw new SessionPersistenceNotFoundError(id);
930
+ // 旧格式(v0/v1)会话:meta 与继承前缀来自转换链(handle.header 已是
931
+ // 转换后的 v2 header);v2 会话用存储行。
932
+ if (isLegacyVersion(row.fVersion)) {
933
+ return {
934
+ meta: handle.header,
935
+ inheritedEventCount: handle.inheritedEventCount,
936
+ events,
937
+ };
938
+ }
939
+ return {
940
+ meta: rowToMeta(row),
941
+ inheritedEventCount: SessionLogOffset(row.fSeedLength ?? 0),
942
+ events,
943
+ };
944
+ } finally {
945
+ await handle.close();
946
+ }
947
+ }
948
+
949
+ /** 便捷:open(write) + append + close。 */
950
+ async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
951
+ const handle = await this.open(id, "write");
952
+ try {
953
+ await handle.append(events);
954
+ } finally {
955
+ await handle.close();
956
+ }
957
+ }
958
+
959
+ /** 便捷:readFrom(稠密后缀)。 */
960
+ async readFrom(
961
+ id: SessionId,
962
+ fromSeq: number,
963
+ signal?: AbortSignal,
964
+ ): Promise<{
965
+ meta: SessionHeader;
966
+ inheritedEventCount: number;
967
+ events: readonly SessionEvent[];
968
+ }> {
969
+ const log = await this.readLog(id, { fromSeq }, signal);
970
+ if (log === undefined) throw new SessionPersistenceNotFoundError(id);
971
+ return {
972
+ meta: log.meta,
973
+ inheritedEventCount: log.inheritedEventCount,
974
+ events: log.events,
975
+ };
438
976
  }
439
977
 
440
978
  async close(): Promise<void> {
@@ -450,16 +988,173 @@ export class SessionPersistenceRdb
450
988
  return {
451
989
  backend: this.backend,
452
990
  writeGuard: this.writeGuard,
453
- create: (meta, inheritedEventCount) => this.create(meta, inheritedEventCount),
991
+ create: (meta, inheritedEventCount) =>
992
+ this.create(
993
+ meta,
994
+ inheritedEventCount === undefined
995
+ ? undefined
996
+ : { inheritedEventCount: SessionLogOffset(inheritedEventCount) },
997
+ ),
454
998
  append: (id, events) => this.append(id, events),
455
999
  load: (id) => this.load(id),
456
- inspect: (id, signal) => this.inspect(id, signal),
1000
+ inspect: (id, signal) => this.load(id, signal),
457
1001
  readFrom: (id, fromSeq, signal) => this.readFrom(id, fromSeq, signal),
458
1002
  listSnapshots: (signal) => this.listSnapshots(signal),
459
1003
  readStoredRevision: (id, signal) => this.readStoredRevision(id, signal),
460
1004
  registerReuseEventIds: (childId, map) => this.registerReuseEventIds(childId, map),
461
1005
  };
462
1006
  }
1007
+
1008
+ private rowRevision(row: import("./backend.ts").SessionRow): SessionPersistenceRevision {
1009
+ return SessionPersistenceRevision(
1010
+ `${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
1011
+ );
1012
+ }
1013
+
1014
+ // --- live 路由:session/created → create/adopt handle;event → 缓冲;flush → drain ---
1015
+
1016
+ private installLiveRouting(ctx: Context): void {
1017
+ ctx.on("session/created", (session: Session) => {
1018
+ this.liveBuffers.set(session.id, []);
1019
+ const ready = this.ensureLiveHandle(session);
1020
+ this.liveReady.set(session.id, ready);
1021
+ void ready.catch((error: unknown) => {
1022
+ ctx.logger.warn(
1023
+ `session-rdb: live session "${session.id}" persistence init failed: ${String(error)}`,
1024
+ );
1025
+ });
1026
+ });
1027
+ // HMR:插件 apply 时已存在的 live 会话不重放 session/created——补种。
1028
+ for (const session of ctx.sessions.list()) {
1029
+ this.liveBuffers.set(session.id, []);
1030
+ const ready = this.ensureLiveHandle(session);
1031
+ this.liveReady.set(session.id, ready);
1032
+ void ready.catch((error: unknown) => {
1033
+ ctx.logger.warn(
1034
+ `session-rdb: live session "${session.id}" persistence init failed: ${String(error)}`,
1035
+ );
1036
+ });
1037
+ }
1038
+ ctx.on("session/event", (session: Session, event: SessionEvent) => {
1039
+ const handle = this.tracker.writerOf(session.id);
1040
+ if (handle !== undefined) {
1041
+ handle.enqueueLive(event, (error) => {
1042
+ ctx.logger.warn(
1043
+ `session-rdb: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`,
1044
+ );
1045
+ });
1046
+ return;
1047
+ }
1048
+ this.liveBuffers.get(session.id)?.push(structuredClone(event));
1049
+ });
1050
+ ctx.on("session/flush", (session: Session) => {
1051
+ const handle = this.tracker.writerOf(session.id);
1052
+ if (handle === undefined) {
1053
+ const ready = this.liveReady.get(session.id);
1054
+ if (ready === undefined) return undefined;
1055
+ return ready.then(() => {
1056
+ const settled = this.tracker.writerOf(session.id);
1057
+ if (settled === undefined) return undefined;
1058
+ return settled.drainLive().then(() => settled.flush());
1059
+ });
1060
+ }
1061
+ return handle.drainLive().then(() => handle.flush());
1062
+ });
1063
+ ctx.on("session/disposed", (session: Session) => {
1064
+ const ready = this.liveReady.get(session.id);
1065
+ this.liveBuffers.delete(session.id);
1066
+ this.liveReady.delete(session.id);
1067
+ const closeHandle = (): void => {
1068
+ const handle = this.tracker.writerOf(session.id);
1069
+ if (handle === undefined) return;
1070
+ handle.close().catch((error: unknown) => {
1071
+ ctx.logger.warn(
1072
+ `session-rdb: final drain for session "${session.id}" failed: ${String(error)}`,
1073
+ );
1074
+ });
1075
+ };
1076
+ if (ready === undefined) {
1077
+ closeHandle();
1078
+ return;
1079
+ }
1080
+ // handle 可能仍在构造(ensureLiveHandle 异步):等就绪后再 close。
1081
+ void ready.then(closeHandle, closeHandle);
1082
+ });
1083
+ ctx.effect(
1084
+ () => async () => {
1085
+ // 先等所有 live handle 就绪(ensureLiveHandle 异步),再统一关闭。
1086
+ await Promise.allSettled(this.liveReady.values());
1087
+ await this.tracker.closeAll();
1088
+ // 等 init(异步 open)settle 后关闭后端连接:dispose 返回后调用方
1089
+ // 可能立即释放存储(pg 测试 drop 数据库),未完成的 open 会以
1090
+ // 无人处理的 rejection 泄漏。
1091
+ await this.close();
1092
+ },
1093
+ `${this.name} open handles`,
1094
+ );
1095
+ }
1096
+
1097
+ /** session/created 后为 live 会话建立 write handle(create 或 adopt)。 */
1098
+ private async ensureLiveHandle(session: Session): Promise<void> {
1099
+ const id = session.header.id;
1100
+ if (this.tracker.writerOf(id) !== undefined) return;
1101
+ await this.ready;
1102
+ const stored = await this.readLog(id, {});
1103
+ let handle: RdbSessionHandle;
1104
+ if (stored === undefined) {
1105
+ // 新会话:注册 pending 并返回 write handle;构造 seed 事件不发布
1106
+ // session/event,须在此一次性落库(与旧版 onCreated 同语义)。
1107
+ handle = (await this.create(session.header, {
1108
+ inheritedEventCount: session.inheritedEventCount,
1109
+ })) as RdbSessionHandle;
1110
+ const seed = session.snapshotEvents();
1111
+ if (seed.length > 0) await handle.append(seed);
1112
+ } else {
1113
+ // adopt:校验 cwd / inheritedEventCount / seed 前缀匹配后接管写所有权。
1114
+ if (stored.meta.cwd !== session.header.cwd) {
1115
+ throw new Error(
1116
+ `session "${id}" is already persisted at a different cwd (persisted: ${String(stored.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`,
1117
+ );
1118
+ }
1119
+ if (stored.inheritedEventCount !== session.inheritedEventCount) {
1120
+ throw new Error(
1121
+ `session "${id}" is already persisted with a different inherited event count (id collision)`,
1122
+ );
1123
+ }
1124
+ assertVersion(stored.meta);
1125
+ // adopt 比较必须与读取视图同源:live seed 来自修复后的读取视图(补
1126
+ // stream、surface 修复),未修复的存储视图会把修复差异误判为 id 冲突。
1127
+ repairReadView(stored.events);
1128
+ const seed = session.snapshotEvents();
1129
+ if (!seedCoversPrefix(seed, stored.events)) {
1130
+ throw new Error(
1131
+ `session "${id}" already has a persisted log on disk that does not match this live session (id collision)`,
1132
+ );
1133
+ }
1134
+ handle = (await this.open(id, "write")) as RdbSessionHandle;
1135
+ // 持久化 seed 后缀(构造 seed 事件不发布 session/event,缓冲看不到)。
1136
+ const suffix = seed.slice(stored.events.length);
1137
+ if (suffix.length > 0) await handle.append(suffix);
1138
+ }
1139
+ // 把 handle 就绪前缓冲的事件移交。
1140
+ const buffered = this.liveBuffers.get(id);
1141
+ if (buffered !== undefined && buffered.length > 0) {
1142
+ this.liveBuffers.set(id, []);
1143
+ for (const event of buffered) {
1144
+ handle.enqueueLive(event, () => {});
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
1151
+ return (
1152
+ prefix.length <= seed.length &&
1153
+ prefix.every((event, index) => {
1154
+ const seedEvent = seed[index];
1155
+ return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event);
1156
+ })
1157
+ );
463
1158
  }
464
1159
 
465
1160
  function createBackend(config: Config): Backend {
@@ -504,7 +1199,6 @@ async function appendEventTail(
504
1199
  fSessionId: SessionId;
505
1200
  fEventId: string;
506
1201
  fSequence: number;
507
- fOriginalSeq: number;
508
1202
  fSurfaceOp: string | null;
509
1203
  }> = [];
510
1204
  for (const event of events) {
@@ -512,6 +1206,15 @@ async function appendEventTail(
512
1206
  const eventId = reusedId ?? randomUUID();
513
1207
  if (reusedId === undefined) {
514
1208
  const { kind, role, name, actionId } = eventDimensions(event);
1209
+ // fData 存完整事件(含 ignorable 信封,与 JSONL 每行同构):data 部分
1210
+ // 与信封字段在同一 JSON 记录里,读回时整体解析。surfaceOp 走桥接行
1211
+ // 列(f_surface_op),sourceEventSeqs 不落库(读取时重计算)。
1212
+ const raw = event as SessionEvent & {
1213
+ ignorable?: unknown;
1214
+ surfaceOp?: unknown;
1215
+ sourceEventSeqs?: unknown;
1216
+ };
1217
+ const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = raw;
515
1218
  eventRows.push({
516
1219
  fEventId: eventId,
517
1220
  fParentId: parentId,
@@ -521,7 +1224,7 @@ async function appendEventTail(
521
1224
  fName: name,
522
1225
  fActionId: actionId,
523
1226
  fEncoding: EVENT_ENCODING,
524
- fData: JSON.stringify(event.data),
1227
+ fData: JSON.stringify({ ...envelope, data }),
525
1228
  fCreatedAt: event.time,
526
1229
  });
527
1230
  }
@@ -533,7 +1236,6 @@ async function appendEventTail(
533
1236
  fSessionId: meta.id,
534
1237
  fEventId: eventId,
535
1238
  fSequence: nextSeq,
536
- fOriginalSeq: event.seq,
537
1239
  fSurfaceOp: surfaceOp,
538
1240
  });
539
1241
  parentId = eventId;