@morlay/session-rdb 0.0.16 → 0.0.17

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 (48) hide show
  1. package/README.md +44 -10
  2. package/dist/artifact.d.mts +9 -2
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{import-BPEHfHNk.mjs → import-DZIAjOUr.mjs} +2 -1
  5. package/dist/import.d.mts +1 -1
  6. package/dist/import.mjs +1 -1
  7. package/dist/{index-CgAqCQAb.d.mts → index-BEKoV1Uy.d.mts} +11 -2
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +509 -6
  10. package/dist/{log-BIuXv09P.mjs → log-DO69NQnn.mjs} +16 -1
  11. package/dist/schema-Bo6Hy5gW.d.mts +3476 -0
  12. package/dist/{sqlite-IH5I48aL.mjs → sqlite-BIPdMNQb.mjs} +504 -6
  13. package/dist/storage.d.mts +10 -2
  14. package/dist/storage.mjs +2 -2
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +65 -0
  18. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +1236 -0
  19. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +10 -0
  20. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +1236 -0
  21. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +66 -0
  22. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +958 -0
  23. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +13 -0
  24. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +958 -0
  25. package/package.json +6 -4
  26. package/src/backend.ts +7 -0
  27. package/src/branch.ts +2 -0
  28. package/src/drizzle/postgres-v3.ts +5 -0
  29. package/src/drizzle/sqlite-v3.ts +5 -0
  30. package/src/entities/v3/index.ts +20 -0
  31. package/src/entities/v3/session-projcache-rows.ts +27 -0
  32. package/src/entities/v3/sessions.ts +6 -0
  33. package/src/entities/v3/storage-units.ts +10 -0
  34. package/src/entities/v3/workspace-sessions.ts +24 -0
  35. package/src/entities/v3/workspace-state.ts +18 -0
  36. package/src/entities/v3/workspaces.ts +19 -0
  37. package/src/import-storages.ts +173 -0
  38. package/src/index.ts +53 -0
  39. package/src/log.ts +19 -0
  40. package/src/postgres.ts +70 -2
  41. package/src/schema.ts +16 -2
  42. package/src/sqlite.ts +87 -4
  43. package/src/storage-takeover/index.ts +60 -0
  44. package/src/storage-takeover/projection-cache.ts +445 -0
  45. package/src/storage-takeover/repository.ts +420 -0
  46. package/src/storage-takeover/storage-backend.ts +177 -0
  47. package/src/storage-takeover/types.ts +82 -0
  48. package/dist/schema-COK7-wRV.d.mts +0 -104
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
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,7 +35,7 @@
35
35
  "./cordis.patch.yml": "./cordis.patch.yml"
36
36
  },
37
37
  "dependencies": {
38
- "@morlay/session-branch": "^0.0.4",
38
+ "@morlay/session-branch": "^0.0.5",
39
39
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
40
40
  "fflate": "^0.8.2",
41
41
  "pg": "^8.23.0"
@@ -46,8 +46,8 @@
46
46
  "@deepseek-ai/dsh-session-turn-outline": "^0.1.5-rc.1",
47
47
  "@deepseek-ai/dsh-storage": "^0.1.5-rc.1",
48
48
  "@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.1",
49
- "@deepseek-ai/dsh-storage-json": "^0.1.5-rc.1",
50
49
  "@deepseek-ai/dsh-token-meter": "^0.1.5-rc.1",
50
+ "@deepseek-ai/dsh-workspace": "^0.1.5-rc.1",
51
51
  "@types/pg": "^8.23.1",
52
52
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
53
53
  },
@@ -61,6 +61,7 @@
61
61
  "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-rc.1",
62
62
  "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
63
63
  "@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
64
+ "@deepseek-ai/dsh-storage": "^0.1.5-rc.1",
64
65
  "@deepseek-ai/schemastery": "^3.18.2"
65
66
  },
66
67
  "dsh": {
@@ -79,7 +80,8 @@
79
80
  "chai": "6.2.2",
80
81
  "magic-string": "0.30.21",
81
82
  "tinyrainbow": "3.1.1",
82
- "vitest": "4.1.11"
83
+ "vitest": "4.1.11",
84
+ "zod": "4.6.1"
83
85
  },
