@morlay/session-rdb 0.1.9 → 0.1.10-alpha.2

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 (143) hide show
  1. package/README.md +24 -3
  2. package/dist/artifact.d.mts +118 -3
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
  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 -3
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -3
  12. package/dist/export.mjs +32 -57
  13. package/dist/import.d.mts +1 -3
  14. package/dist/import.mjs +32 -78
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1562 -5
  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 -50
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -5
  25. package/dist/testing.mjs +27 -8
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +8 -42
  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} +125 -112
  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 -1630
  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 -272
  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-query.ts +17 -4
  74. package/src/session-store.ts +675 -0
  75. package/src/stats-fold.ts +44 -0
  76. package/src/storage-takeover/index.ts +2 -5
  77. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  78. package/src/storage-takeover/projection-cache.ts +139 -77
  79. package/src/storage-takeover/read-model.ts +189 -0
  80. package/src/storage-takeover/repository.ts +238 -425
  81. package/src/storage-takeover/storage-backend.ts +47 -149
  82. package/src/storage-takeover/types.ts +45 -14
  83. package/src/storage-takeover/workspace-documents.ts +65 -0
  84. package/src/storage.ts +4 -2
  85. package/src/testing.ts +21 -1
  86. package/src/usage.ts +11 -65
  87. package/src/version.ts +13 -0
  88. package/src/write-path.ts +286 -0
  89. package/dist/branch-DbFW3rJX.mjs +0 -391
  90. package/dist/deletion-ODZFcbZm.mjs +0 -2218
  91. package/dist/gc.d.mts +0 -6
  92. package/dist/gc.mjs +0 -49
  93. package/dist/log-DYxdjJ-l.mjs +0 -562
  94. package/dist/routes-DGBlBE_I.mjs +0 -10
  95. package/dist/sqlite-DjOnMRh2.mjs +0 -1777
  96. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  97. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  99. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  101. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  103. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  104. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  105. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  106. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  107. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  108. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  109. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  110. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  111. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  112. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  114. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  116. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  118. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  120. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  122. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  123. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  124. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  125. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  126. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  127. package/src/adapters/index.ts +0 -12
  128. package/src/adapters/to-postgres.ts +0 -93
  129. package/src/adapters/to-sqlite.ts +0 -83
  130. package/src/adapters/types.ts +0 -44
  131. package/src/drizzle/postgres-v2.ts +0 -10
  132. package/src/drizzle/postgres-v3.ts +0 -18
  133. package/src/drizzle/sqlite-v2.ts +0 -9
  134. package/src/drizzle/sqlite-v3.ts +0 -18
  135. package/src/entities/v2/index.ts +0 -20
  136. package/src/entities/v2/session-events.ts +0 -10
  137. package/src/entities/v3/schedule-tasks.ts +0 -16
  138. package/src/entities/v3/workspace-sessions.ts +0 -19
  139. package/src/entities/v3/workspace-state.ts +0 -12
  140. package/src/entities/v3/workspaces.ts +0 -14
  141. package/src/gc.ts +0 -77
  142. package/src/postgres.ts +0 -1015
  143. package/src/sqlite.ts +0 -1086
@@ -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;
20
+ import { isLegacyVersion } from "../legacy.ts";
21
+ import type { BranchPort } from "./port.ts";
22
+ import { rowToEvent, rowToMeta } from "../log/rows.ts";
74
23
 
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
- }
24
+ import {
25
+ assertRewindBoundary,
26
+ locateTurnEnd,
27
+ mintSessionId,
28
+ renumber,
29
+ } from "./semantics.ts";
83
30
 
84
- function renumber(events: readonly SessionEvent[], offset: number): SessionEvent[] {
85
- return events.map((event, index) => ({ ...event, seq: offset + index }) as SessionEvent);
86
- }
87
-
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;
@@ -153,7 +94,19 @@ function resetSurfaceManager(surfaceManager: SurfaceManagerLike): void {
153
94
  surfaceManager._pendingPlan = undefined;
154
95
  }
155
96
 
