@morlay/session-rdb 0.1.10-alpha.0 → 0.1.10-alpha.3

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 (142) hide show
  1. package/README.md +18 -3
  2. package/dist/artifact.d.mts +118 -2
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
  5. package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
  6. package/dist/branch-CG1PUlO6.mjs +833 -0
  7. package/dist/deletion-SSX7IqU8.mjs +35 -0
  8. package/dist/deletion.d.mts +1 -1
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -1
  12. package/dist/export.mjs +31 -55
  13. package/dist/import.d.mts +1 -1
  14. package/dist/import.mjs +31 -76
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1561 -4
  18. package/dist/open-YoyD_kah.mjs +561 -0
  19. package/dist/organize.d.mts +9 -0
  20. package/dist/organize.mjs +30 -0
  21. package/dist/route-DpfY0zq-.mjs +114 -0
  22. package/dist/storage.d.mts +41 -48
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -1
  25. package/dist/testing.mjs +25 -4
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +7 -40
  29. package/dist/write-guard-CYrY58GM.mjs +174 -0
  30. package/package.json +32 -36
  31. package/src/artifact.ts +4 -1
  32. package/src/backend-rdb.ts +331 -0
  33. package/src/backend.ts +32 -15
  34. package/src/{branch.ts → branch/index.ts} +109 -110
  35. package/src/branch/port.ts +62 -0
  36. package/src/branch/semantics.ts +69 -0
  37. package/src/db/open.ts +180 -0
  38. package/src/db/stats.ts +449 -0
  39. package/src/deletion-error.ts +17 -0
  40. package/src/deletion.ts +19 -72
  41. package/src/entities/index.ts +16 -1
  42. package/src/entities/v3/event-usage.ts +3 -3
  43. package/src/entities/v3/events.ts +3 -3
  44. package/src/entities/v3/index.ts +4 -16
  45. package/src/entities/v3/persistence-state.ts +3 -3
  46. package/src/entities/v3/schema-meta.ts +3 -3
  47. package/src/entities/v3/session-counts.ts +3 -3
  48. package/src/entities/v3/session-events.ts +3 -3
  49. package/src/entities/v3/session-projcache-rows.ts +3 -3
  50. package/src/entities/v3/session-usage.ts +3 -3
  51. package/src/entities/v3/sessions.ts +3 -3
  52. package/src/entities/v3/storage-records.ts +17 -0
  53. package/src/entities/v3/storage-units.ts +3 -3
  54. package/src/{schema.ts → event-shape.ts} +0 -46
  55. package/src/export.ts +32 -78
  56. package/src/handles.ts +349 -0
  57. package/src/http/route.ts +178 -0
  58. package/src/import-storages.ts +5 -2
  59. package/src/import.ts +63 -125
  60. package/src/index.ts +15 -1666
  61. package/src/legacy.ts +3 -1
  62. package/src/live-routing.ts +205 -0
  63. package/src/log/export.ts +84 -0
  64. package/src/{log.ts → log/repair.ts} +1 -268
  65. package/src/log/rows.ts +107 -0
  66. package/src/log/usage-row.ts +64 -0
  67. package/src/migrations.ts +196 -0
  68. package/src/organize.ts +38 -0
  69. package/src/read-path.ts +190 -0
  70. package/src/routes.ts +2 -1
  71. package/src/rows.ts +25 -77
  72. package/src/session-ops.ts +444 -0
  73. package/src/session-store.ts +675 -0
  74. package/src/stats-fold.ts +44 -0
  75. package/src/storage-takeover/index.ts +2 -5
  76. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  77. package/src/storage-takeover/projection-cache.ts +122 -71
  78. package/src/storage-takeover/read-model.ts +189 -0
  79. package/src/storage-takeover/repository.ts +236 -442
  80. package/src/storage-takeover/storage-backend.ts +44 -159
  81. package/src/storage-takeover/types.ts +45 -19
  82. package/src/storage-takeover/workspace-documents.ts +65 -0
  83. package/src/storage.ts +4 -2
  84. package/src/testing.ts +21 -1
  85. package/src/usage.ts +11 -65
  86. package/src/version.ts +13 -0
  87. package/src/write-path.ts +286 -0
  88. package/dist/branch-CoPCRKer.mjs +0 -401
  89. package/dist/deletion-CWl89TIR.mjs +0 -2249
  90. package/dist/gc.d.mts +0 -4
  91. package/dist/gc.mjs +0 -48
  92. package/dist/log-CcWy46VE.mjs +0 -558
  93. package/dist/routes-hZjTcX_R.mjs +0 -9
  94. package/dist/sqlite-BNRd1aqR.mjs +0 -1778
  95. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  96. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  97. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  99. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  101. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  103. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  104. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  105. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  106. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  107. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  108. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  109. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  110. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  111. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  112. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  114. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  116. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  118. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  120. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  122. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  123. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  124. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  125. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  126. package/src/adapters/index.ts +0 -12
  127. package/src/adapters/to-postgres.ts +0 -93
  128. package/src/adapters/to-sqlite.ts +0 -83
  129. package/src/adapters/types.ts +0 -44
  130. package/src/drizzle/postgres-v2.ts +0 -10
  131. package/src/drizzle/postgres-v3.ts +0 -18
  132. package/src/drizzle/sqlite-v2.ts +0 -9
  133. package/src/drizzle/sqlite-v3.ts +0 -18
  134. package/src/entities/v2/index.ts +0 -20
  135. package/src/entities/v2/session-events.ts +0 -10
  136. package/src/entities/v3/schedule-tasks.ts +0 -16
  137. package/src/entities/v3/workspace-sessions.ts +0 -19
  138. package/src/entities/v3/workspace-state.ts +0 -12
  139. package/src/entities/v3/workspaces.ts +0 -14
  140. package/src/gc.ts +0 -77
  141. package/src/postgres.ts +0 -1015
  142. package/src/sqlite.ts +0 -1096