84
86
  "scripts": {
85
87
  "build": "pnpm exec tsdown",
package/src/backend.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SessionId } from "@deepseek-ai/dsh-session";
2
2
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
3
+ import type { StorageRepository } from "./storage-takeover/types.ts";
3
4
 
4
5
  export interface SessionRow {
5
6
  fSessionId: string;
@@ -63,6 +64,9 @@ export interface BackendTx {
63
64
 
64
65
  updateSeedLength(id: SessionId, seedLength: number): Promise<void>;
65
66
 
67
+ /** 从事件表重算会话标题(最后一条 `session/title`)并写回会话行。 */
68
+ refreshTitle(id: SessionId): Promise<void>;
69
+
66
70
  insertEvents(events: EventInsert[]): Promise<void>;
67
71
 
68
72
  insertBridges(
@@ -91,6 +95,9 @@ export interface Backend {
91
95
 
92
96
  readonly storeIdentity: string;
93
97
 
98
+ /** storages 接管表(workspace 域与投影 checkpoint)的访问层:与事件日志同介质。 */
99
+ readonly storage: StorageRepository;
100
+
94
101
  open(): Promise<void>;
95
102
 
96
103
  getSession(id: SessionId): Promise<SessionRow | undefined>;
package/src/branch.ts CHANGED
@@ -340,6 +340,8 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
340
340
  await tx.updateSeedLength(id, denseBoundary + 1);
341
341
  shrunk = denseBoundary + 1;
342
342
  }
343
+ // 截断可能删掉最新的 session/title:标题随保留前缀重算。
344
+ await tx.refreshTitle(id);
343
345
  await tx.bumpRevision(id);
344
346
  return shrunk;
345
347
  });
@@ -9,3 +9,8 @@ export const tSchemaMeta = tables["t_schema_meta"]!;
9
9
  export const tSessions = tables["t_sessions"]!;
10
10
  export const tEvents = tables["t_events"]!;
11
11
  export const tSessionEvents = tables["t_session_events"]!;
12
+ export const tStorageUnits = tables["t_storage_units"]!;
13
+ export const tWorkspaces = tables["t_workspaces"]!;
14
+ export const tWorkspaceSessions = tables["t_workspace_sessions"]!;
15
+ export const tWorkspaceState = tables["t_workspace_state"]!;
16
+ export const tSessionProjcacheRows = tables["t_session_projcache_row"]!;
@@ -9,3 +9,8 @@ export const tSchemaMeta = tables["t_schema_meta"]!;
9
9
  export const tSessions = tables["t_sessions"]!;
10
10
  export const tEvents = tables["t_events"]!;
11
11
  export const tSessionEvents = tables["t_session_events"]!;
12
+ export const tStorageUnits = tables["t_storage_units"]!;
13
+ export const tWorkspaces = tables["t_workspaces"]!;
14
+ export const tWorkspaceSessions = tables["t_workspace_sessions"]!;
15
+ export const tWorkspaceState = tables["t_workspace_state"]!;
16
+ export const tSessionProjcacheRows = tables["t_session_projcache_row"]!;
@@ -3,12 +3,22 @@ import { schemaMeta } from "./schema-meta.ts";
3
3
  import { sessions } from "./sessions.ts";
4
4
  import { events } from "./events.ts";
5
5
  import { sessionEvents } from "./session-events.ts";
6
+ import { storageUnits } from "./storage-units.ts";
7
+ import { workspaces } from "./workspaces.ts";
8
+ import { workspaceSessions } from "./workspace-sessions.ts";
9
+ import { workspaceState } from "./workspace-state.ts";
10
+ import { sessionProjcacheRows } from "./session-projcache-rows.ts";
6
11
 
7
12
  export { persistenceState };
8
13
  export { schemaMeta };
9
14
  export { sessions };
10
15
  export { events };
11
16
  export { sessionEvents };
17
+ export { storageUnits };
18
+ export { workspaces };
19
+ export { workspaceSessions };
20
+ export { workspaceState };
21
+ export { sessionProjcacheRows };
12
22
 
13
23
  export const sqliteTableDefs = [
14
24
  persistenceState,
@@ -16,6 +26,11 @@ export const sqliteTableDefs = [
16
26
  sessions,
17
27
  events,
18
28
  sessionEvents,
29
+ storageUnits,
30
+ workspaces,
31
+ workspaceSessions,
32
+ workspaceState,
33
+ sessionProjcacheRows,
19
34
  ] as const;
20
35
 
21
36
  export const postgresTableDefs = [
@@ -24,4 +39,9 @@ export const postgresTableDefs = [
24
39
  sessions,
25
40
  events,
26
41
  sessionEvents,
42
+ storageUnits,
43
+ workspaces,
44
+ workspaceSessions,
45
+ workspaceState,
46
+ sessionProjcacheRows,
27
47
  ] as const;
@@ -0,0 +1,27 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /**
4
+ * 投影 checkpoint 行(上游 checkpoint 的 `(sessionId, key, ver, seq, val)`
5
+ * 逐行落库):`f_seq` 是该行的日志水位、`f_ver` 是投影单元的 stateVersion。
6
+ * 行是折出捷径、非权威,版本不匹配时整行丢弃。
7
+ *
8
+ * checkpoint 的日志 identity 不另存一份:它与 `t_sessions` 的行是 1:1,直接
9
+ * 复用该行的 `f_version` / `f_created_at` / `f_cwd` / `f_seed_length` 做校验。
10
+ */
11
+ export const sessionProjcacheRows: TableDef = {
12
+ name: "t_session_projcache_row",
13
+ columns: {
14
+ f_id: { type: "serial", primaryKey: true },
15
+ f_session_id: {
16
+ type: "text",
17
+ notNull: true,
18
+ references: { table: "t_sessions", column: "f_session_id", onDelete: "cascade" },
19
+ },
20
+ f_key: { type: "text", notNull: true },
21
+ f_ver: { type: "integer", notNull: true },
22
+ f_seq: { type: "integer", notNull: true },
23
+ f_val: { type: "text", notNull: true },
24
+ },
25
+ uniques: { uq_session_projcache_row_session_key: { columns: ["f_session_id", "f_key"] } },
26
+ indexes: { idx_session_projcache_row_key: { columns: ["f_key"] } },
27
+ };
@@ -16,5 +16,11 @@ export const sessions: TableDef = {
16
16
  f_delegation_depth: { type: "integer" },
17
17
  f_incarnation: { type: "text", notNull: true },
18
18
  f_revision: { type: "integer", notNull: true },
19
+ /** 归档标记:非空即该会话已归档(毫秒时间戳),NULL 表示未归档。 */
20
+ f_archived_at: { type: "bigint" },
21
+ /** 最新会话标题(最后一条 `session/title` 事件的 title),NULL 表示尚无标题。 */
22
+ f_title: { type: "text" },
23
+ /** `f_title` 来源事件的稠密 seq(列表 hint 的水位)。 */
24
+ f_title_seq: { type: "integer" },
19
25
  },
20
26
  };
@@ -0,0 +1,10 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /** storage 域版本账本:域首次打开时写入 descriptor version,其后按 accepted 集合校验。 */
4
+ export const storageUnits: TableDef = {
5
+ name: "t_storage_units",
6
+ columns: {
7
+ f_name: { type: "text", primaryKey: true },
8
+ f_version: { type: "integer", notNull: true },
9
+ },
10
+ };
@@ -0,0 +1,24 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /**
4
+ * workspace 的会话归属(上游 `workspaceRecord.sessionIds` 的数组顺序):
5
+ * 一个会话一行,`f_position` 是归属显示顺序。会话 id 有独立索引,可按
6
+ * session 反查归属 workspace。
7
+ */
8
+ export const workspaceSessions: TableDef = {
9
+ name: "t_workspace_sessions",
10
+ columns: {
11
+ f_id: { type: "serial", primaryKey: true },
12
+ f_workspace_id: {
13
+ type: "text",
14
+ notNull: true,
15
+ references: { table: "t_workspaces", column: "f_workspace_id", onDelete: "cascade" },
16
+ },
17
+ f_session_id: { type: "text", notNull: true },
18
+ f_position: { type: "integer", notNull: true },
19
+ },
20
+ uniques: {
21
+ uq_workspace_sessions_workspace_session: { columns: ["f_workspace_id", "f_session_id"] },
22
+ },
23
+ indexes: { idx_workspace_sessions_session_id: { columns: ["f_session_id"] } },
24
+ };
@@ -0,0 +1,18 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /**
4
+ * workspace 域 global 单例(上游 `workspaceDomainState`):`initialized` 是
5
+ * 引导标记,`pendingMutation` 拆成操作与目标两列(create / delete 的可恢复
6
+ * 两写标记)。显示顺序(`workspaceIds`)在 `t_workspaces.f_position`,
7
+ * 归档集在 `t_session_archives`。
8
+ */
9
+ export const workspaceState: TableDef = {
10
+ name: "t_workspace_state",
11
+ columns: {
12
+ f_singleton: { type: "integer", primaryKey: true },
13
+ f_initialized: { type: "integer", notNull: true },
14
+ f_pending_operation: { type: "text" },
15
+ f_pending_workspace_id: { type: "text" },
16
+ },
17
+ checks: { ck_workspace_state_singleton: { expression: "f_singleton = 1" } },
18
+ };
@@ -0,0 +1,19 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ /**
4
+ * workspace 域记录表(上游 `workspace` 域 v2 的 `workspaces` 表):
5
+ * 记录本身拆成语义列;会话归属在 {@link workspaceSessions}(带顺序),
6
+ * 显示顺序由 `f_position` 承载(未进入 `workspaceIds` 的记录为 -1)。
7
+ */
8
+ export const workspaces: TableDef = {
9
+ name: "t_workspaces",
10
+ columns: {
11
+ f_id: { type: "serial", primaryKey: true },
12
+ f_workspace_id: { type: "text", notNull: true, unique: true },
13
+ f_path: { type: "text", notNull: true },
14
+ f_title: { type: "text", notNull: true },
15
+ f_created_at: { type: "text", notNull: true },
16
+ f_updated_at: { type: "text", notNull: true },
17
+ f_position: { type: "integer", notNull: true, default: -1 },
18
+ },
19
+ };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 一次性导入:把旧 `$DSH_HOME/storages` 的 JSON 数据搬进 session-rdb 的
3
+ * 语义专用表(显式命令触发,不做启动隐式导入)。旧文件保留不删,便于对账与
4
+ * 回退。
5
+ *
6
+ * 支持两种来源布局:
7
+ * - `workspace.json`:single 文档(`{unit, global, tables}`);
8
+ * - `session_projcache/sessions/*.json`:v7 per-record 文档;目录缺失时回退
9
+ * 读旧 single 文档 `session_projcache.json`。
10
+ */
11
+
12
+ import { readFile, readdir } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import type {
15
+ ProjectionCheckpoint,
16
+ StorageRepository,
17
+ WorkspaceDomainState,
18
+ WorkspaceRecord,
19
+ } from "./storage-takeover/types.ts";
20
+ import { SessionId } from "@deepseek-ai/dsh-session";
21
+
22
+ /** 一条待导入的投影 checkpoint:identity 不落库(复用会话行),只导行。 */
23
+ interface ImportedProjcache {
24
+ sessionId: SessionId;
25
+ rows: ProjectionCheckpoint;
26
+ }
27
+
28
+ /** 导入统计(按记录数)。 */
29
+ export interface StoragesImportResult {
30
+ /** 导入的 workspace 记录数。 */
31
+ workspaces: number;
32
+ /** 是否导入了 workspace global 单例。 */
33
+ workspaceState: boolean;
34
+ /** 导入的投影 checkpoint 记录数。 */
35
+ projcache: number;
36
+ }
37
+
38
+ /** 导入参数。 */
39
+ export interface StoragesImportOptions {
40
+ /** Harness home(含 `storages/` 的目录)。 */
41
+ dshHome: string;
42
+ }
43
+
44
+ const WORKSPACE_UNIT = "workspace";
45
+ const WORKSPACE_UNIT_VERSIONS = new Set([2]);
46
+ const PROJCACHE_UNIT = "session_projcache";
47
+ const PROJCACHE_UNIT_VERSIONS = new Set([7, 3, 4, 5, 6]);
48
+
49
+ /**
50
+ * Import the legacy storages documents into the rdb tables.
51
+ * @param repository - storages takeover access layer (medium already open).
52
+ * @param options - harness home holding the legacy `storages/` tree.
53
+ * @returns per-record import counts.
54
+ */
55
+ export async function importStorages(
56
+ repository: StorageRepository,
57
+ options: StoragesImportOptions,
58
+ ): Promise<StoragesImportResult> {
59
+ const storagesRoot = join(options.dshHome, "storages");
60
+ const result: StoragesImportResult = { workspaces: 0, workspaceState: false, projcache: 0 };
61
+
62
+ const workspaceDocument = await readJson(join(storagesRoot, `${WORKSPACE_UNIT}.json`));
63
+ if (workspaceDocument !== undefined) {
64
+ const tables = unitTables(workspaceDocument, WORKSPACE_UNIT, WORKSPACE_UNIT_VERSIONS);
65
+ await repository.insertUnitVersion(WORKSPACE_UNIT, WORKSPACE_UNIT_VERSIONS.values().next().value as number);
66
+ for (const [id, record] of Object.entries(tables["workspaces"] ?? {})) {
67
+ await repository.putWorkspace(id, record as WorkspaceRecord);
68
+ result.workspaces += 1;
69
+ }
70
+ if (workspaceDocument.global !== undefined && workspaceDocument.global !== null) {
71
+ await repository.writeWorkspaceState(workspaceDocument.global as WorkspaceDomainState);
72
+ result.workspaceState = true;
73
+ }
74
+ }
75
+
76
+ const projcacheEntries = await readProjcacheEntries(storagesRoot);
77
+ if (projcacheEntries.length > 0) {
78
+ await repository.insertUnitVersion(
79
+ PROJCACHE_UNIT,
80
+ PROJCACHE_UNIT_VERSIONS.values().next().value as number,
81
+ );
82
+ for (const entry of projcacheEntries) {
83
+ // 只导行:checkpoint 的 identity 由 t_sessions 的行承载(会话不存在时
84
+ // 外键拒绝写入,导入 fail loud)。
85
+ await repository.putProjcache(entry.sessionId, entry.rows);
86
+ result.projcache += 1;
87
+ }
88
+ }
89
+
90
+ return result;
91
+ }
92
+
93
+ /** Read one JSON document; a missing file is `undefined`, malformed JSON fails loud. */
94
+ async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
95
+ let text: string;
96
+ try {
97
+ text = await readFile(path, "utf8");
98
+ } catch (error: unknown) {
99
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
100
+ throw error;
101
+ }
102
+ return JSON.parse(text) as Record<string, unknown>;
103
+ }
104
+
105
+ /** Validate one legacy single document's unit identity and return its tables map. */
106
+ function unitTables(
107
+ document: Record<string, unknown>,
108
+ name: string,
109
+ versions: ReadonlySet<number>,
110
+ ): Record<string, Record<string, unknown>> {
111
+ const unit = document["unit"] as { name?: unknown; version?: unknown } | undefined;
112
+ if (unit?.name !== name) {
113
+ throw new Error(`storages document is not the '${name}' unit`);
114
+ }
115
+ if (typeof unit.version !== "number" || !versions.has(unit.version)) {
116
+ throw new Error(
117
+ `storages document '${name}' is stamped version ${String(unit.version)}, not in the accepted set`,
118
+ );
119
+ }
120
+ const tables = document["tables"];
121
+ if (typeof tables !== "object" || tables === null) {
122
+ throw new Error(`storages document '${name}' has no tables object`);
123
+ }
124
+ return tables as Record<string, Record<string, unknown>>;
125
+ }
126
+
127
+ /**
128
+ * Read projection checkpoints from the per-record directory, falling back to
129
+ * the legacy whole-unit document when the directory is absent.
130
+ */
131
+ async function readProjcacheEntries(storagesRoot: string): Promise<ImportedProjcache[]> {
132
+ const entries: ImportedProjcache[] = [];
133
+ const sessionsDir = join(storagesRoot, PROJCACHE_UNIT, "sessions");
134
+ let files: string[];
135
+ try {
136
+ files = (await readdir(sessionsDir)).filter((name) => name.endsWith(".json")).sort();
137
+ } catch (error: unknown) {
138
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
139
+ return readLegacyProjcache(join(storagesRoot, `${PROJCACHE_UNIT}.json`));
140
+ }
141
+ for (const file of files) {
142
+ const document = (await readJson(join(sessionsDir, file))) as
143
+ | { version?: unknown; record?: unknown }
144
+ | undefined;
145
+ if (document === undefined) continue;
146
+ if (typeof document.version !== "number" || !PROJCACHE_UNIT_VERSIONS.has(document.version)) {
147
+ // Stale per-record document: the cache reads it as absent, so the import
148
+ // discards it too instead of stamping it current.
149
+ continue;
150
+ }
151
+ const record = document.record as { identity?: unknown; rows?: unknown } | undefined;
152
+ if (record === undefined || typeof record !== "object") continue;
153
+ entries.push({
154
+ sessionId: SessionId(file.slice(0, -".json".length)),
155
+ rows: (record.rows ?? {}) as ProjectionCheckpoint,
156
+ });
157
+ }
158
+ return entries;
159
+ }
160
+
161
+ /** Read the legacy whole-unit projection cache document (`{unit, tables:{sessions}}`). */
162
+ async function readLegacyProjcache(path: string): Promise<ImportedProjcache[]> {
163
+ const document = await readJson(path);
164
+ if (document === undefined) return [];
165
+ const tables = unitTables(document, PROJCACHE_UNIT, PROJCACHE_UNIT_VERSIONS);
166
+ return Object.entries(tables["sessions"] ?? {}).map(([sessionId, record]) => {
167
+ const value = record as { rows?: unknown };
168
+ return {
169
+ sessionId: SessionId(sessionId),
170
+ rows: (value.rows ?? {}) as ProjectionCheckpoint,
171
+ };
172
+ });
173
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,11 @@ import { PostgresBackend } from "./postgres.ts";
51
51
  import { SessionBranchRdb } from "./branch.ts";
52
52
  import { registerSessionImport } from "./import.ts";
53
53
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
54
+ import { installStorageTakeover } from "./storage-takeover/index.ts";
55
+
56
+ /** 投影缓存默认写节流(与上游 base 装配的部署值一致)。 */
57
+ const DEFAULT_PROJECTION_WRITE_EVERY_EVENTS = 200;
58
+ const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5000;
54
59
 
55
60
  export { SCHEMA_VERSION } from "./schema.ts";
56
61
  export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
@@ -79,6 +84,14 @@ export interface SessionPersistenceRdbInternals {
79
84
  registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void;
80
85
  }
81
86
 
87
+ /** 投影 checkpoint 写节流(替换上游 `session-projection-cache` 的 Config)。 */
88
+ export interface ProjectionCacheOptions {
89
+ /** 两次强制点之间累积多少已提交事件强制落盘(默认 200)。 */
90
+ writeEveryEvents?: number;
91
+ /** 脏 checkpoint 在强制点之间允许滞留的最长毫秒数(默认 5000)。 */
92
+ writeIntervalMs?: number;
93
+ }
94
+
82
95
  export type Config =
83
96
  | {
84
97
  type: "sqlite";
@@ -88,6 +101,8 @@ export type Config =
88
101
  journalMode?: JournalMode;
89
102
 
90
103
  busyTimeout?: number;
104
+
105
+ projectionCache?: ProjectionCacheOptions;
91
106
  }
92
107
  | {
93
108
  type: "postgres";
@@ -95,6 +110,8 @@ export type Config =
95
110
  connectionString: string;
96
111
 
97
112
  schema?: string;
113
+
114
+ projectionCache?: ProjectionCacheOptions;
98
115
  };
99
116
 
100
117
  /** 一个已创建但未 materialize 的会话(本进程可见,其他进程不可见)。 */
@@ -441,11 +458,29 @@ export class SessionPersistenceRdb extends SessionPersistence {
441
458
  path: z.string().required(),
442
459
  journalMode: z.union(["wal", "delete", "truncate", "persist"] as const).default("wal"),
443
460
  busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
461
+ projectionCache: z
462
+ .object({
463
+ writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
464
+ writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
465
+ })
466
+ .default({
467
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
468
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
469
+ }),
444
470
  }),
445
471
  z.object({
446
472
  type: z.const("postgres"),
447
473
  connectionString: z.string().required(),
448
474
  schema: z.string().default("public"),
475
+ projectionCache: z
476
+ .object({
477
+ writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
478
+ writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
479
+ })
480
+ .default({
481
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
482
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
483
+ }),
449
484
  }),
450
485
  ]);