156
- export function truncateLiveSession(session: Session, newLength: number): void {
97
+ /**
98
+ * 会话**当前内存 log** 的快照(复制一份)。用于"需要完整序列"的操作(首次落库的 seed、rewind / fork 的历史),
99
+ * 不走已弃用的同步历史读 `snapshotEvents()`——那三个读口上游已声明「新调用禁止」,而这里要的是当前内存里
100
+ * 那份完整日志,不是按需分页的历史。
101
+ * @param session - 会话。
102
+ * @returns 事件副本(调用方可以自由遍历)。
103
+ */
104
+ export function liveEventsOf(session: Session): readonly SessionEvent[] {
105
+ return [...(session as unknown as { log: readonly SessionEvent[] }).log];
106
+ }
107
+
108
+ /** @returns 截断后日志里最后一个 `turn/start` 的 turn 号(没有则 0)。 */
109
+ export function truncateLiveSession(session: Session, newLength: number): number {
157
110
  const s = session as unknown as {
158
111
  log: SessionEvent[];
159
112
  eventsSnapshot?: unknown;
@@ -166,6 +119,10 @@ export function truncateLiveSession(session: Session, newLength: number): void {
166
119
  derivedGeneration: number;
167
120
  surfaceManager: SurfaceManagerLike;
168
121
  };
122
+ // 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
123
+ // 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
124
+ const lastTurn =
125
+ s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
169
126
  s.log.length = newLength;
170
127
  s.eventsSnapshot = undefined;
171
128
  s.headerFold = undefined;
@@ -176,6 +133,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
176
133
  s.derivedNodes = 0;
177
134
  s.derivedGeneration = 0;
178
135
  resetSurfaceManager(s.surfaceManager);
136
+ return lastTurn;
179
137
  }
180
138
 
181
139
  export function replaceLiveSessionLog(session: Session, events: readonly SessionEvent[]): void {
@@ -208,7 +166,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
208
166
  readonly name = "session-rdb";
209
167
 
210
168
  constructor(
211
- private readonly persistence: SessionPersistenceRdb,
169
+ private readonly port: BranchPort,
212
170
 
213
171
  private readonly live: LiveSessionHooks = {
214
172
  getSession: () => undefined,
@@ -232,7 +190,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
232
190
  id: SessionId,
233
191
  signal?: AbortSignal,
234
192
  ): Promise<{ meta: SessionHeader; events: readonly SessionEvent[] }> {
235
- const stored = await this.persistence.readLog(id, {}, signal);
193
+ const stored = await this.port.readLog(id, {}, signal);
236
194
  if (stored === undefined)
237
195
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
238
196
  return { meta: stored.meta, events: stored.events };
@@ -245,7 +203,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
245
203
  ): Promise<SessionId> {
246
204
  signal?.throwIfAborted();
247
205
  const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
248
- const source = await this.persistence.readLog(sourceId, {}, signal);
206
+ const source = await this.port.readLog(sourceId, {}, signal);
249
207
  if (source === undefined)
250
208
  throw new SessionBranchError(`session "${sourceId}" not found`, "SESSION_NOT_FOUND");
251
209
  const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
@@ -277,14 +235,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
277
235
  };
278
236
  const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
279
237
 
280
- const internals = this.persistence.internals();
238
+ await this.port.ready;
281
239
  // 复用父会话的事件行有个前提:**读取视图的 seq 与存储行的 f_sequence 一一对应**。legacy 会话的读视图
282
240
  // 由迁移链重建(可能重新编号、增删事件),按 seq 复用会把桥接行挂到语义不符的行上——读取时按 `f_data`
283
241
  // 解析,子会话前缀读到的内容就会和它自己的 seed 事件对不上。那种情况下复制事件行(多几行存储,正确优先)。
284
242
  const reusable = !source.migrated && source.events.length === source.storedCount;
285
243
  const reuse = new Map<number, string>();
286
244
  if (reusable) {
287
- const sourceRows = await internals.backend.getEventRows(sourceId);
245
+ const sourceRows = await this.port.eventRows(sourceId);
288
246
  const sourceEventIds = new Map(sourceRows.map((row) => [row.fSequence, row.fEventId]));
289
247
  for (const event of prefix) {
290
248
  const eventId = sourceEventIds.get(event.seq);
@@ -295,9 +253,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
295
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`,
296
254
  );
297
255
  }
298
- internals.registerReuseEventIds(childId, reuse);
256
+ this.port.registerReuseEventIds(childId, reuse);
299
257
  try {
300
- const handle = await this.persistence.create(childMeta, {
258
+ const handle = await this.port.create(childMeta, {
301
259
  inheritedEventCount: SessionLogOffset(prefix.length),
302
260
  });
303
261
  try {
@@ -308,7 +266,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
308
266
  } catch (error) {
309
267
  // fork 没成(id 已存在、append 失败等):别把复用映射留在进程里——同一个 childId 之后的落写会按它
310
268
  // 去复用父会话的事件行,而那条会话可能完全无关。
311
- internals.dropReuseEventIds(childId);
269
+ this.port.dropReuseEventIds(childId);
312
270
  throw error;
313
271
  }
314
272
  // fork 不动统计:继承前缀的消耗是父会话真实发生的那一次,子会话只记自己新产生的
@@ -332,11 +290,11 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
332
290
 
333
291
  if (live !== undefined) await this.live.flush(live);
334
292
 
335
- const internals = this.persistence.internals();
293
+ await this.port.ready;
336
294
 
337
295
  // SessionBranch 是「先停止、再操作」的排他面(ADR-rewind绕过handle模型直接截断):rewind 直连 DB 做最小工作,
338
296
  // 不走持久化抽象的全量读路径(readLog 会拉全部事件 + legacy 转换 + 读视图修复)。
339
- const row = await internals.backend.getSession(id);
297
+ const row = await this.port.sessionRow(id);
340
298
  if (row === undefined) {
341
299
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
342
300
  }
@@ -346,7 +304,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
346
304
  let kept: readonly SessionEvent[] | undefined;
347
305
  if (isLegacyVersion(row.fVersion)) {
348
306
  // 旧格式的持久化坐标与当前视图 seq 不一致:只有这条路径需要读日志(含格式转换与读视图修复)
349
- const log = await this.persistence.readLog(id, {}, signal);
307
+ const log = await this.port.readLog(id, {}, signal);
350
308
  if (log === undefined) {
351
309
  throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
352
310
  }
@@ -357,14 +315,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
357
315
  kept = balanceRewindPrefix(log.events.slice(0, rawKeepLength));
358
316
  } else {
359
317
  const boundaryType =
360
- toBoundary === -1 ? undefined : await internals.backend.getEventTypeAt(id, toBoundary);
318
+ toBoundary === -1 ? undefined : await this.port.eventTypeAt(id, toBoundary);
361
319
  assertRewindBoundary(id, toBoundary, boundaryType, row.fHeadSequence);
362
320
  rawKeepLength =
363
321
  toBoundary === -1 ? 0 : boundaryType === "turn/end" ? toBoundary + 1 : toBoundary;
364
322
  }
365
323
  const keepLength =
366
324
  kept === undefined
367
- ? await this.rewindKeepLength(id, rawKeepLength, internals.backend)
325
+ ? await this.rewindKeepLength(id, rawKeepLength)
368
326
  : kept.length;
369
327
  if (keepLength < rawKeepLength) {
370
328
  this.live.warn?.(
@@ -373,7 +331,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
373
331
  }
374
332
 
375
333
  const denseBoundary = keepLength - 1;
376
- const newSeedLength = await internals.backend.transaction(async (tx) => {
334
+ const newSeedLength = await this.port.transaction(async (tx) => {
377
335
  signal?.throwIfAborted();
378
336
  const head = await tx.getHead(id);
379
337
  if (denseBoundary > head.fHeadSequence) {
@@ -382,14 +340,14 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
382
340
  "INVALID_BOUNDARY",
383
341
  );
384
342
  }
343
+ let nextHead: { fEventId: string; fSequence: number } | undefined;
385
344
  if (denseBoundary < head.fHeadSequence) {
386
345
  await tx.deleteBridgeTail(id, denseBoundary + 1);
387
346
  const prev = denseBoundary === -1 ? undefined : await tx.getPrevBridge(id, denseBoundary);
388
- if (prev === undefined) {
389
- await tx.updateHead(id, "", -1);
390
- } else {
391
- await tx.updateHead(id, prev.fEventId, prev.fSequence);
392
- }
347
+ nextHead =
348
+ prev === undefined
349
+ ? { fEventId: "", fSequence: -1 }
350
+ : { fEventId: prev.fEventId, fSequence: prev.fSequence };
393
351
  }
394
352
 
395
353
  const storedSeedLength = await tx.getSeedLength(id);
@@ -400,14 +358,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
400
358
  }
401
359
 
402
360
  await tx.refreshTitle(id);
403
- 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
+ }
404
367
  return shrunk;
405
368
  });
406
369
 
407
- internals.writeGuard.confirmHead(id, denseBoundary);
370
+ this.port.confirmHead(id, denseBoundary);
408
371
 
409
372
  if (live !== undefined) {
410
- 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
+ }
411
391
 
412
392
  this.live.resetProjections?.(live);
413
393
 
@@ -416,22 +396,36 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
416
396
  if (agent !== undefined) {
417
397
  agent.requestHeaderLogged = false;
418
398
 
419
- const lastTurn =
420
- live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
421
399
  const phase = (agent as unknown as { phase?: { lastTurn?: number } }).phase;
422
400
  if (phase !== undefined) phase.lastTurn = lastTurn;
423
401
  }
424
402
 
425
- const handle = this.persistence.tracker.writerOf(id);
426
- if (handle !== undefined) {
427
- handle.resetAfterRewind(keepLength, newSeedLength === null ? undefined : newSeedLength);
428
- }
403
+ this.port.resetWriterAfterRewind(
404
+ id,
405
+ keepLength,
406
+ newSeedLength === null ? undefined : newSeedLength,
407
+ );
429
408
 
430
409
  agent?.inbox?.clear();
431
410
 
432
- 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
+ }
433
420
 
434
- 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
+ });
435
429
  } else {
436
430
  // rewind 不读日志:缓存行按新水位截断(保留下来的投影单元仍在截断前缀内)
437
431
  await this.refreshProjectionCache({
@@ -444,20 +438,16 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
444
438
  }
445
439
 
446
440
  // 撤回不改统计:请求已发起就已经消耗过(见 ADR-撤回不回退统计)。
447
- return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
441
+ return { header: rowToMeta(row), revision: (await this.port.readStoredRevision(id))! };
448
442
  }
449
443
 
450
444
  // 保留长度只依赖尾部窗口:从 rawKeepLength 往回读类型(有界),窗口没覆盖到最近一个 turn/end
451
445
  // 就翻倍重读,直到覆盖或到达前缀开头。
452
- private async rewindKeepLength(
453
- id: SessionId,
454
- rawKeepLength: number,
455
- backend: Backend,
456
- ): Promise<number> {
446
+ private async rewindKeepLength(id: SessionId, rawKeepLength: number): Promise<number> {
457
447
  if (rawKeepLength === 0) return 0;
458
448
  let limit = 64;
459
449
  for (;;) {
460
- const rows = await backend.getEventTypesBefore(id, rawKeepLength, limit);
450
+ const rows = await this.port.eventTypesBefore(id, rawKeepLength, limit);
461
451
  const types = [...rows].reverse().map((row) => row.fType);
462
452
  const windowStart = rawKeepLength - types.length;
463
453
  if (types.includes("turn/end") || windowStart === 0 || types.length >= rawKeepLength) {
@@ -542,7 +532,7 @@ export class SessionBranchRdb extends SessionBranch {
542
532
  }
543
533
 
544
534
  private readonly provider = new SessionBranchRdbProvider(
545
- this.ctx.sessionPersistence as SessionPersistenceRdb,
535
+ (this.ctx.sessionPersistence as unknown as { branchPort(): BranchPort }).branchPort(),
546
536
  {
547
537
  getSession: (id) => this.ctx.sessions.get(id),
548
538
  getAgent: (id) => {
@@ -569,15 +559,34 @@ export class SessionBranchRdb extends SessionBranch {
569
559
  }
570
560
  | undefined;
571
561
  if (cache === undefined) return;
572
- try {
573
- 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 {
574
566
  await cache.truncateTo(session.header, session.inheritedEventCount, session.headSeq);
575
- 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
+ );
576
571
  }
572
+ return;
573
+ }
574
+ try {
577
575
  await cache.write(session);
578
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
+ }
579
588
  this.ctx.logger.warn(
580
- `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 ?? "") : ""}`,
581
590
  );
582
591
  }
583
592
  },
@@ -618,3 +627,7 @@ export class SessionBranchRdb extends SessionBranch {
618
627
  }
619
628
 
620
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
+ }