@@ -17,77 +17,18 @@ import {
17
17
  type ForkFromOptions,
18
18
  type SessionBranchProvider,
19
19
  } from "@morlay/session-branch";
20
- import { randomUUID } from "node:crypto";
21
- import type { Backend } from "./backend.ts";
22
- import type { SessionPersistenceRdb } from "./index.ts";
23
- import { isLegacyVersion } from "./legacy.ts";
24
- import { rowToMeta } from "./log.ts";
25
-
26
- function assertRewindBoundary(
27
- id: SessionId,
28
- toBoundary: number,
29
- boundaryType: string | undefined,
30
- head: number,
31
- ): void {
32
- if (toBoundary === -1) return;
33
- if (toBoundary > head) {
34
- throw new SessionBranchError(
35
- `rewind boundary ${toBoundary} is beyond the stored head ${head}`,
36
- "INVALID_BOUNDARY",
37
- );
38
- }
39
- if (boundaryType === undefined) {
40
- throw new SessionBranchError(
41
- `rewind boundary ${toBoundary} does not exist in session "${id}"`,
42
- "INVALID_BOUNDARY",
43
- );
44
- }
45
- if (boundaryType !== "turn/end" && boundaryType !== "user/message") {
46
- throw new SessionBranchError(
47
- `rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryType})`,
48
- "INVALID_BOUNDARY",
49
- );
50
- }
51
- }
52
-
53
- export function locateTurnEnd(
54
- events: readonly SessionEvent[],
55
- atSeq?: number,
56
- mode: BranchAnchorMode = "after",
57
- ): number {
58
- const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
59
- if (atSeq === undefined) {
60
- const last = ends.at(-1);
61
- if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
62
- return last;
63
- }
64
- if (mode === "before") {
65
- let boundary = -1;
66
- for (const seq of ends) {
67
- if (seq < atSeq) boundary = seq;
68
- else break;
69
- }
70
- return boundary;
71
- }
72
- const firstAfter = ends.find((seq) => seq >= atSeq);
73
- if (firstAfter !== undefined) return firstAfter;
74
-
75
- const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
76
- if (lastStart !== undefined && lastStart.seq <= atSeq) {
77
- throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
78
- }
79
- const last = ends.at(-1);
80
- if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
81
- return last;
82
- }
20
+ import { isLegacyVersion } from "../legacy.ts";
21
+ import type { BranchPort } from "./port.ts";
22
+ import { rowToEvent, rowToMeta } from "../log/rows.ts";
83
23
 
