@morlay/session-rdb 0.0.16-alpha.4 → 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.
- package/README.md +44 -10
- package/dist/artifact.d.mts +9 -2
- package/dist/artifact.mjs +3 -3
- package/dist/{import-BPEHfHNk.mjs → import-DZIAjOUr.mjs} +2 -1
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +1 -1
- package/dist/{index-CgAqCQAb.d.mts → index-BEKoV1Uy.d.mts} +11 -2
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +509 -6
- package/dist/{log-BIuXv09P.mjs → log-DO69NQnn.mjs} +16 -1
- package/dist/schema-Bo6Hy5gW.d.mts +3476 -0
- package/dist/{sqlite-IH5I48aL.mjs → sqlite-BIPdMNQb.mjs} +504 -6
- package/dist/storage.d.mts +10 -2
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +65 -0
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +1236 -0
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +10 -0
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +1236 -0
- package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +66 -0
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +958 -0
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +13 -0
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +958 -0
- package/package.json +20 -18
- package/src/backend.ts +7 -0
- package/src/branch.ts +2 -0
- package/src/drizzle/postgres-v3.ts +5 -0
- package/src/drizzle/sqlite-v3.ts +5 -0
- package/src/entities/v3/index.ts +20 -0
- package/src/entities/v3/session-projcache-rows.ts +27 -0
- package/src/entities/v3/sessions.ts +6 -0
- package/src/entities/v3/storage-units.ts +10 -0
- package/src/entities/v3/workspace-sessions.ts +24 -0
- package/src/entities/v3/workspace-state.ts +18 -0
- package/src/entities/v3/workspaces.ts +19 -0
- package/src/import-storages.ts +173 -0
- package/src/index.ts +53 -0
- package/src/log.ts +19 -0
- package/src/postgres.ts +70 -2
- package/src/schema.ts +16 -2
- package/src/sqlite.ts +87 -4
- package/src/storage-takeover/index.ts +60 -0
- package/src/storage-takeover/projection-cache.ts +445 -0
- package/src/storage-takeover/repository.ts +420 -0
- package/src/storage-takeover/storage-backend.ts +177 -0
- package/src/storage-takeover/types.ts +82 -0
- 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.
|
|
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,32 +35,33 @@
|
|
|
35
35
|
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@morlay/session-branch": "^0.0.
|
|
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"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@deepseek-ai/dsh-session-projection": "^0.1.5-
|
|
45
|
-
"@deepseek-ai/dsh-session-projection-cache": "^0.1.5-
|
|
46
|
-
"@deepseek-ai/dsh-session-turn-outline": "^0.1.5-
|
|
47
|
-
"@deepseek-ai/dsh-storage": "^0.1.5-
|
|
48
|
-
"@deepseek-ai/dsh-storage-domain": "^0.1.5-
|
|
49
|
-
"@deepseek-ai/dsh-
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
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-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
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
56
|
-
"@deepseek-ai/dsh-invariants": "^0.1.5-
|
|
57
|
-
"@deepseek-ai/dsh-llm": "^0.1.5-
|
|
58
|
-
"@deepseek-ai/dsh-scope": "^0.1.5-
|
|
59
|
-
"@deepseek-ai/dsh-session": "^0.1.5-
|
|
60
|
-
"@deepseek-ai/dsh-session-format": "^0.1.5-
|
|
61
|
-
"@deepseek-ai/dsh-session-format-catalog": "^0.1.5-
|
|
62
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.5-
|
|
63
|
-
"@deepseek-ai/dsh-settings": "^0.1.5-
|
|
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",
|
|
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"]!;
|
package/src/drizzle/sqlite-v3.ts
CHANGED
|
@@ -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"]!;
|
package/src/entities/v3/index.ts
CHANGED
|
@@ -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
|
+
}
|