451
486
 
@@ -499,6 +534,18 @@ export class SessionPersistenceRdb extends SessionPersistence {
499
534
  new SessionBranchRdb(this.ctx);
500
535
  // 导入端点:webServer + connection 就绪后注册 `/api/session.import`。
501
536
  registerSessionImport(this.ctx, this);
537
+ // storages 接管:storage hub 的 `rdb` 后端(workspace 域)与
538
+ // `ctx.sessionProjectionCache` 服务(替换上游插件)。
539
+ installStorageTakeover(this.ctx, {
540
+ repository: this.backend.storage,
541
+ ready: this.ready,
542
+ projectionCache: {
543
+ writeEveryEvents:
544
+ this.config.projectionCache?.writeEveryEvents ?? DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
545
+ writeIntervalMs:
546
+ this.config.projectionCache?.writeIntervalMs ?? DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
547
+ },
548
+ });
502
549
  }
503
550
 
504
551
  private async init(): Promise<void> {
@@ -1249,6 +1296,12 @@ async function appendEventTail(
1249
1296
  }
1250
1297
  if (eventRows.length > 0) await tx.insertEvents(eventRows);
1251
1298
  await tx.insertBridges(bridgeRows);
1299
+ // 标题是会话数据的一部分:批内出现 session/title 时同步刷新会话行,
1300
+ // 列表消费直接取列(sessions.title 直取)。
1301
+ // 插件合并类型不在 core 的判别联合内,按字符串比较(同 eventDimensions)。
1302
+ if (events.some((event) => (event.type as string) === "session/title")) {
1303
+ await tx.refreshTitle(meta.id);
1304
+ }
1252
1305
  return { headEventId: parentId, headSequence: nextSeq - 1 };
1253
1306
  }
1254
1307
 
package/src/log.ts CHANGED
@@ -695,3 +695,22 @@ export function toJsonlArtifact(
695
695
  }
696
696
  return lines.join("\n");
697
697
  }
698
+
699
+ /**
700
+ * 事件行 JSON → `session/title` 的标题文本。存储层写路径保证 `f_data` 是完整
701
+ * 事件 JSON;形状不符(或损坏)按「无标题」处理——标题是派生显示数据。
702
+ * @param data - `t_events.f_data` 原文。
703
+ * @returns 标题文本,或 `undefined`。
704
+ */
705
+ export function titleOfEventData(data: string): string | undefined {
706
+ try {
707
+ // 当前世代:`{...envelope, data:{title}}`;旧世代(v0-v2)的 f_data 直接
708
+ // 平铺事件 data,title 在顶层。两者都读,避免 legacy 会话的标题被清空。
709
+ const value = JSON.parse(data) as { data?: { title?: unknown }; title?: unknown };
710
+ const title = value.data?.title ?? value.title;
711
+ return typeof title === "string" ? title : undefined;
712
+ } catch {
713
+ // 写路径已保证 JSON;此处只可能是损坏数据,读作无标题。
714
+ return undefined;
715
+ }
716
+ }