84
- function renumber(events: readonly SessionEvent[], offset: number): SessionEvent[] {
85
- return events.map((event, index) => ({ ...event, seq: offset + index }) as SessionEvent);
86
- }
24
+ import {
25
+ assertRewindBoundary,
26
+ locateTurnEnd,
27
+ mintSessionId,
28
+ renumber,
29
+ } from "./semantics.ts";
87
30
 
88
- function mintSessionId(): SessionId {
89
- return `session-${randomUUID()}` as SessionId;
90
- }
31
+ export { locateTurnEnd } from "./semantics.ts";
91
32
 
92
33
  export interface LiveSessionHooks {
93
34
  getSession(id: SessionId): Session | undefined;
@@ -180,7 +121,8 @@ export function truncateLiveSession(session: Session, newLength: number): number
180
121
  };
181
122
  // 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
182
123
  // 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
183
- const lastTurn = s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
124
+ const lastTurn =
125
+ s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
184
126
  s.log.length = newLength;
185
127
  s.eventsSnapshot = undefined;
186
128
  s.headerFold = undefined;
@@ -224,7 +166,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
224
166
  readonly name = "session-rdb";
225
167
 
226
168
  constructor(
227
- private readonly persistence: SessionPersistenceRdb,
169
+ private readonly port: BranchPort,
228
170
 
229
171
  private readonly live: LiveSessionHooks = {
230
172
  getSession: () => undefined,
@@ -248,7 +190,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
248
190
  id: SessionId,
249
191
  signal?: AbortSignal,
250
192
  ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
251
- const stored = await this.persistence.readLog(id, {}, signal);
193
+ const stored = await this.port.readLog(id, {}, signal);
252
194
  if (stored === undefined)
253
195
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
254
196
  return { meta: stored.meta, events: stored.events };
@@ -261,7 +203,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
261
203
  ): Promise<SessionId> {
262
204
  signal?.throwIfAborted();
263
205
  const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
264
- const source = await this.persistence.readLog(sourceId, {}, signal);
206
+ const source = await this.port.readLog(sourceId, {}, signal);
265
207
  if (source === undefined)
266
208
  throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
267
209
  const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
@@ -293,14 +235,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
293
235
  };
294
236
  const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
295
237
 
296
- const internals = this.persistence.internals();
238
+ await this.port.ready;
297
239
  // 复用父会话的事件行有个前提:**读取视图的 seq 与存储行的 f_sequence 一一对应**。legacy 会话的读视图
298
240
  // 由迁移链重建(可能重新编号、增删事件),按 seq 复用会把桥接行挂到语义不符的行上——读取时按 `f_data`
299
241
  // 解析,子会话前缀读到的内容就会和它自己的 seed 事件对不上。那种情况下复制事件行(多几行存储,正确优先)。
300
242
  const reusable = !source.migrated && source.events.length === source.storedCount;
301
243
  const reuse = new Map<number, string>();
302
244
  if (reusable) {
303
- const sourceRows = await internals.backend.getEventRows(sourceId);
245
+ const sourceRows = await this.port.eventRows(sourceId);
304
246
  const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
305
247
  for (const event of prefix) {
306
248
  const eventId = sourceEventIds.get(event.seq);
@@ -311,9 +253,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
311
253
  `session-rdb: fork "${sourceId}" copies event rows instead of reusing them — its read view comes from a migration, so stored seqs need not line up`,
312
254
  );
313
255
  }
314
- internals.registerReuseEventIds(childId, reuse);
256
+ this.port.registerReuseEventIds(childId, reuse);
315
257
  try {
316
- const handle = await this.persistence.create(childMeta, {
258
+ const handle = await this.port.create(childMeta, {
317
259
  inheritedEventCount: SessionLogOffset(prefix.length),
318
260
  });
319
261
  try {
@@ -324,7 +266,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
324
266
  } catch (error) {
325
267
  // fork 没成(id 已存在、append 失败等):别把复用映射留在进程里——同一个 childId 之后的落写会按它
326
268
  // 去复用父会话的事件行,而那条会话可能完全无关。
327
- internals.dropReuseEventIds(childId);
269
+ this.port.dropReuseEventIds(childId);
328
270
  throw error;
329
271
  }
330
272
  // fork 不动统计:继承前缀的消耗是父会话真实发生的那一次,子会话只记自己新产生的
@@ -348,11 +290,11 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
348
290
 
349
291
  if (live !== undefined) await this.live.flush(live);
350
292
 
351
- const internals = this.persistence.internals();
293
+ await this.port.ready;
352
294
 
353
295
  // SessionBranch 是「先停止、再操作」的排他面(ADR-rewind绕过handle模型直接截断):rewind 直连 DB 做最小工作,
354
296
  // 不走持久化抽象的全量读路径(readLog 会拉全部事件 + legacy 转换 + 读视图修复)。
355
- const row = await internals.backend.getSession(id);
297
+ const row = await this.port.sessionRow(id);
356
298
  if (row === undefined) {
357
299
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
358
300
  }
@@ -362,7 +304,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
362
304
  let kept: readonly SessionEvent[] | undefined;
363
305
  if (isLegacyVersion(row.fVersion)) {
364
306
  // 旧格式的持久化坐标与当前视图 seq 不一致:只有这条路径需要读日志(含格式转换与读视图修复)
365
- const log = await this.persistence.readLog(id, {}, signal);
307
+ const log = await this.port.readLog(id, {}, signal);
366
308
  if (log === undefined) {
367
309
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
368
310
  }
@@ -373,14 +315,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
373
315
  kept = balanceRewindPrefix(log.events.slice(0, rawKeepLength));
374
316
  } else {
375
317
  const boundaryType =
376
- toBoundary === -1 ? undefined : await internals.backend.getEventTypeAt(id, toBoundary);
318
+ toBoundary === -1 ? undefined : await this.port.eventTypeAt(id, toBoundary);
377
319
  assertRewindBoundary(id, toBoundary, boundaryType, row.fHeadSequence);
378
320
  rawKeepLength =
379
321
  toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
380
322
  }
381
323
  const keepLength =
382
324
  kept === undefined
383
- ? await this.rewindKeepLength(id, rawKeepLength, internals.backend)
325
+ ? await this.rewindKeepLength(id, rawKeepLength)
384
326
  : kept.length;
385
327
  if (keepLength < rawKeepLength) {
386
328
  this.live.warn?.(
@@ -389,7 +331,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
389
331
  }
390
332
 
391
333
  const denseBoundary = keepLength - 1;
392
- const newSeedLength = await internals.backend.transaction(async (tx) => {
334
+ const newSeedLength = await this.port.transaction(async (tx) => {
393
335
  signal?.throwIfAborted();
394
336
  const head = await tx.getHead(id);
395
337
  if (denseBoundary > head.fHeadSequence) {
@@ -398,14 +340,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
398
340
  "INVALID_BOUNDARY",
399
341
  );
400
342
  }
343
+ let nextHead: { fEventId: string; fSequence: number } | undefined;
401
344
  if (denseBoundary < head.fHeadSequence) {
402
345
  await tx.deleteBridgeTail(id, denseBoundary + 1);
403
346
  const prev = denseBoundary === -1 ? undefined : await tx.getPrevBridge(id, denseBoundary);
404
- if (prev === undefined) {
405
- await tx.updateHead(id, "", -1);
406
- } else {
407
- await tx.updateHead(id, prev.fEventId, prev.fSequence);
408
- }
347
+ nextHead =
348
+ prev === undefined
349
+ ? { fEventId: "", fSequence: -1 }
350
+ : { fEventId: prev.fEventId, fSequence: prev.fSequence };
409
351
  }
410
352
 
411
353
  const storedSeedLength = await tx.getSeedLength(id);
@@ -416,14 +358,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
416
358
  }
417
359
 
418
360
  await tx.refreshTitle(id);
419
- await tx.bumpRevision(id);
361
+ if (nextHead === undefined) {
362
+ await tx.bumpRevision(id);
363
+ } else {
364
+ // head 回退与 revision bump 是同一次行写:分成两条 UPDATE 就是两次行版本。
365
+ await tx.updateHeadAndRevision(id, nextHead.fEventId, nextHead.fSequence);
366
+ }
420
367
  return shrunk;
421
368
  });
422
369
 
423
- internals.writeGuard.confirmHead(id, denseBoundary);
370
+ this.port.confirmHead(id, denseBoundary);
424
371
 
425
372
  if (live !== undefined) {
426
- const lastTurn = truncateLiveSession(live, keepLength);
373
+ // 内存 log 与存储同源的判据:上游 `Session.append` 用 `log.length` 当 seq,所以「内存条目数 - 1」
374
+ // 就是存储 head。内存**落后**于存储时(导入 / 取消与截断的竞态),不能拿内存做长度运算——
375
+ // `log.length = n` 会在尾部补出空洞,之后每次 append 的 seq 都与存储错位,撤回边界就会点到
376
+ // 存储里不存在的位置。落后就按存储前缀**整体重建**;`replaceLiveSessionLog` 顺手把 folds /
377
+ // derived / surface 这些派生缓存一并清掉。
378
+ const liveLength = (live as unknown as { log: readonly SessionEvent[] }).log.length;
379
+ let lastTurn: number;
380
+ if (liveLength < keepLength) {
381
+ const prefix =
382
+ kept ??
383
+ (await this.port.eventRows(id))
384
+ .map(rowToEvent)
385
+ .filter((event) => event.seq <= denseBoundary);
386
+ replaceLiveSessionLog(live, prefix);
387
+ lastTurn = prefix.findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
388
+ } else {
389
+ lastTurn = truncateLiveSession(live, keepLength);
390
+ }
427
391
 
428
392
  this.live.resetProjections?.(live);
429
393
 
@@ -436,16 +400,32 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
436
400
  if (phase !== undefined) phase.lastTurn = lastTurn;
437
401
  }
438
402
 
439
- const handle = this.persistence.tracker.writerOf(id);
440
- if (handle !== undefined) {
441
- handle.resetAfterRewind(keepLength, newSeedLength === null ? undefined : newSeedLength);
442
- }
403
+ this.port.resetWriterAfterRewind(
404
+ id,
405
+ keepLength,
406
+ newSeedLength === null ? undefined : newSeedLength,
407
+ );
443
408
 
444
409
  agent?.inbox?.clear();
445
410
 
446
- await this.live.flush(live);
411
+ // flush 是「把 live 缓冲尽力落库」:失败不该让撤回失败,也不该阻断下面的缓存刷新——
412
+ // 撤回的正确性由库里的截断保证,缓存必须跟着新水位走,否则页面会一直显示撤回前的数据。
413
+ try {
414
+ await this.live.flush(live);
415
+ } catch (error: unknown) {
416
+ this.live.warn?.(
417
+ `session-rdb: flush after rewind for "${id}" failed (the stored log is already truncated; live buffers may lag): ${describeError(error)}`,
418
+ );
419
+ }
447
420
 
448
- await this.refreshProjectionCache(live);
421
+ // 带上新水位:`write` 失败时缓存按这个水位截断兜底,而不是把撤回前的旧快照留在读路径上。
422
+ await this.refreshProjectionCache({
423
+ id: live.id,
424
+ header: live.header,
425
+ inheritedEventCount: live.inheritedEventCount,
426
+ headSeq: keepLength - 1,
427
+ snapshotEvents: () => live.snapshotEvents(),
428
+ });
449
429
  } else {
450
430
  // rewind 不读日志:缓存行按新水位截断(保留下来的投影单元仍在截断前缀内)
451
431
  await this.refreshProjectionCache({
@@ -458,20 +438,16 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
458
438
  }
459
439
 
460
440
  // 撤回不改统计:请求已发起就已经消耗过(见 ADR-撤回不回退统计)。
461
- return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
441
+ return { header: rowToMeta(row), revision: (await this.port.readStoredRevision(id))! };
462
442
  }
463
443
 
464
444
  // 保留长度只依赖尾部窗口:从 rawKeepLength 往回读类型(有界),窗口没覆盖到最近一个 turn/end
465
445
  // 就翻倍重读,直到覆盖或到达前缀开头。
466
- private async rewindKeepLength(
467
- id: SessionId,
468
- rawKeepLength: number,
469
- backend: Backend,
470
- ): Promise<number> {
446
+ private async rewindKeepLength(id: SessionId, rawKeepLength: number): Promise<number> {
471
447
  if (rawKeepLength === 0) return 0;
472
448
  let limit = 64;
473
449
  for (;;) {
474
- const rows = await backend.getEventTypesBefore(id, rawKeepLength, limit);
450
+ const rows = await this.port.eventTypesBefore(id, rawKeepLength, limit);
475
451
  const types = [...rows].reverse().map((row) => row.fType);
476
452
  const windowStart = rawKeepLength - types.length;
477
453
  if (types.includes("turn/end") || windowStart === 0 || types.length >= rawKeepLength) {
@@ -556,7 +532,7 @@ export class SessionBranchRdb extends SessionBranch {
556
532
  }
557
533
 
558
534
  private readonly provider = new SessionBranchRdbProvider(
559
- this.ctx.sessionPersistence as SessionPersistenceRdb,
535
+ (this.ctx.sessionPersistence as unknown as { branchPort(): BranchPort }).branchPort(),
560
536
  {
561
537
  getSession: (id) => this.ctx.sessions.get(id),
562
538
  getAgent: (id) => {
@@ -583,15 +559,34 @@ export class SessionBranchRdb extends SessionBranch {
583
559
  }
584
560
  | undefined;
585
561
  if (cache === undefined) return;
586
- try {
587
- if (session.headSeq !== undefined && cache.truncateTo !== undefined) {
562
+ const cold = session.snapshotEvents().length === 0;
563
+ if (cold && session.headSeq !== undefined && cache.truncateTo !== undefined) {
564
+ // 没有 live 会话:不 fold 日志,按新水位截断即可。
565
+ try {
588
566
  await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
589
- return;
567
+ } catch (error: unknown) {
568
+ this.ctx.logger.warn(
569
+ `session-rdb: projection cache truncate after rewind for "${session.id}" failed: ${describeError(error)}`,
570
+ );
590
571
  }
572
+ return;
573
+ }
574
+ try {
591
575
  await cache.write(session);
592
576
  } catch (error: unknown) {
577
+ // 写回失败(例如带报错信息的日志让上游 fold 抛错、或 flush 失败):退到「按新水位截断镜像」,
578
+ // 至少读路径与服务端的截断结果一致;否则要等实例重启才会从库里重新加载。
579
+ if (session.headSeq !== undefined && cache.truncateTo !== undefined) {
580
+ try {
581
+ await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
582
+ } catch (fallbackError: unknown) {
583
+ this.ctx.logger.warn(
584
+ `session-rdb: projection cache truncate fallback after rewind for "${session.id}" failed: ${describeError(fallbackError)}`,
585
+ );
586
+ }
587
+ }
593
588
  this.ctx.logger.warn(
594
- `session-rdb: projection cache refresh after rewind for "${session.id}" failed (cache stays stale): ${String(error)}`,
589
+ `session-rdb: projection cache rebuild after rewind for "${session.id}" failed (fell back to truncating the cache at the new head): ${describeError(error)}\n${error instanceof Error ? (error.stack ?? "") : ""}`,
595
590
  );
596
591
  }
597
592
  },
@@ -632,3 +627,7 @@ export class SessionBranchRdb extends SessionBranch {
632
627
  }
633
628
 
634
629
  export default SessionBranchRdb;
630
+
631
+ function describeError(error: unknown): string {
632
+ return error instanceof Error ? error.message : "unknown error";
633
+ }
@@ -0,0 +1,62 @@
1
+ import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
2
+ import type { SessionHandle, SessionPersistenceRevision } from "@deepseek-ai/dsh-session-persistence";
3
+ import type { SessionLogOffset } from "@deepseek-ai/dsh-session";
4
+ import type { BackendTx, EventRow, SessionRow } from "../backend.ts";
5
+
6
+ /** 一份读出来的日志(分支只看这些字段)。 */
7
+ export interface BranchLog {
8
+ meta: SessionHeader;
9
+ inheritedEventCount: number;
10
+ events: SessionEvent[];
11
+ tornFrom?: number;
12
+ incarnation: string;
13
+ revision: number;
14
+ storedCount: number;
15
+ migrated: boolean;
16
+ }
17
+
18
+ /**
19
+ * 分支能力需要的持久化面(端口)。
20
+ *
21
+ * 为什么不是整个 `SessionPersistenceRdb`:分支只需要「读日志前缀、写截断、认领复用映射」这几件事,
22
+ * 端口里的每个动词都有真实调用方(不是把服务的方法表转抄一遍)。rewind 走直连快路径,
23
+ * 所以这里保留了行级的 `eventRows` / `sessionRow` / `transaction`,而不是只给一个 `readLog`。
24
+ */
25
+ export interface BranchPort {
26
+ readonly ready: Promise<void>;
27
+
28
+ readLog(
29
+ id: SessionId,
30
+ options?: { fromSeq?: number },
31
+ signal?: AbortSignal,
32
+ ): Promise<BranchLog | undefined>;
33
+
34
+ create(meta: SessionHeader, options?: { inheritedEventCount?: SessionLogOffset }): Promise<SessionHandle>;
35
+
36
+ eventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]>;
37
+
38
+ eventTypesBefore(
39
+ id: SessionId,
40
+ beforeSequence: number,
41
+ limit: number,
42
+ ): Promise<Array<Pick<EventRow, "fSequence" | "fType">>>;
43
+
44
+ sessionRow(id: SessionId): Promise<SessionRow | undefined>;
45
+
46
+ eventTypeAt(id: SessionId, sequence: number): Promise<string | undefined>;
47
+
48
+ transaction<T>(fn: (tx: BackendTx) => Promise<T>): Promise<T>;
49
+
50
+ /** head 回退与 revision bump 是同一次行写,确认位点后写路径才允许继续 append。 */
51
+ confirmHead(id: SessionId, sequence: number): void;
52
+
53
+ readStoredRevision(id: SessionId): Promise<SessionPersistenceRevision | undefined>;
54
+
55
+ /** fork 复用父会话的事件行:子会话落写时按 seq 取回父行 id。 */
56
+ registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void;
57
+
58
+ dropReuseEventIds(childId: SessionId): void;
59
+
60
+ /** rewind 截断后把活跃写句柄的游标与 seed 长度挪到新水位。 */
61
+ resetWriterAfterRewind(id: SessionId, cursor: number, inheritedEventCount?: number): void;
62
+ }
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SessionBranchError, type BranchAnchorMode } from "@morlay/session-branch";
3
+ import type { SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
4
+
5
+ export function assertRewindBoundary(
6
+ id: SessionId,
7
+ toBoundary: number,
8
+ boundaryType: string | undefined,
9
+ head: number,
10
+ ): void {
11
+ if (toBoundary === -1) return;
12
+ if (toBoundary > head) {
13
+ throw new SessionBranchError(
14
+ `rewind boundary ${toBoundary} is beyond the stored head ${head}`,
15
+ "INVALID_BOUNDARY",
16
+ );
17
+ }
18
+ if (boundaryType === undefined) {
19
+ throw new SessionBranchError(
20
+ `rewind boundary ${toBoundary} does not exist in session "${id}"`,
21
+ "INVALID_BOUNDARY",
22
+ );
23
+ }
24
+ if (boundaryType !== "turn/end" && boundaryType !== "user/message") {
25
+ throw new SessionBranchError(
26
+ `rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryType})`,
27
+ "INVALID_BOUNDARY",
28
+ );
29
+ }
30
+ }
31
+
32
+ export function locateTurnEnd(
33
+ events: readonly SessionEvent[],
34
+ atSeq?: number,
35
+ mode: BranchAnchorMode = "after",
36
+ ): number {
37
+ const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
38
+ if (atSeq === undefined) {
39
+ const last = ends.at(-1);
40
+ if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
41
+ return last;
42
+ }
43
+ if (mode === "before") {
44
+ let boundary = -1;
45
+ for (const seq of ends) {
46
+ if (seq < atSeq) boundary = seq;
47
+ else break;
48
+ }
49
+ return boundary;
50
+ }
51
+ const firstAfter = ends.find((seq) => seq >= atSeq);
52
+ if (firstAfter !== undefined) return firstAfter;
53
+
54
+ const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
55
+ if (lastStart !== undefined && lastStart.seq <= atSeq) {
56
+ throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
57
+ }
58
+ const last = ends.at(-1);
59
+ if (last === undefined) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
60
+ return last;
61
+ }
62
+
63
+ export function renumber(events: readonly SessionEvent[], offset: number): SessionEvent[] {
64
+ return events.map((event, index) => ({ ...event, seq: offset + index }) as SessionEvent);
65
+ }
66
+
67
+ export function mintSessionId(): SessionId {
68
+ return `session-${randomUUID()}` as SessionId;
69
+ }