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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +18 -3
  2. package/dist/artifact.d.mts +118 -2
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
  5. package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
  6. package/dist/branch-CG1PUlO6.mjs +833 -0
  7. package/dist/deletion-SSX7IqU8.mjs +35 -0
  8. package/dist/deletion.d.mts +1 -1
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -1
  12. package/dist/export.mjs +31 -55
  13. package/dist/import.d.mts +1 -1
  14. package/dist/import.mjs +31 -76
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1561 -4
  18. package/dist/open-YoyD_kah.mjs +561 -0
  19. package/dist/organize.d.mts +9 -0
  20. package/dist/organize.mjs +30 -0
  21. package/dist/route-DpfY0zq-.mjs +114 -0
  22. package/dist/storage.d.mts +41 -48
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -1
  25. package/dist/testing.mjs +25 -4
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +7 -40
  29. package/dist/write-guard-CYrY58GM.mjs +174 -0
  30. package/package.json +32 -36
  31. package/src/artifact.ts +4 -1
  32. package/src/backend-rdb.ts +331 -0
  33. package/src/backend.ts +32 -15
  34. package/src/{branch.ts → branch/index.ts} +109 -110
  35. package/src/branch/port.ts +62 -0
  36. package/src/branch/semantics.ts +69 -0
  37. package/src/db/open.ts +180 -0
  38. package/src/db/stats.ts +449 -0
  39. package/src/deletion-error.ts +17 -0
  40. package/src/deletion.ts +19 -72
  41. package/src/entities/index.ts +16 -1
  42. package/src/entities/v3/event-usage.ts +3 -3
  43. package/src/entities/v3/events.ts +3 -3
  44. package/src/entities/v3/index.ts +4 -16
  45. package/src/entities/v3/persistence-state.ts +3 -3
  46. package/src/entities/v3/schema-meta.ts +3 -3
  47. package/src/entities/v3/session-counts.ts +3 -3
  48. package/src/entities/v3/session-events.ts +3 -3
  49. package/src/entities/v3/session-projcache-rows.ts +3 -3
  50. package/src/entities/v3/session-usage.ts +3 -3
  51. package/src/entities/v3/sessions.ts +3 -3
  52. package/src/entities/v3/storage-records.ts +17 -0
  53. package/src/entities/v3/storage-units.ts +3 -3
  54. package/src/{schema.ts → event-shape.ts} +0 -46
  55. package/src/export.ts +32 -78
  56. package/src/handles.ts +349 -0
  57. package/src/http/route.ts +178 -0
  58. package/src/import-storages.ts +5 -2
  59. package/src/import.ts +63 -125
  60. package/src/index.ts +15 -1666
  61. package/src/legacy.ts +3 -1
  62. package/src/live-routing.ts +205 -0
  63. package/src/log/export.ts +84 -0
  64. package/src/{log.ts → log/repair.ts} +1 -268
  65. package/src/log/rows.ts +107 -0
  66. package/src/log/usage-row.ts +64 -0
  67. package/src/migrations.ts +196 -0
  68. package/src/organize.ts +38 -0
  69. package/src/read-path.ts +190 -0
  70. package/src/routes.ts +2 -1
  71. package/src/rows.ts +25 -77
  72. package/src/session-ops.ts +444 -0
  73. package/src/session-store.ts +675 -0
  74. package/src/stats-fold.ts +44 -0
  75. package/src/storage-takeover/index.ts +2 -5
  76. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  77. package/src/storage-takeover/projection-cache.ts +122 -71
  78. package/src/storage-takeover/read-model.ts +189 -0
  79. package/src/storage-takeover/repository.ts +236 -442
  80. package/src/storage-takeover/storage-backend.ts +44 -159
  81. package/src/storage-takeover/types.ts +45 -19
  82. package/src/storage-takeover/workspace-documents.ts +65 -0
  83. package/src/storage.ts +4 -2
  84. package/src/testing.ts +21 -1
  85. package/src/usage.ts +11 -65
  86. package/src/version.ts +13 -0
  87. package/src/write-path.ts +286 -0
  88. package/dist/branch-CoPCRKer.mjs +0 -401
  89. package/dist/deletion-CWl89TIR.mjs +0 -2249
  90. package/dist/gc.d.mts +0 -4
  91. package/dist/gc.mjs +0 -48
  92. package/dist/log-CcWy46VE.mjs +0 -558
  93. package/dist/routes-hZjTcX_R.mjs +0 -9
  94. package/dist/sqlite-BNRd1aqR.mjs +0 -1778
  95. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  96. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  97. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  99. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  101. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  103. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  104. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  105. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  106. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  107. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  108. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  109. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  110. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  111. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  112. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  114. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  116. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  118. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  120. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  122. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  123. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  124. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  125. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  126. package/src/adapters/index.ts +0 -12
  127. package/src/adapters/to-postgres.ts +0 -93
  128. package/src/adapters/to-sqlite.ts +0 -83
  129. package/src/adapters/types.ts +0 -44
  130. package/src/drizzle/postgres-v2.ts +0 -10
  131. package/src/drizzle/postgres-v3.ts +0 -18
  132. package/src/drizzle/sqlite-v2.ts +0 -9
  133. package/src/drizzle/sqlite-v3.ts +0 -18
  134. package/src/entities/v2/index.ts +0 -20
  135. package/src/entities/v2/session-events.ts +0 -10
  136. package/src/entities/v3/schedule-tasks.ts +0 -16
  137. package/src/entities/v3/workspace-sessions.ts +0 -19
  138. package/src/entities/v3/workspace-state.ts +0 -12
  139. package/src/entities/v3/workspaces.ts +0 -14
  140. package/src/gc.ts +0 -77
  141. package/src/postgres.ts +0 -1015
  142. package/src/sqlite.ts +0 -1096
