@morlay/session-rdb 0.0.16-alpha.2 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/artifact.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { a as repairAssistantSettlement, c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as toJsonlArtifact, h as syncMeteringRanges, i as renameLegacyPtcEvents, l as repairSurfaceOps, m as sessionInsertRow, n as orphanInboxSpliceSeqs, o as repairOrphanInboxSplices, p as sessionConflictRow, r as recomputeReplaceProvenance, s as repairReadView, t as findSurfaceRepairs, u as rowToEvent } from "./log-BIuXv09P.mjs";
2
- import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-eBSoCrxl.mjs";
2
+ import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-BPEHfHNk.mjs";
3
3
  export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, findSurfaceRepairs, orphanInboxSpliceSeqs, parseImportZip, parseJsonlArtifact, persistImport, recomputeReplaceProvenance, registerSessionImport, renameLegacyPtcEvents, repairAssistantSettlement, repairOrphanInboxSplices, repairReadView, repairRequestHeaders, repairSurfaceOps, rowToEvent, rowToMeta, scanRows, sessionConflictRow, sessionInsertRow, syncMeteringRanges, toJsonlArtifact };
@@ -142,7 +142,8 @@ var SessionBranchRdbProvider = class {
142
142
  if (!Number.isSafeInteger(toBoundary) || toBoundary < -1) throw new SessionBranchError(`rewind boundary must be a non-negative safe integer, got ${toBoundary}`, "INVALID_BOUNDARY");
143
143
  const live = this.live.getSession(id);
144
144
  if (live !== void 0) await this.live.flush(live);
145
- const raw = live === void 0 ? await this.readRawEvents(id, signal) : void 0;
145
+ const raw = live === void 0 ? await this.persistence.readLog(id, {}, signal) : void 0;
146
+ if (live === void 0 && raw === void 0) throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
146
147
  const inspection = live === void 0 ? void 0 : await this.persistence.internals().inspect(id, signal);
147
148
  const events = live === void 0 ? raw.events : inspection.events;
148
149
  const meta = live === void 0 ? raw.meta : inspection.meta;
@@ -150,7 +151,8 @@ var SessionBranchRdbProvider = class {
150
151
  if (toBoundary === -1) {} else if (boundaryEvent === void 0) throw new SessionBranchError(`rewind boundary ${toBoundary} does not exist in session "${id}"`, "INVALID_BOUNDARY");
151
152
  else if (boundaryEvent.type !== "turn/end" && boundaryEvent.type !== "user/message") throw new SessionBranchError(`rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryEvent.type})`, "INVALID_BOUNDARY");
152
153
  const rawKeepLength = toBoundary === -1 ? 0 : boundaryEvent.type === "turn/end" ? toBoundary + 1 : toBoundary;
153
- const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
154
+ const kept = balanceRewindPrefix(events.slice(0, rawKeepLength));
155
+ const keepLength = kept.length;
154
156
  const internals = this.persistence.internals();
155
157
  const denseBoundary = keepLength - 1;
156
158
  const newSeedLength = await internals.backend.transaction(async (tx) => {
@@ -175,6 +177,7 @@ var SessionBranchRdbProvider = class {
175
177
  internals.writeGuard.confirmHead(id, denseBoundary);
176
178
  if (live !== void 0) {
177
179
  truncateLiveSession(live, keepLength);
180
+ this.live.resetProjections?.(live);
178
181
  const agent = this.live.getAgent(id);
179
182
  if (agent !== void 0) {
180
183
  agent.requestHeaderLogged = false;
@@ -184,7 +187,15 @@ var SessionBranchRdbProvider = class {
184
187
  }
185
188
  const handle = this.persistence.tracker.writerOf(id);
186
189
  if (handle !== void 0) handle.resetAfterRewind(keepLength, newSeedLength === null ? void 0 : newSeedLength);
187
- }
190
+ agent?.inbox?.clear();
191
+ await this.live.flush(live);
192
+ await this.refreshProjectionCache(live);
193
+ } else await this.refreshProjectionCache({
194
+ id,
195
+ header: meta,
196
+ inheritedEventCount: SessionLogOffset(raw.inheritedEventCount),
197
+ snapshotEvents: () => kept
198
+ });
188
199
  const row = await internals.backend.getSession(id);
189
200
  if (row === void 0) return {
190
201
  header: {
@@ -205,6 +216,16 @@ var SessionBranchRdbProvider = class {
205
216
  revision: await internals.readStoredRevision(id)
206
217
  };
207
218
  }
219
+ /**
220
+ * 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
221
+ * 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
222
+ */
223
+ async refreshProjectionCache(session) {
224
+ if (this.live.refreshProjectionCache === void 0) return;
225
+ try {
226
+ await this.live.refreshProjectionCache(session);
227
+ } catch {}
228
+ }
208
229
  };
209
230
  var SessionBranchRdb = class extends SessionBranch {
210
231
  static inject = ["sessionPersistence", "sessions"];
@@ -216,7 +237,21 @@ var SessionBranchRdb = class extends SessionBranch {
216
237
  getAgent: (id) => {
217
238
  return this.ctx.get("agents")?.get(id);
218
239
  },
219
- flush: (session) => this.ctx.sessions.flush(session)
240
+ flush: (session) => this.ctx.sessions.flush(session),
241
+ resetProjections: (session) => {
242
+ const registry = this.ctx.get("sessionProjections");
243
+ if (registry?.registrations === void 0) return;
244
+ for (const registration of registry.registrations.values()) registration.cells.delete(session);
245
+ },
246
+ refreshProjectionCache: async (session) => {
247
+ const cache = this.ctx.get("sessionProjectionCache");
248
+ if (cache === void 0) return;
249
+ try {
250
+ await cache.write(session);
251
+ } catch (error) {
252
+ this.ctx.logger.warn(`session-rdb: projection cache refresh after rewind for "${session.id}" failed (cache stays stale): ${String(error)}`);
253
+ }
254
+ }
220
255
  });
221
256
  readBranchPrefix(id, atSeq, mode, signal) {
222
257
  return this.provider.readBranchPrefix(id, atSeq, mode, signal);
package/dist/import.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as SessionPersistenceRdb } from "./index-DfS0PzGM.mjs";
1
+ import { n as SessionPersistenceRdb } from "./index-CgAqCQAb.mjs";
2
2
  import { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
3
3
  import { Session, SessionEvent, SessionId } from "@deepseek-ai/dsh-session";
4
4
  import { Context } from "@deepseek-ai/cordis";
package/dist/import.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-eBSoCrxl.mjs";
1
+ import { a as persistImport, i as parseJsonlArtifact, n as SESSION_LOG_ARTIFACT_FILENAME, o as registerSessionImport, r as parseImportZip, t as SESSION_IMPORT_PATH } from "./import-BPEHfHNk.mjs";
2
2
  export { SESSION_IMPORT_PATH, SESSION_LOG_ARTIFACT_FILENAME, parseImportZip, parseJsonlArtifact, persistImport, registerSessionImport };
@@ -10,10 +10,27 @@ interface LiveSessionHooks {
10
10
  getSession(id: SessionId): Session | undefined;
11
11
  getAgent(id: SessionId): LiveAgentLike | undefined;
12
12
  flush(session: Session): Promise<boolean>;
13
+ resetProjections?(session: Session): void;
14
+ refreshProjectionCache?(session: ProjectionCacheSession): Promise<void>;
15
+ }
16
+ /**
17
+ * 投影检查点刷新所需的最小会话面:cold rewind 没有 live Session 可复用,
18
+ * 而截断后的前缀未必是合法的独立会话(surface 引用可能悬空),不能走
19
+ * `Session.create` 的校验;缓存服务只读 id / header / inheritedEventCount
20
+ * 与事件前缀,用普通对象承载即可。
21
+ */
22
+ interface ProjectionCacheSession {
23
+ readonly id: SessionId;
24
+ readonly header: SessionHeader;
25
+ readonly inheritedEventCount: SessionLogOffset;
26
+ snapshotEvents(): readonly SessionEvent[];
13
27
  }
14
28
  interface LiveAgentLike {
15
29
  session: Session;
16
30
  requestHeaderLogged?: boolean;
31
+ inbox?: {
32
+ clear(): void;
33
+ };
17
34
  }
18
35
  declare class SessionBranchRdbProvider implements SessionBranchProvider {
19
36
  private readonly persistence;
@@ -27,6 +44,11 @@ declare class SessionBranchRdbProvider implements SessionBranchProvider {
27
44
  }>;
28
45
  forkFrom(sourceId: SessionId, options?: ForkFromOptions, signal?: AbortSignal): Promise<SessionId>;
29
46
  rewind(id: SessionId, toBoundary: number, signal?: AbortSignal): Promise<SessionPersistenceSnapshot>;
47
+ /**
48
+ * 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
49
+ * 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
50
+ */
51
+ private refreshProjectionCache;
30
52
  }
31
53
  declare class SessionBranchRdb extends SessionBranch {
32
54
  static inject: string[];
package/dist/index.d.mts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { o as SCHEMA_VERSION } from "./schema-COK7-wRV.mjs";
2
- import { a as SessionBranchRdbProvider, i as SessionBranchRdb, n as SessionPersistenceRdb, o as locateTurnEnd, r as SessionPersistenceRdbInternals, t as Config } from "./index-DfS0PzGM.mjs";
2
+ import { a as SessionBranchRdbProvider, i as SessionBranchRdb, n as SessionPersistenceRdb, o as locateTurnEnd, r as SessionPersistenceRdbInternals, t as Config } from "./index-CgAqCQAb.mjs";
3
3
  export { Config, SCHEMA_VERSION, SessionBranchRdb, SessionBranchRdbProvider, SessionPersistenceRdb, SessionPersistenceRdb as default, SessionPersistenceRdbInternals, locateTurnEnd };
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { a as SCHEMA_VERSION, g as WriteGuard, h as toPostgresSchema, i as EVENT_ENCODING, m as postgresTableDefs, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend } from "./sqlite-Dpd17YhI.mjs";
1
+ import { a as SCHEMA_VERSION, g as WriteGuard, h as toPostgresSchema, i as EVENT_ENCODING, m as postgresTableDefs, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend } from "./sqlite-IH5I48aL.mjs";
2
2
  import { c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as toJsonlArtifact, m as sessionInsertRow, p as sessionConflictRow, s as repairReadView } from "./log-BIuXv09P.mjs";
3
- import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-eBSoCrxl.mjs";
3
+ import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-BPEHfHNk.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { Pool } from "pg";
@@ -10,10 +10,11 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-sessi
10
10
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
11
11
  import { and, eq, gte, sql } from "drizzle-orm";
12
12
  import { readdirSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
13
14
  import { migrate } from "drizzle-orm/node-postgres/migrator";
14
15
  //#region src/postgres.ts
15
16
  /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
16
- const postgresMigrationsDir = new URL("../drizzle/postgres/", import.meta.url).pathname;
17
+ const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
17
18
  var PostgresBackend = class PostgresBackend {
18
19
  db;
19
20
  options;
@@ -6,6 +6,7 @@ import { bigint, check as check$1, index as index$1, integer as integer$1, pgSch
6
6
  import { readdirSync, statSync } from "node:fs";
7
7
  import { mkdir, open } from "node:fs/promises";
8
8
  import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { DatabaseSync } from "node:sqlite";
10
11
  import { drizzle } from "drizzle-orm/node-sqlite";
11
12
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
@@ -482,7 +483,7 @@ function eventDimensions(event) {
482
483
  //#endregion
483
484
  //#region src/sqlite.ts
484
485
  /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
485
- const sqliteMigrationsDir = new URL("../drizzle/sqlite/", import.meta.url).pathname;
486
+ const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
486
487
  const sqliteTxQueues = /* @__PURE__ */ new Map();
487
488
  function enqueueSqliteTx(path, fn) {
488
489
  const run = (sqliteTxQueues.get(path) ?? Promise.resolve()).then(fn);
package/dist/storage.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as SCHEMA_VERSION, c as eventKind, d as tSchemaMeta, f as tSessionEvents, g as WriteGuard, i as EVENT_ENCODING, l as tEvents, n as openDatabase, o as SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, p as tSessions, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend, u as tPersistenceState } from "./sqlite-Dpd17YhI.mjs";
1
+ import { a as SCHEMA_VERSION, c as eventKind, d as tSchemaMeta, f as tSessionEvents, g as WriteGuard, i as EVENT_ENCODING, l as tEvents, n as openDatabase, o as SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, p as tSessions, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend, u as tPersistenceState } from "./sqlite-IH5I48aL.mjs";
2
2
  export { DEFAULT_BUSY_TIMEOUT_MS, EVENT_ENCODING, SCHEMA_VERSION, SESSION_PERSISTENCE_SQLITE_APPLICATION_ID, SqliteBackend, WriteGuard, eventDimensions, eventKind, openDatabase, tEvents, tPersistenceState, tSchemaMeta, tSessionEvents, tSessions };
package/dist/testing.mjs CHANGED
@@ -5116,7 +5116,7 @@ function manageArtifactAttachment(attachment) {
5116
5116
  if (attachment.body != null) attachment.bodyEncoding ??= "base64";
5117
5117
  }
5118
5118
  //#endregion
5119
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.14.0_vite@8.2.2__b2fde82745a4512744fb5cfb49e8ecf4/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5119
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js
5120
5120
  const NAME_WORKER_STATE = "__vitest_worker__";
5121
5121
  function getWorkerState() {
5122
5122
  const workerState = globalThis[NAME_WORKER_STATE];
@@ -10019,10 +10019,10 @@ function offsetToLineNumber(source, offset) {
10019
10019
  return line + 1;
10020
10020
  }
10021
10021
  //#endregion
10022
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.14.0_vite@8.2.2__b2fde82745a4512744fb5cfb49e8ecf4/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10022
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js
10023
10023
  var commonjsGlobal = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {};
10024
10024
  //#endregion
10025
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.14.0_vite@8.2.2__b2fde82745a4512744fb5cfb49e8ecf4/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10025
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js
10026
10026
  const RealDate = Date;
10027
10027
  let now = null;
10028
10028
  var MockDate = class MockDate extends RealDate {
@@ -10987,7 +10987,7 @@ function raceWith(promise, other) {
10987
10987
  }))]);
10988
10988
  }
10989
10989
  //#endregion
10990
- //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.0_happy-dom@20.14.0_vite@8.2.2__b2fde82745a4512744fb5cfb49e8ecf4/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10990
+ //#region ../../node_modules/.pnpm/vitest@4.1.11_@opentelemetry+api@1.9.1_@types+node@26.5.1_happy-dom@20.14.3_vite@8.2.2__e05875f349154650201ff2ddb1226938/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js
10991
10991
  var fakeTimersSrc = {};
10992
10992
  var global$1;
10993
10993
  var hasRequiredGlobal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.16-alpha.2",
3
+ "version": "0.0.16",
4
4
  "description": "RDB durable session backend for DeepSeek Harness: implements both session-persistence and session-branch (rewind / retry / fork) providers.",
5
5
  "keywords": [
6
6
  "branch",
@@ -35,27 +35,32 @@
35
35
  "./cordis.patch.yml": "./cordis.patch.yml"
36
36
  },
37
37
  "dependencies": {
38
- "@morlay/session-branch": "^0.0.4-alpha.2",
38
+ "@morlay/session-branch": "^0.0.4",
39
39
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
40
40
  "fflate": "^0.8.2",
41
41
  "pg": "^8.23.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@deepseek-ai/dsh-session-projection": "^0.1.5-alpha.1",
45
- "@deepseek-ai/dsh-token-meter": "^0.1.5-alpha.1",
44
+ "@deepseek-ai/dsh-session-projection": "^0.1.5-rc.1",
45
+ "@deepseek-ai/dsh-session-projection-cache": "^0.1.5-rc.1",
46
+ "@deepseek-ai/dsh-session-turn-outline": "^0.1.5-rc.1",
47
+ "@deepseek-ai/dsh-storage": "^0.1.5-rc.1",
48
+ "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.1",
49
+ "@deepseek-ai/dsh-storage-json": "^0.1.5-rc.1",
50
+ "@deepseek-ai/dsh-token-meter": "^0.1.5-rc.1",
46
51
  "@types/pg": "^8.23.1",
47
52
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
48
53
  },
49
54
  "peerDependencies": {
50
55
  "@deepseek-ai/cordis": "^4.0.2",
51
- "@deepseek-ai/dsh-invariants": "^0.1.5-alpha.1",
52
- "@deepseek-ai/dsh-llm": "^0.1.5-alpha.1",
53
- "@deepseek-ai/dsh-scope": "^0.1.5-alpha.1",
54
- "@deepseek-ai/dsh-session": "^0.1.5-alpha.1",
55
- "@deepseek-ai/dsh-session-format": "^0.1.5-alpha.1",
56
- "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-alpha.1",
57
- "@deepseek-ai/dsh-session-persistence": "^0.1.5-alpha.1",
58
- "@deepseek-ai/dsh-settings": "^0.1.5-alpha.1",
56
+ "@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-scope": "^0.1.5-rc.1",
59
+ "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
60
+ "@deepseek-ai/dsh-session-format": "^0.1.5-rc.1",
61
+ "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-rc.1",
62
+ "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
63
+ "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
59
64
  "@deepseek-ai/schemastery": "^3.18.2"
60
65
  },
61
66
  "dsh": {
package/src/branch.ts CHANGED
@@ -66,12 +66,45 @@ export interface LiveSessionHooks {
66
66
  getAgent(id: SessionId): LiveAgentLike | undefined;
67
67
 
68
68
  flush(session: Session): Promise<boolean>;
69
+
70
+ // 丢弃 live 会话已缓存的投影单元:rewind 直接截断内存 log,投影 registry
71
+ // 的 observedSeq 仍停在截断前,后续 append 的事件(seq 更小)会被 drive()
72
+ // 跳过,重放输入永远进不了 inbox 投影。可选:纯持久化环境没有投影服务。
73
+ resetProjections?(session: Session): void;
74
+
75
+ // 用截断后的 log 重写该会话的持久化投影检查点:rewind 不会产生事件,
76
+ // 缓存行的水位(以及基于它折出的值)仍停在截断前。超前行不会被前端投影
77
+ // store 的 higher-seq-wins 规则覆盖(rewind 后的正确值 seq 更小),表现为
78
+ // 轮次导航残留被删除的旧轮次。可选:纯持久化环境没有投影缓存服务。
79
+ refreshProjectionCache?(session: ProjectionCacheSession): Promise<void>;
80
+ }
81
+
82
+ /**
83
+ * 投影检查点刷新所需的最小会话面:cold rewind 没有 live Session 可复用,
84
+ * 而截断后的前缀未必是合法的独立会话(surface 引用可能悬空),不能走
85
+ * `Session.create` 的校验;缓存服务只读 id / header / inheritedEventCount
86
+ * 与事件前缀,用普通对象承载即可。
87
+ */
88
+ export interface ProjectionCacheSession {
89
+ readonly id: SessionId;
90
+ readonly header: SessionHeader;
91
+ readonly inheritedEventCount: SessionLogOffset;
92
+ snapshotEvents(): readonly SessionEvent[];
93
+ }
94
+
95
+ // 投影 registry 的失效面(上游私有结构,duck-type 读取)。
96
+ interface ProjectionRegistryLike {
97
+ registrations?: Map<string, { cells: WeakMap<object, unknown> }>;
69
98
  }
70
99
 
71
100
  export interface LiveAgentLike {
72
101
  session: Session;
73
102
 
74
103
  requestHeaderLogged?: boolean;
104
+
105
+ // 上游 Agent 的 inbox 契约面(ReactLoopInbox.clear):rewind 后残留的排队
106
+ // 输入必须 durable 取消,否则 agent 会继续处理它们。可选:测试替身可能没有。
107
+ inbox?: { clear(): void };
75
108
  }
76
109
 
77
110
  export function truncateLiveSession(session: Session, newLength: number): void {
@@ -246,8 +279,12 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
246
279
  if (live !== undefined) await this.live.flush(live);
247
280
  // 边界校验用原始事件(readLog,不补 closers)——inspect 会给未闭合
248
281
  // log 补合成 closers,把 user/message 边界掩盖成 turn/end,丢失 exclusive
249
- // 语义。
250
- const raw = live === undefined ? await this.readRawEvents(id, signal) : undefined;
282
+ // 语义。cold 分支同时取回 inheritedEventCount:刷新投影检查点时要用它
283
+ // 重建与存储一致的会话身份。
284
+ const raw = live === undefined ? await this.persistence.readLog(id, {}, signal) : undefined;
285
+ if (live === undefined && raw === undefined) {
286
+ throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
287
+ }
251
288
  const inspection =
252
289
  live === undefined ? undefined : await this.persistence.internals().inspect(id, signal);
253
290
  const events = live === undefined ? raw!.events : inspection!.events;
@@ -270,7 +307,8 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
270
307
  // 编辑版替换)。exclusive 截断可能残留孤儿 step/start,经平衡化剔除。
271
308
  const rawKeepLength =
272
309
  toBoundary === -1 ? 0 : boundaryEvent!.type === "turn/end" ? toBoundary + 1 : toBoundary;
273
- const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
310
+ const kept = balanceRewindPrefix(events.slice(0, rawKeepLength));
311
+ const keepLength = kept.length;
274
312
 
275
313
  const internals = this.persistence.internals();
276
314
  // 原样存储(无过滤):live 视图与 RDB head 同空间(上游 seq),
@@ -314,6 +352,9 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
314
352
  // agent 轮次游标。不调用 load——live 时 load 会先 flush 把旧内存写回,
315
353
  // 撤销本次截断。
316
354
  truncateLiveSession(live, keepLength);
355
+ // 投影缓存同样停在截断前的水位,必须先失效,否则截断后的重放事件
356
+ // (seq 回退)不会进入投影。
357
+ this.live.resetProjections?.(live);
317
358
  const agent = this.live.getAgent(id);
318
359
  if (agent !== undefined) {
319
360
  agent.requestHeaderLogged = false;
@@ -330,6 +371,24 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
330
371
  if (handle !== undefined) {
331
372
  handle.resetAfterRewind(keepLength, newSeedLength === null ? undefined : newSeedLength);
332
373
  }
374
+ // 截断后强制清空排队输入:保留区里未被截断的 pending(`agent/inbox/spliced`)
375
+ // 仍会被 agent 处理,必须 durable 取消。cursor 已对齐,取消事件从截断点续接。
376
+ agent?.inbox?.clear();
377
+ // 取消事件先经 coordinator 缓冲,落盘后 rewind 才真正 durable(调用方
378
+ // 可能在 rewind 返回后直接读后端)。
379
+ await this.live.flush(live);
380
+ // 持久化投影检查点也要退到截断后的水位:rewind 不产生事件,缓存行
381
+ // 会停在截断前;超前行锁死前端(higher-seq-wins),轮次导航残留旧轮次。
382
+ await this.refreshProjectionCache(live);
383
+ } else {
384
+ // cold:用截断后的前缀 + 存储身份构造最小会话面,让缓存服务按截断后
385
+ // 的 log 重写检查点(没有 live 会话可复用,也不为此 resume 一个 agent)。
386
+ await this.refreshProjectionCache({
387
+ id,
388
+ header: meta,
389
+ inheritedEventCount: SessionLogOffset(raw!.inheritedEventCount),
390
+ snapshotEvents: () => kept,
391
+ });
333
392
  }
334
393
 
335
394
  const row = await internals.backend.getSession(id);
@@ -354,6 +413,19 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
354
413
  }
355
414
  return { header: rowToMeta(row), revision: (await internals.readStoredRevision(id))! };
356
415
  }
416
+
417
+ /**
418
+ * 让持久化投影检查点跟随截断后的 log(见 {@link LiveSessionHooks.refreshProjectionCache})。
419
+ * 缓存是派生数据:刷新失败只让缓存多滞后一轮,不能让 rewind 失败。
420
+ */
421
+ private async refreshProjectionCache(session: ProjectionCacheSession): Promise<void> {
422
+ if (this.live.refreshProjectionCache === undefined) return;
423
+ try {
424
+ await this.live.refreshProjectionCache(session);
425
+ } catch {
426
+ // 实现方(SessionBranchRdb)已记日志;此处只保证 rewind 不因派生数据失败。
427
+ }
428
+ }
357
429
  }
358
430
 
359
431
  export class SessionBranchRdb extends SessionBranch {
@@ -375,6 +447,30 @@ export class SessionBranchRdb extends SessionBranch {
375
447
  return agents?.get(id);
376
448
  },
377
449
  flush: (session) => this.ctx.sessions.flush(session),
450
+ resetProjections: (session) => {
451
+ // sessionProjections 是可选服务(纯持久化环境无 agent-loop/投影)。
452
+ const registry = (this.ctx as unknown as { get(name: string): unknown }).get(
453
+ "sessionProjections",
454
+ ) as ProjectionRegistryLike | undefined;
455
+ if (registry?.registrations === undefined) return;
456
+ for (const registration of registry.registrations.values()) {
457
+ registration.cells.delete(session);
458
+ }
459
+ },
460
+ refreshProjectionCache: async (session) => {
461
+ // sessionProjectionCache 是可选服务(纯持久化环境无投影缓存)。
462
+ const cache = this.ctx.get("sessionProjectionCache") as
463
+ | { write(session: ProjectionCacheSession): Promise<void> }
464
+ | undefined;
465
+ if (cache === undefined) return;
466
+ try {
467
+ await cache.write(session);
468
+ } catch (error: unknown) {
469
+ this.ctx.logger.warn(
470
+ `session-rdb: projection cache refresh after rewind for "${session.id}" failed (cache stays stale): ${String(error)}`,
471
+ );
472
+ }
473
+ },
378
474
  },
379
475
  );
380
476
 
package/src/postgres.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readdirSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { and, eq, gte, sql } from "drizzle-orm";
4
5
  import type { PgAsyncDatabase, PgAsyncTransaction } from "drizzle-orm/pg-core";
5
6
  import type { NodePgDatabase, NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
@@ -18,7 +19,7 @@ import { postgresTableDefs } from "./entities/index.ts";
18
19
  import { sessionConflictRow, sessionInsertRow } from "./log.ts";
19
20
 
20
21
  /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
21
- const postgresMigrationsDir = new URL("../drizzle/postgres/", import.meta.url).pathname;
22
+ const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
22
23
 
23
24
  export interface PostgresBackendOptions {
24
25
  identityBase: string;
package/src/sqlite.ts CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { readdirSync, statSync } from "node:fs";
3
3
  import { mkdir, open } from "node:fs/promises";
4
4
  import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { DatabaseSync } from "node:sqlite";
6
7
  import { and, eq, gte, sql } from "drizzle-orm";
7
8
  import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -30,7 +31,7 @@ import {
30
31
  type SqliteDb = NodeSQLiteDatabase & { $client: DatabaseSync };
31
32
 
32
33
  /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
33
- const sqliteMigrationsDir = new URL("../drizzle/sqlite/", import.meta.url).pathname;
34
+ const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
34
35
 
35
36
  const sqliteTxQueues = new Map<string, Promise<void>>();
36
37