@@ -0,0 +1,64 @@
1
+ import { localDayKey } from "../usage.ts";
2
+
3
+ export interface EventUsageRow {
4
+ fEventId: string;
5
+ fCreatedAt: number;
6
+ fDay: string;
7
+ fProvider: string | null;
8
+ fModel: string | null;
9
+ fReferenced: number;
10
+ fSubagent: number;
11
+ fInputTokens: number;
12
+ fOutputTokens: number;
13
+ fCacheReadTokens: number;
14
+ fReasoningTokens: number;
15
+ fTotalTokens: number;
16
+ }
17
+
18
+ function numericField(value: unknown): number {
19
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
20
+ }
21
+
22
+ function textField(value: unknown): string | null {
23
+ return typeof value === "string" && value !== "" ? value : null;
24
+ }
25
+
26
+ // 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有;没有用量时为 undefined):写路径据此记录
27
+ // `t_event_usage`,统计因此不必逐行解析 JSON,两种 `f_data` 结构(带信封 / 老格式)都认。读侧维度一起物化:
28
+ // 本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务),`f_subagent` 取写入它的会话是否子代理。
29
+ export function usageRowOf(
30
+ event: {
31
+ fEventId: string;
32
+ fCreatedAt: number;
33
+ fType: string;
34
+ fData: string;
35
+ },
36
+ subagent: boolean,
37
+ ): EventUsageRow | undefined {
38
+ if (event.fType !== "assistant/message") return undefined;
39
+ let parsed: Record<string, unknown>;
40
+ try {
41
+ parsed = JSON.parse(event.fData) as Record<string, unknown>;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ const envelope = (parsed["data"] ?? parsed) as Record<string, unknown>;
46
+ const usage = envelope["usage"];
47
+ if (typeof usage !== "object" || usage === null) return undefined;
48
+ const source = (envelope["message"] as { source?: Record<string, unknown> } | undefined)?.source;
49
+ const row: EventUsageRow = {
50
+ fEventId: event.fEventId,
51
+ fCreatedAt: event.fCreatedAt,
52
+ fDay: localDayKey(event.fCreatedAt),
53
+ fProvider: textField(source?.["provider"]),
54
+ fModel: textField(source?.["model"]),
55
+ fReferenced: 1,
56
+ fSubagent: subagent ? 1 : 0,
57
+ fInputTokens: numericField((usage as Record<string, unknown>)["inputTokens"]),
58
+ fOutputTokens: numericField((usage as Record<string, unknown>)["outputTokens"]),
59
+ fCacheReadTokens: numericField((usage as Record<string, unknown>)["cacheReadTokens"]),
60
+ fReasoningTokens: numericField((usage as Record<string, unknown>)["reasoningTokens"]),
61
+ fTotalTokens: numericField((usage as Record<string, unknown>)["totalTokens"]),
62
+ };
63
+ return row;
64
+ }
@@ -0,0 +1,196 @@
1
+ import { E, defineTable, type Migration } from "@morlay/rdbkit";
2
+ import { sessions } from "./entities/index.ts";
3
+ import {
4
+ migrateLegacyDomainStorage,
5
+ type LegacyLinkRow,
6
+ type LegacyScheduleRow,
7
+ type LegacyWorkspaceRow,
8
+ type LegacyWorkspaceStateRow,
9
+ } from "./storage-takeover/legacy-domain-migration.ts";
10
+ import { createStorageRepository } from "./storage-takeover/repository.ts";
11
+
12
+ // 旧语义表的定义:只用于读(旧表保留可回退,这里不改它们)。列名与当年的 DDL 一致。
13
+ const legacyWorkspaces = defineTable({
14
+ name: "t_workspaces",
15
+ columns: {
16
+ f_workspace_id: { type: "text", primaryKey: true },
17
+ f_path: { type: "text" },
18
+ f_title: { type: "text" },
19
+ f_created_at: { type: "text" },
20
+ f_updated_at: { type: "text" },
21
+ f_position: { type: "integer" },
22
+ },
23
+ });
24
+
25
+ const legacyLinks = defineTable({
26
+ name: "t_workspace_sessions",
27
+ columns: {
28
+ f_workspace_id: { type: "text" },
29
+ f_session_id: { type: "text" },
30
+ f_position: { type: "integer" },
31
+ },
32
+ });
33
+
34
+ const legacyWorkspaceState = defineTable({
35
+ name: "t_workspace_state",
36
+ columns: {
37
+ f_singleton: { type: "integer", primaryKey: true },
38
+ f_initialized: { type: "integer" },
39
+ f_pending_operation: { type: "text" },
40
+ f_pending_workspace_id: { type: "text" },
41
+ },
42
+ });
43
+
44
+ const legacyScheduleTasks = defineTable({
45
+ name: "t_schedule_tasks",
46
+ columns: {
47
+ f_id: { type: "text", primaryKey: true },
48
+ f_session_id: { type: "text" },
49
+ f_status: { type: "text" },
50
+ f_record: { type: "text" },
51
+ f_last_delivery: { type: "text" },
52
+ f_delivery_history: { type: "text" },
53
+ },
54
+ });
55
+
56
+ const legacyGlobals = defineTable({
57
+ name: "t_storage_globals",
58
+ columns: {
59
+ f_domain: { type: "text", primaryKey: true },
60
+ f_value: { type: "text" },
61
+ },
62
+ });
63
+
64
+ /**
65
+ * 旧语义表(workspace / schedule / cua 授权域)→ 通用域存储的一次性搬运。
66
+ *
67
+ * 旧表保留可回退;是否搬过由 `t_schema_migrations` 账本决定(只跑一次),不再需要 `t_schema_meta` 标记。
68
+ */
69
+ export function sessionMigrations(): readonly Migration[] {
70
+ return [
71
+ {
72
+ id: "20261028-legacy-domain-storage",
73
+ up: async (tx) => {
74
+ // 旧表不存在(新库 / 还没升到这一版):这次不搬、也不记账,以后真出现旧表还会再试。
75
+ const objects = new Set(await tx.objectNames());
76
+ if (!objects.has(legacyWorkspaces.name)) return false;
77
+
78
+ const workspaces: LegacyWorkspaceRow[] = [];
79
+ if (objects.has(legacyWorkspaces.name)) {
80
+ const table = tx.table(legacyWorkspaces);
81
+ for await (const row of table.select({
82
+ id: table.f_workspace_id,
83
+ path: table.f_path,
84
+ title: table.f_title,
85
+ createdAt: table.f_created_at,
86
+ updatedAt: table.f_updated_at,
87
+ position: table.f_position,
88
+ })) {
89
+ workspaces.push({
90
+ id: String(row.id),
91
+ path: String(row.path ?? ""),
92
+ title: String(row.title ?? ""),
93
+ createdAt: String(row.createdAt ?? ""),
94
+ updatedAt: String(row.updatedAt ?? ""),
95
+ position: Number(row.position ?? 0),
96
+ });
97
+ }
98
+ }
99
+
100
+ const links: LegacyLinkRow[] = [];
101
+ if (objects.has(legacyLinks.name)) {
102
+ const table = tx.table(legacyLinks);
103
+ for await (const row of table.select({
104
+ workspaceId: table.f_workspace_id,
105
+ sessionId: table.f_session_id,
106
+ position: table.f_position,
107
+ })) {
108
+ links.push({
109
+ workspaceId: String(row.workspaceId),
110
+ sessionId: String(row.sessionId),
111
+ position: Number(row.position ?? 0),
112
+ });
113
+ }
114
+ }
115
+
116
+ let state: LegacyWorkspaceStateRow | undefined;
117
+ if (objects.has(legacyWorkspaceState.name)) {
118
+ const table = tx.table(legacyWorkspaceState);
119
+ for await (const row of table
120
+ .select({
121
+ initialized: table.f_initialized,
122
+ pendingOperation: table.f_pending_operation,
123
+ pendingWorkspaceId: table.f_pending_workspace_id,
124
+ })
125
+ .where(E.eq(table.f_singleton, 1))
126
+ .limit(1)) {
127
+ state = {
128
+ initialized: Number(row.initialized ?? 0),
129
+ pendingOperation: row.pendingOperation,
130
+ pendingWorkspaceId: row.pendingWorkspaceId,
131
+ };
132
+ }
133
+ }
134
+
135
+ const tasks: LegacyScheduleRow[] = [];
136
+ if (objects.has(legacyScheduleTasks.name)) {
137
+ const table = tx.table(legacyScheduleTasks);
138
+ for await (const row of table.select({
139
+ id: table.f_id,
140
+ sessionId: table.f_session_id,
141
+ status: table.f_status,
142
+ record: table.f_record,
143
+ lastDelivery: table.f_last_delivery,
144
+ deliveryHistory: table.f_delivery_history,
145
+ })) {
146
+ tasks.push({
147
+ id: String(row.id),
148
+ sessionId: String(row.sessionId ?? ""),
149
+ status: String(row.status ?? ""),
150
+ record: String(row.record ?? ""),
151
+ lastDelivery: row.lastDelivery,
152
+ deliveryHistory: row.deliveryHistory,
153
+ });
154
+ }
155
+ }
156
+
157
+ const globals: Array<{ domain: string; value: unknown }> = [];
158
+ if (objects.has(legacyGlobals.name)) {
159
+ const table = tx.table(legacyGlobals);
160
+ for await (const row of table.select({
161
+ domain: table.f_domain,
162
+ value: table.f_value,
163
+ })) {
164
+ globals.push({ domain: String(row.domain), value: JSON.parse(String(row.value)) });
165
+ }
166
+ }
167
+
168
+ const sessionRows = sessions.table(tx);
169
+ const archived: string[] = [];
170
+ for await (const row of sessionRows
171
+ .select({ id: sessionRows.f_session_id })
172
+ .where(E.isNotNull(sessionRows.f_archived_at))
173
+ .orderBy(sessionRows.f_archived_at)) {
174
+ archived.push(String(row.id));
175
+ }
176
+ const pinned: Array<{ id: string; seq: number }> = [];
177
+ for await (const row of sessionRows
178
+ .select({ id: sessionRows.f_session_id, seq: sessionRows.f_pinned_seq })
179
+ .where(E.isNotNull(sessionRows.f_pinned_seq))
180
+ .orderBy(sessionRows.f_pinned_seq)) {
181
+ pinned.push({ id: String(row.id), seq: Number(row.seq) });
182
+ }
183
+
184
+ // 迁移已经在一个事务里:仓储的写用直通(不再 BEGIN),否则会变成嵌套事务。
185
+ const storage = createStorageRepository({
186
+ database: tx,
187
+ writeAtomically: async (fn) => await fn(tx),
188
+ });
189
+ await migrateLegacyDomainStorage(
190
+ { workspaces, links, state, archived, pinned, tasks, globals },
191
+ storage,
192
+ );
193
+ },
194
+ },
195
+ ];
196
+ }
@@ -0,0 +1,38 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import { SESSION_ORGANIZE_PATH } from "./routes.ts";
3
+ import { mountJsonRoute } from "./http/route.ts";
4
+ import type { SessionPersistenceRdb } from "./session-store.ts";
5
+
6
+ export { SESSION_ORGANIZE_PATH };
7
+
8
+ interface OrganizeAgentLike {
9
+ cancel?(cause: { kind: "user" }, options?: { keepInbox?: boolean }): void;
10
+ whenIdle(): Promise<void>;
11
+ }
12
+
13
+ // 让所有运行中的 agent 先退场:整理数据要重写事件与统计表,运行中的写路径必须停下来。
14
+ async function stopRunningAgents(ctx: Context): Promise<number> {
15
+ const agents = ctx.get("agents") as { list(): OrganizeAgentLike[] } | undefined;
16
+ const running = agents?.list() ?? [];
17
+ for (const agent of running) agent.cancel?.({ kind: "user" }, { keepInbox: true });
18
+ await Promise.all(running.map((agent) => agent.whenIdle()));
19
+ return running.length;
20
+ }
21
+
22
+ /**
23
+ * 整理数据通道:停 agent → 回收孤儿(会话 / 事件行)→ 重算派生统计 → VACUUM。
24
+ *
25
+ * 不只是清理孤儿:三张派生统计表是可销毁重建的,整理时一并整表重算,修掉旁路累加漏记或旧数据漂移。
26
+ */
27
+ export function registerSessionOrganize(ctx: Context, persistence: SessionPersistenceRdb): void {
28
+ mountJsonRoute(ctx, {
29
+ path: SESSION_ORGANIZE_PATH,
30
+ handle: async () => {
31
+ const stoppedAgents = await stopRunningAgents(ctx);
32
+ const { orphanSessions, orphanEvents, rebuiltSessions } = await persistence.organize();
33
+ return {
34
+ body: { stoppedAgents, orphanSessions, orphanEvents, rebuiltSessions, vacuumed: true },
35
+ };
36
+ },
37
+ });
38
+ }
@@ -0,0 +1,190 @@
1
+ import { SESSION_FORMAT_VERSION } from "@deepseek-ai/dsh-session";
2
+ import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
3
+ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
4
+ import { balanceRewindPrefix } from "@morlay/session-branch";
5
+ import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
6
+ import { scanRows, toJsonlArtifact } from "./log/export.ts";
7
+ import { needsShapeAdoption, repairReadView } from "./log/repair.ts";
8
+ import { rowToMeta } from "./log/rows.ts";
9
+ import type {
10
+ SessionPersistenceRevision,
11
+ SessionPersistenceSnapshot,
12
+ } from "@deepseek-ai/dsh-session-persistence";
13
+ import type { Backend, SessionRow } from "./backend.ts";
14
+ import type { RdbBackendTracker } from "./handles.ts";
15
+
16
+ export interface ReadPathDeps {
17
+ readonly ready: Promise<void>;
18
+ backend(): Backend;
19
+ readonly tracker: RdbBackendTracker;
20
+ readonly logger: { warn(message: string): void };
21
+ /** materialization 身份的 revision 串(`storeIdentity:incarnation:…`)。 */
22
+ revisionOf(row: SessionRow): SessionPersistenceRevision;
23
+ }
24
+
25
+ /**
26
+ * 读侧:会话日志、原始导出、快照列表与已存储 revision。
27
+ *
28
+ * 这些只读 `backend.reads`,不碰写事务;`session-store.ts` 的契约方法(`load` / `open` / `readFrom`)把调用转到这里。
29
+ */
30
+ export function createReadPath(deps: ReadPathDeps) {
31
+ return {
32
+ async readLog(
33
+ id: SessionId,
34
+ options: { fromSeq?: number } = {},
35
+ signal?: AbortSignal,
36
+ ): Promise<
37
+ | {
38
+ meta: SessionHeader;
39
+
40
+ inheritedEventCount: number;
41
+ events: SessionEvent[];
42
+ tornFrom?: number;
43
+
44
+ incarnation: string;
45
+
46
+ revision: number;
47
+
48
+ storedCount: number;
49
+
50
+ migrated: boolean;
51
+ }
52
+ | undefined
53
+ > {
54
+ signal?.throwIfAborted();
55
+ await deps.ready;
56
+ signal?.throwIfAborted();
57
+ const row = await deps.backend().reads.getSession(id);
58
+ if (row === undefined) return undefined;
59
+ const meta = rowToMeta(row);
60
+ const eventRows =
61
+ options.fromSeq === undefined
62
+ ? await deps.backend().reads.getEventRows(id)
63
+ : await deps.backend().reads.getEventRows(id, options.fromSeq);
64
+ signal?.throwIfAborted();
65
+
66
+ if (isLegacyVersion(row.fVersion)) {
67
+ try {
68
+ const converted = convertLegacyRows(row, eventRows);
69
+ return {
70
+ meta: converted.meta,
71
+ inheritedEventCount: converted.inheritedEventCount,
72
+ events: converted.events,
73
+ incarnation: row.fIncarnation,
74
+ revision: row.fRevision,
75
+ storedCount: eventRows.length,
76
+ migrated: true,
77
+ };
78
+ } catch (error: unknown) {
79
+ deps.logger.warn(
80
+ `session-rdb: session "${id}" is not a single released format; adopting its stored rows as current-format data (${error instanceof Error ? error.message : String(error)})`,
81
+ );
82
+ const adopted = adoptLegacyRows(row, eventRows);
83
+ return {
84
+ meta: adopted.meta,
85
+ inheritedEventCount: adopted.inheritedEventCount,
86
+ events: adopted.events,
87
+ incarnation: row.fIncarnation,
88
+ revision: row.fRevision,
89
+ storedCount: eventRows.length,
90
+ migrated: false,
91
+ ...(adopted.tornFrom !== undefined ? { tornFrom: adopted.tornFrom } : {}),
92
+ };
93
+ }
94
+ }
95
+ const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
96
+ // 版本号不完全可信:写路径曾把回退视图的结果以当前版本号落库,于是库里存在「v4 标记 + 旧代形状」
97
+ // 的行——在扫干净的边界之内再按内容判一次(撕裂尾部与坏行已经被 `scanRows` 丢掉了)。
98
+ if (needsShapeAdoption(preserved)) {
99
+ const adopted = adoptLegacyRows(row, eventRows);
100
+ return {
101
+ meta: adopted.meta,
102
+ inheritedEventCount: adopted.inheritedEventCount,
103
+ events: adopted.events,
104
+ incarnation: row.fIncarnation,
105
+ revision: row.fRevision,
106
+ storedCount: eventRows.length,
107
+ migrated: false,
108
+ ...(adopted.tornFrom !== undefined ? { tornFrom: adopted.tornFrom } : {}),
109
+ };
110
+ }
111
+ return {
112
+ meta,
113
+ inheritedEventCount: row.fSeedLength ?? 0,
114
+ events: preserved,
115
+ incarnation: row.fIncarnation,
116
+ revision: row.fRevision,
117
+ storedCount: eventRows.length,
118
+ migrated: false,
119
+ ...(tornFrom !== undefined ? { tornFrom } : {}),
120
+ };
121
+ },
122
+
123
+ async readRaw(
124
+ id: SessionId,
125
+ signal?: AbortSignal,
126
+ ): Promise<
127
+ | { meta: SessionHeader; inheritedEventCount: number; filename: string; content: string }
128
+ | undefined
129
+ > {
130
+ signal?.throwIfAborted();
131
+ await deps.ready;
132
+ signal?.throwIfAborted();
133
+ const log = await this.readLog(id, {}, signal);
134
+ if (log === undefined) return undefined;
135
+ repairReadView(log.events);
136
+ // 整段日志导出:只丢弃「已不平衡」之后的尾部(尾部未闭合的 step 是中断运行的正常形状,由上游 resume 补 closers)
137
+ const events = balanceRewindPrefix(log.events, { keepOpenTail: true });
138
+ const inheritedEventCount = Math.min(log.inheritedEventCount, events.length);
139
+ return {
140
+ meta: log.meta,
141
+ inheritedEventCount,
142
+
143
+ filename: sessionFormatLogFilename(SESSION_FORMAT_VERSION),
144
+ content: toJsonlArtifact(log.meta, inheritedEventCount, events),
145
+ };
146
+ },
147
+
148
+ async listSnapshots(
149
+ signal?: AbortSignal,
150
+ ): Promise<Array<SessionPersistenceSnapshot & { inheritedEventCount: number }>> {
151
+ signal?.throwIfAborted();
152
+ await deps.ready;
153
+ signal?.throwIfAborted();
154
+ const snapshots: Array<SessionPersistenceSnapshot & { inheritedEventCount: number }> = [];
155
+ const listed = new Set<SessionId>();
156
+ for (const [id, pending] of deps.tracker.pendingEntries()) {
157
+ snapshots.push({
158
+ header: pending.header,
159
+ revision: pending.revision,
160
+ inheritedEventCount: pending.inheritedEventCount,
161
+ });
162
+ listed.add(id);
163
+ }
164
+ const rows = await deps.backend().reads.listSessions();
165
+ signal?.throwIfAborted();
166
+ for (const row of rows) {
167
+ if (listed.has(row.fSessionId as SessionId)) continue;
168
+ snapshots.push({
169
+ header: rowToMeta(row),
170
+ revision: deps.revisionOf(row),
171
+ inheritedEventCount: row.fSeedLength ?? 0,
172
+ });
173
+ }
174
+ return snapshots;
175
+ },
176
+
177
+ async readStoredRevision(
178
+ id: SessionId,
179
+ signal?: AbortSignal,
180
+ ): Promise<SessionPersistenceRevision | undefined> {
181
+ signal?.throwIfAborted();
182
+ await deps.ready;
183
+ signal?.throwIfAborted();
184
+ const row = await deps.backend().reads.getSession(id);
185
+ if (row === undefined) return undefined;
186
+ return deps.revisionOf(row);
187
+ }
188
+
189
+ };
190
+ }
package/src/routes.ts CHANGED
@@ -11,6 +11,7 @@ export const SESSION_IMPORT_PATH = `${MORLAY_SESSION_API_PREFIX}/import`;
11
11
 
12
12
  export const SESSION_DELETE_PATH = `${MORLAY_SESSION_API_PREFIX}/delete`;
13
13
 
14
- export const SESSION_GC_PATH = `${MORLAY_SESSION_API_PREFIX}/gc`;
14
+ // 「整理数据」通道:回收孤儿 + 重算派生统计 + VACUUM,一次请求完成。
15
+ export const SESSION_ORGANIZE_PATH = `${MORLAY_SESSION_API_PREFIX}/organize`;
15
16
 
16
17
  export const SESSION_USAGE_PATH = `${MORLAY_SESSION_API_PREFIX}/usage`;
package/src/rows.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import type { Backend } from "./backend.ts";
3
3
  import { SESSION_ROWS_PATH } from "./routes.ts";
4
+ import { mountJsonRoute } from "./http/route.ts";
4
5
 
5
6
  // 管理面的会话列表路由:完整语料(含归档)+ 标题 + 最后活动时间,与官方 `session/list` 分开
6
7
  // (见 ADR-会话列表两条路)。
@@ -41,82 +42,29 @@ export interface SessionRowsValue {
41
42
  const MAX_PAGE_SIZE = 200;
42
43
  const DEFAULT_PAGE_SIZE = 20;
43
44
 
44
- export function registerSessionRows(ctx: Context, backend: Backend): void {
45
- ctx.inject(["webServer", "connection"] as const, (webCtx) => {
46
- const webServer = webCtx.webServer as unknown as {
47
- register(route: {
48
- kind: "exact";
49
- path: string;
50
- handler: (
51
- req: import("node:http").IncomingMessage,
52
- res: import("node:http").ServerResponse,
53
- ) => void | Promise<void>;
54
- }): () => void;
55
- };
56
- const connection = webCtx.get("connection") as unknown as {
57
- requestRejection(request: {
58
- headers: import("node:http").IncomingHttpHeaders;
59
- }): number | undefined;
60
- };
61
- return webCtx.effect(
62
- () =>
63
- webServer.register({
64
- kind: "exact",
65
- path: SESSION_ROWS_PATH,
66
- handler: async (req, res) => {
67
- const rejection = connection.requestRejection(req);
68
- if (rejection !== undefined) {
69
- res.writeHead(rejection);
70
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
71
- return;
72
- }
73
- if (req.method !== "POST") {
74
- res.writeHead(405, { "content-type": "application/json" });
75
- res.end(JSON.stringify({ error: "method not allowed" }));
76
- return;
77
- }
78
- const chunks: Buffer[] = [];
79
- for await (const chunk of req) chunks.push(chunk as Buffer);
80
- const raw = Buffer.concat(chunks).toString("utf8");
81
- let envelope: SessionRowsRequest = {};
82
- if (raw !== "") {
83
- try {
84
- envelope = JSON.parse(raw) as SessionRowsRequest;
85
- } catch {
86
- res.writeHead(400, { "content-type": "application/json" });
87
- res.end(JSON.stringify({ error: "request body is not JSON" }));
88
- return;
89
- }
90
- }
91
- const page =
92
- Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
93
- const requested = envelope.pageSize;
94
- const pageSize =
95
- Number.isSafeInteger(requested) && (requested ?? 0) > 0
96
- ? Math.min(requested!, MAX_PAGE_SIZE)
97
- : DEFAULT_PAGE_SIZE;
98
- try {
99
- const { items, total } = await backend.listSessionRows({
100
- ...(envelope.query === undefined ? {} : { query: envelope.query }),
101
- ...(envelope.includeSubagents === undefined
102
- ? {}
103
- : { includeSubagents: envelope.includeSubagents }),
104
- limit: pageSize,
105
- offset: (page - 1) * pageSize,
106
- });
107
- res.writeHead(200, { "content-type": "application/json" });
108
- res.end(JSON.stringify({ items, total, page, pageSize } satisfies SessionRowsValue));
109
- } catch (error: unknown) {
110
- res.writeHead(500, { "content-type": "application/json" });
111
- res.end(
112
- JSON.stringify({
113
- error: error instanceof Error ? error.message : "session rows failed",
114
- }),
115
- );
116
- }
117
- },
118
- }),
119
- "session-rdb: session rows route",
120
- );
45
+ export function registerSessionRows(ctx: Context, getBackend: () => Promise<Backend>): void {
46
+ mountJsonRoute(ctx, {
47
+ path: SESSION_ROWS_PATH,
48
+ handle: async (body) => {
49
+ const envelope = body as SessionRowsRequest;
50
+ const page =
51
+ Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
52
+ const requested = envelope.pageSize;
53
+ const pageSize =
54
+ Number.isSafeInteger(requested) && (requested ?? 0) > 0
55
+ ? Math.min(requested!, MAX_PAGE_SIZE)
56
+ : DEFAULT_PAGE_SIZE;
57
+ const backend = await getBackend();
58
+ const { items, total } = await backend.listSessionRows({
59
+ ...(envelope.query === undefined ? {} : { query: envelope.query }),
60
+ ...(envelope.includeSubagents === undefined
61
+ ? {}
62
+ : { includeSubagents: envelope.includeSubagents }),
63
+ limit: pageSize,
64
+ offset: (page - 1) * pageSize,
65
+ });
66
+ return { body: { items, total, page, pageSize } satisfies SessionRowsValue };
67
+ },
68
+ statusOfError: () => undefined,
121
69
  });
122
70
  }