@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
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { and, eq, gte, inArray, isNotNull, lt, notInArray, sql } from "drizzle-orm";
3
2
  import {
4
3
  SESSION_FORMAT_VERSION,
5
4
  SessionId,
@@ -7,67 +6,27 @@ import {
7
6
  SessionSeq,
8
7
  } from "@deepseek-ai/dsh-session";
9
8
  import type { SessionSeqCursor } from "@deepseek-ai/dsh-session";
10
- import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
11
9
  import type {
12
10
  CheckpointIdentity,
11
+ DomainRecord,
13
12
  ProjectionCheckpoint,
14
13
  ProjectionCheckpointRow,
15
- ScheduleTask,
16
14
  StorageRepository,
15
+ StorageRepositoryHost,
17
16
  StoredProjcacheEntry,
18
17
  WorkspaceDomainState,
18
+ WorkspaceIndexEntry,
19
19
  WorkspaceRecord,
20
20
  } from "./types.ts";
21
+ import { E, type Database } from "@morlay/rdbkit";
22
+ import { sessionProjcacheRows, sessions, storageRecords, storageUnits } from "../entities/index.ts";
21
23
 
22
- export interface StorageRepositoryHost {
23
- db: () => Promise<unknown>;
24
-
25
- // 不经 await、直接拿到的数据库句柄;未开放时只走异步的 `db`。
26
- dbDirect?: () => unknown;
27
-
28
- tables: Record<string, unknown>;
29
-
30
- writeAtomically: <T>(fn: () => Promise<T>) => Promise<T>;
31
- }
32
-
33
- async function allRows<T>(query: unknown): Promise<T[]> {
34
- const q = query as { all?: () => T[] };
35
- if (typeof q.all === "function") return q.all();
36
- return (await query) as T[];
37
- }
38
-
39
- async function oneRow<T>(query: unknown): Promise<T | undefined> {
40
- const q = query as { get?: () => T | undefined };
41
- if (typeof q.get === "function") return q.get();
42
- const rows = (await query) as T[];
43
- return rows[0];
44
- }
45
-
46
- async function runQuery(query: unknown): Promise<void> {
47
- const q = query as { run?: () => unknown };
48
- if (typeof q.run === "function") {
49
- q.run();
50
- return;
51
- }
52
- await query;
53
- }
54
-
55
- interface SessionIdentityRow {
24
+ function identityOfSession(row: {
56
25
  fVersion: number;
57
26
  fCreatedAt: number;
58
27
  fCwd: string | null;
59
28
  fSeedLength: number | null;
60
- }
61
-
62
- interface ProjcacheRowRecord {
63
- fSessionId: string;
64
- fKey: string;
65
- fVer: number;
66
- fSeq: number;
67
- fVal: string;
68
- }
69
-
70
- function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
29
+ }): CheckpointIdentity {
71
30
  return {
72
31
  formatVersion: row.fVersion,
73
32
  createdAt: row.fCreatedAt,
@@ -81,458 +40,293 @@ function seqCursor(seq: number): SessionSeqCursor {
81
40
  return seq === -1 ? -1 : SessionSeq(seq);
82
41
  }
83
42
 
84
- function pendingMutationOf(row: {
85
- fPendingOperation: string | null;
86
- fPendingWorkspaceId: string | null;
87
- }): WorkspaceDomainState["pendingMutation"] {
88
- const operation = row.fPendingOperation;
89
- if (operation !== "create" && operation !== "delete") return undefined;
90
- return { operation, workspaceId: (row.fPendingWorkspaceId ?? "") as WorkspaceId };
91
- }
92
-
93
- interface ScheduleTaskRowRecord {
94
- fId: string;
95
- fSessionId: string;
96
- fStatus: string;
97
- fRecord: string;
98
- fLastDelivery: string | null;
99
- fDeliveryHistory: string | null;
100
- }
101
-
102
- // 行的读写映射:形状校验在 KV 那一层做(`storage-backend.ts` 的 `scheduleTaskOf`),这里只按列拆装——
103
- // 归属会话与状态是列,其余三段保持上游契约的 JSON 原样(读回时 `dsh-schedule` 的域 schema 还会再校验)。
104
- function taskFromRow(row: ScheduleTaskRowRecord): ScheduleTask {
105
- return {
106
- sessionId: row.fSessionId as SessionId,
107
- record: JSON.parse(row.fRecord),
108
- status: row.fStatus === "inactive" ? "inactive" : "active",
109
- ...(row.fLastDelivery === null ? {} : { lastDelivery: JSON.parse(row.fLastDelivery) }),
110
- ...(row.fDeliveryHistory === null ? {} : { deliveryHistory: JSON.parse(row.fDeliveryHistory) }),
111
- } as ScheduleTask;
112
- }
113
-
114
- function rowFromTask(id: string, task: ScheduleTask): ScheduleTaskRowRecord {
115
- return {
116
- fId: id,
117
- fSessionId: task.sessionId,
118
- fStatus: task.status === "inactive" ? "inactive" : "active",
119
- fRecord: JSON.stringify(task.record),
120
- fLastDelivery: task.lastDelivery === undefined ? null : JSON.stringify(task.lastDelivery),
121
- fDeliveryHistory:
122
- task.deliveryHistory === undefined ? null : JSON.stringify(task.deliveryHistory),
123
- };
124
- }
125
-
126
43
  export function createStorageRepository(host: StorageRepositoryHost): StorageRepository {
127
- const tables = host.tables as Record<string, any>;
128
- const tUnits = tables["t_storage_units"]!;
129
- const tSessions = tables["t_sessions"]!;
130
- const tWorkspaces = tables["t_workspaces"]!;
131
- const tWorkspaceSessions = tables["t_workspace_sessions"]!;
132
- const tWorkspaceState = tables["t_workspace_state"]!;
133
- const tScheduleTasks = tables["t_schedule_tasks"]!;
134
- const tProjcacheRows = tables["t_session_projcache_row"]!;
135
-
136
- const sessionIdentity = {
137
- fSessionId: tSessions.fSessionId,
138
- fVersion: tSessions.fVersion,
139
- fCreatedAt: tSessions.fCreatedAt,
140
- fCwd: tSessions.fCwd,
141
- fSeedLength: tSessions.fSeedLength,
142
- };
44
+ const records = storageRecords.table(host.database);
45
+ const projcache = sessionProjcacheRows.table(host.database);
46
+ const sessionRows = sessions.table(host.database);
47
+ const units = storageUnits.table(host.database);
48
+
49
+ /** 读一个域的 global(空表名那一行);没有就返回 `null`(域层据此用 initial)。 */
50
+ async function readGlobal(domain: string): Promise<unknown> {
51
+ for await (const row of records
52
+ .select({ value: records.column("f_value") })
53
+ .where(E.and(E.eq(records.f_domain, domain), E.eq(records.f_table, GLOBAL_TABLE)))) {
54
+ return JSON.parse(row.value);
55
+ }
56
+ return null;
57
+ }
143
58
 
144
- const dbx = async (): Promise<any> => (await host.db()) as any;
59
+ const GLOBAL_TABLE = "";
60
+ const WORKSPACE_DOMAIN = "workspace";
61
+ const WORKSPACE_TABLE = "workspaces";
62
+
63
+ // workspace 的 global 里那两个集合仍是会话行上的标记:列表的 archived 与钉住顺序按会话查;
64
+ // 文档是唯一的域存储,这里只写穿(找不到会话行的 id 跳过,与既有语义一致)。
65
+ async function syncSessionFlags(database: Database, state: WorkspaceDomainState): Promise<void> {
66
+ const table = sessions.table(database);
67
+ const pinned = state.pinnedSessionIds ?? [];
68
+ // 钉住:先把不在集合里的置空,再按位置写(找不到会话行就跳过,与既有语义一致)。
69
+ await table.update({ f_pinned_seq: null }, E.isNotNull(table.f_pinned_seq));
70
+ for (const [position, sessionId] of pinned.entries()) {
71
+ await table.update({ f_pinned_seq: position }, E.eq(table.f_session_id, sessionId));
72
+ }
73
+
74
+ const archived = state.archivedSessionIds;
75
+ await table.update({ f_archived_at: null }, E.isNotNull(table.f_archived_at));
76
+ const stamp = Date.now();
77
+ for (const sessionId of archived) {
78
+ // 旧库的归档集合里可能有还没落会话行的 id:先补一行再打标记。
79
+ await table.insertIgnore(
80
+ {
81
+ f_session_id: sessionId,
82
+ f_head_event_id: "",
83
+ f_head_sequence: -1,
84
+ f_version: SESSION_FORMAT_VERSION,
85
+ f_created_at: stamp,
86
+ f_cwd: null,
87
+ f_parent_session: null,
88
+ f_seed_length: null,
89
+ f_origin: null,
90
+ f_delegation_depth: null,
91
+ f_incarnation: randomUUID(),
92
+ f_revision: 0,
93
+ f_archived_at: stamp,
94
+ },
95
+ ["f_session_id"],
96
+ );
97
+ await table.update({ f_archived_at: stamp }, E.eq(table.f_session_id, sessionId));
98
+ }
99
+ }
145
100
 
146
101
  return {
147
102
  async readUnitVersion(name: string): Promise<number | undefined> {
148
- const db = await dbx();
149
- const row = await oneRow<{ fVersion: number }>(
150
- db.select().from(tUnits).where(eq(tUnits.fName, name)),
151
- );
152
- return row?.fVersion;
103
+ for await (const row of units
104
+ .select({ version: units.column("f_version") })
105
+ .where(E.eq(units.f_name, name))) {
106
+ return row.version;
107
+ }
108
+ return undefined;
153
109
  },
154
110
 
155
111
  async insertUnitVersion(name: string, version: number): Promise<void> {
156
- const db = await dbx();
157
- await runQuery(
158
- db.insert(tUnits).values({ fName: name, fVersion: version }).onConflictDoNothing(),
159
- );
112
+ await host.writeAtomically(async () => {
113
+ await units.insertIgnore({ f_name: name, f_version: version }, ["f_name"]);
114
+ });
160
115
  },
161
116
 
162
- async listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>> {
163
- const db = await dbx();
164
- const rows = await allRows<{
165
- fWorkspaceId: string;
166
- fPath: string;
167
- fTitle: string;
168
- fCreatedAt: string;
169
- fUpdatedAt: string;
170
- }>(db.select().from(tWorkspaces));
171
- const links = await allRows<{
172
- fWorkspaceId: string;
173
- fSessionId: string;
174
- fPosition: number;
175
- }>(db.select().from(tWorkspaceSessions));
176
- const sessions = new Map<string, Array<{ id: string; position: number }>>();
177
- for (const link of links) {
178
- const owned = sessions.get(link.fWorkspaceId) ?? [];
179
- owned.push({ id: link.fSessionId, position: link.fPosition });
180
- sessions.set(link.fWorkspaceId, owned);
117
+ async listDomainRecords(domain: string): Promise<DomainRecord[]> {
118
+ const result: DomainRecord[] = [];
119
+ for await (const row of records
120
+ .select({
121
+ table: records.column("f_table"),
122
+ key: records.column("f_key"),
123
+ value: records.column("f_value"),
124
+ })
125
+ .where(E.eq(records.f_domain, domain))) {
126
+ if (row.table === "") continue;
127
+ result.push({ table: row.table, key: row.key, value: JSON.parse(row.value) });
181
128
  }
182
- return rows.map((row) => ({
183
- id: row.fWorkspaceId,
184
- record: {
185
- path: row.fPath,
186
- title: row.fTitle,
187
- sessionIds: (sessions.get(row.fWorkspaceId) ?? [])
188
- .sort((left, right) => left.position - right.position)
189
- .map((entry) => SessionId(entry.id)),
190
- createdAt: row.fCreatedAt,
191
- updatedAt: row.fUpdatedAt,
192
- },
193
- }));
129
+ return result;
194
130
  },
195
131
 
196
- async putWorkspace(id: string, record: WorkspaceRecord): Promise<void> {
197
- await host.writeAtomically(async () => {
198
- const db = await dbx();
199
- const values = {
200
- fWorkspaceId: id,
201
- fPath: record.path,
202
- fTitle: record.title,
203
- fCreatedAt: record.createdAt,
204
- fUpdatedAt: record.updatedAt,
205
- };
206
- await runQuery(
207
- db
208
- .insert(tWorkspaces)
209
- .values(values)
210
- .onConflictDoUpdate({ target: tWorkspaces.fWorkspaceId, set: values }),
211
- );
212
-
213
- await runQuery(
214
- db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
215
- );
216
- const links = record.sessionIds.map((sessionId, position) => ({
217
- fWorkspaceId: id,
218
- fSessionId: sessionId,
219
- fPosition: position,
220
- }));
221
- if (links.length > 0) await runQuery(db.insert(tWorkspaceSessions).values(links));
222
- });
132
+ async readDomainGlobal(domain: string): Promise<unknown> {
133
+ return await readGlobal(domain);
223
134
  },
224
135
 
225
- async deleteWorkspace(id: string): Promise<void> {
136
+ async putDomainRecord(
137
+ domain: string,
138
+ table: string,
139
+ key: string,
140
+ value: unknown,
141
+ ): Promise<void> {
226
142
  await host.writeAtomically(async () => {
227
- const db = await dbx();
228
- await runQuery(
229
- db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
143
+ await records.upsert(
144
+ { f_domain: domain, f_table: table, f_key: key, f_value: JSON.stringify(value) },
145
+ ["f_domain", "f_table", "f_key"],
146
+ ["f_value"],
230
147
  );
231
- await runQuery(db.delete(tWorkspaces).where(eq(tWorkspaces.fWorkspaceId, id)));
232
148
  });
233
149
  },
234
150
 
235
- async deleteWorkspaces(ids: readonly string[]): Promise<void> {
236
- if (ids.length === 0) return;
151
+ async deleteDomainRecord(domain: string, table: string, key: string): Promise<void> {
237
152
  await host.writeAtomically(async () => {
238
- const db = await dbx();
239
- await runQuery(
240
- db.delete(tWorkspaceSessions).where(inArray(tWorkspaceSessions.fWorkspaceId, [...ids])),
153
+ await records.delete(
154
+ E.and(E.eq(records.f_domain, domain), E.eq(records.f_table, table), E.eq(records.f_key, key)),
241
155
  );
242
- await runQuery(db.delete(tWorkspaces).where(inArray(tWorkspaces.fWorkspaceId, [...ids])));
243
156
  });
244
157
  },
245
158
 
246
- async readWorkspaceState(): Promise<WorkspaceDomainState | null> {
247
- const db = await dbx();
248
- const row = await oneRow<{
249
- fInitialized: number;
250
- fPendingOperation: string | null;
251
- fPendingWorkspaceId: string | null;
252
- }>(db.select().from(tWorkspaceState).where(eq(tWorkspaceState.fSingleton, 1)));
253
- if (row === undefined) return null;
254
- const ordered = await allRows<{ fWorkspaceId: string }>(
255
- db
256
- .select()
257
- .from(tWorkspaces)
258
- .where(gte(tWorkspaces.fPosition, 0))
259
- .orderBy(tWorkspaces.fPosition),
260
- );
261
- const archives = await allRows<{ fSessionId: string }>(
262
- db
263
- .select({ fSessionId: tSessions.fSessionId })
264
- .from(tSessions)
265
- .where(isNotNull(tSessions.fArchivedAt))
266
- .orderBy(tSessions.fArchivedAt),
267
- );
268
- const pinned = await allRows<{ fSessionId: string; fPinnedSeq: number | null }>(
269
- db
270
- .select({ fSessionId: tSessions.fSessionId, fPinnedSeq: tSessions.fPinnedSeq })
271
- .from(tSessions)
272
- .where(isNotNull(tSessions.fPinnedSeq))
273
- .orderBy(tSessions.fPinnedSeq),
274
- );
275
- const pendingMutation = pendingMutationOf(row);
276
- return {
277
- initialized: row.fInitialized !== 0,
278
- workspaceIds: ordered.map((entry) => entry.fWorkspaceId as WorkspaceId),
279
- archivedSessionIds: archives.map((entry) => entry.fSessionId as SessionId),
280
- pinnedSessionIds: pinned.map((entry) => entry.fSessionId as SessionId),
281
- ...(pendingMutation === undefined ? {} : { pendingMutation }),
282
- };
283
- },
284
-
285
- async writeWorkspaceState(state: WorkspaceDomainState): Promise<void> {
159
+ async deleteDomainRecords(
160
+ domain: string,
161
+ table: string,
162
+ keys: readonly string[],
163
+ ): Promise<void> {
164
+ if (keys.length === 0) return;
286
165
  await host.writeAtomically(async () => {
287
- const db = await dbx();
288
- const pending = state.pendingMutation as
289
- | { operation?: unknown; workspaceId?: unknown }
290
- | undefined;
291
- const values = {
292
- fSingleton: 1,
293
- fInitialized: state.initialized ? 1 : 0,
294
- fPendingOperation:
295
- pending === undefined || typeof pending.operation !== "string"
296
- ? null
297
- : pending.operation,
298
- fPendingWorkspaceId:
299
- pending === undefined || typeof pending.workspaceId !== "string"
300
- ? null
301
- : pending.workspaceId,
302
- };
303
- await runQuery(
304
- db
305
- .insert(tWorkspaceState)
306
- .values(values)
307
- .onConflictDoUpdate({ target: tWorkspaceState.fSingleton, set: values }),
308
- );
309
-
310
- await runQuery(db.update(tWorkspaces).set({ fPosition: -1 }));
311
- for (const [position, workspaceId] of state.workspaceIds.entries()) {
312
- await runQuery(
313
- db
314
- .update(tWorkspaces)
315
- .set({ fPosition: position })
316
- .where(eq(tWorkspaces.fWorkspaceId, workspaceId)),
317
- );
318
- }
319
-
320
- // 钉住:未在集合里的清掉,集合里的按数组位置写序号(读回即按它排序)。
321
- // 找不到会话行的 id 跳过:pin 只作用于已存在的会话(与归档不同,不凭空造行)。
322
- // 既有 storages 文档里可能没有这个集合,导入路径按空处理。
323
- const pinned = state.pinnedSessionIds ?? [];
324
- await runQuery(
325
- pinned.length === 0
326
- ? db.update(tSessions).set({ fPinnedSeq: null }).where(isNotNull(tSessions.fPinnedSeq))
327
- : db
328
- .update(tSessions)
329
- .set({ fPinnedSeq: null })
330
- .where(
331
- and(isNotNull(tSessions.fPinnedSeq), notInArray(tSessions.fSessionId, pinned)),
332
- ),
333
- );
334
- for (const [position, sessionId] of pinned.entries()) {
335
- await runQuery(
336
- db
337
- .update(tSessions)
338
- .set({ fPinnedSeq: position })
339
- .where(eq(tSessions.fSessionId, sessionId)),
340
- );
341
- }
342
-
343
- const archived = state.archivedSessionIds;
344
- await runQuery(
345
- archived.length === 0
346
- ? db
347
- .update(tSessions)
348
- .set({ fArchivedAt: null })
349
- .where(isNotNull(tSessions.fArchivedAt))
350
- : db
351
- .update(tSessions)
352
- .set({ fArchivedAt: null })
353
- .where(
354
- and(isNotNull(tSessions.fArchivedAt), notInArray(tSessions.fSessionId, archived)),
355
- ),
166
+ await records.delete(
167
+ E.and(
168
+ E.eq(records.f_domain, domain),
169
+ E.eq(records.f_table, table),
170
+ E.inList(records.f_key, [...new Set(keys)]),
171
+ ),
356
172
  );
357
- const stamp = Date.now();
358
- for (const sessionId of archived) {
359
- await runQuery(
360
- db
361
- .insert(tSessions)
362
- .values({
363
- fSessionId: sessionId,
364
- fHeadEventId: "",
365
- fHeadSequence: -1,
366
- fVersion: SESSION_FORMAT_VERSION,
367
- fCreatedAt: stamp,
368
- fCwd: null,
369
- fParentSession: null,
370
- fSeedLength: null,
371
- fOrigin: null,
372
- fDelegationDepth: null,
373
- fIncarnation: randomUUID(),
374
- fRevision: 0,
375
- fArchivedAt: stamp,
376
- })
377
- .onConflictDoUpdate({
378
- target: tSessions.fSessionId,
379
-
380
- set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
381
- }),
382
- );
383
- }
384
173
  });
385
174
  },
386
175
 
387
- async listScheduleTasks(): Promise<Array<{ id: string; task: ScheduleTask }>> {
388
- const db = await dbx();
389
- const rows = await allRows<ScheduleTaskRowRecord>(db.select().from(tScheduleTasks));
390
- return rows.map((row) => ({ id: row.fId, task: taskFromRow(row) }));
391
- },
392
-
393
- async putScheduleTask(id: string, task: ScheduleTask): Promise<void> {
176
+ async writeDomainGlobal(domain: string, value: unknown): Promise<void> {
394
177
  await host.writeAtomically(async () => {
395
- const db = await dbx();
396
- const values = rowFromTask(id, task);
397
- await runQuery(
398
- db
399
- .insert(tScheduleTasks)
400
- .values(values)
401
- .onConflictDoUpdate({ target: tScheduleTasks.fId, set: values }),
178
+ await records.upsert(
179
+ { f_domain: domain, f_table: "", f_key: "", f_value: JSON.stringify(value) },
180
+ ["f_domain", "f_table", "f_key"],
181
+ ["f_value"],
402
182
  );
183
+ // workspace 的归档 / 钉住仍是会话行上的标记(列表与删除都按会话查),由这里写穿。
184
+ if (domain === WORKSPACE_DOMAIN) {
185
+ await syncSessionFlags(host.database, value as WorkspaceDomainState);
186
+ }
403
187
  });
404
188
  },
405
189
 
406
- async deleteScheduleTask(id: string): Promise<void> {
407
- await host.writeAtomically(async () => {
408
- const db = await dbx();
409
- await runQuery(db.delete(tScheduleTasks).where(eq(tScheduleTasks.fId, id)));
410
- });
411
- },
412
-
413
- async deleteScheduleTasks(ids: readonly string[]): Promise<void> {
414
- if (ids.length === 0) return;
415
- await host.writeAtomically(async () => {
416
- const db = await dbx();
417
- await runQuery(db.delete(tScheduleTasks).where(inArray(tScheduleTasks.fId, [...ids])));
418
- });
190
+ async listWorkspaceIndex(): Promise<WorkspaceIndexEntry[]> {
191
+ const rows: Array<{ id: string; record: WorkspaceRecord }> = [];
192
+ for await (const row of records
193
+ .select({ key: records.column("f_key"), value: records.column("f_value") })
194
+ .where(
195
+ E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, WORKSPACE_TABLE)),
196
+ )) {
197
+ rows.push({ id: row.key, record: JSON.parse(row.value) as WorkspaceRecord });
198
+ }
199
+ const state = (await readGlobal(WORKSPACE_DOMAIN)) as WorkspaceDomainState | null;
200
+ const order = new Map<string, number>(
201
+ (state?.workspaceIds ?? []).map((id, position) => [String(id), position]),
202
+ );
203
+ return rows
204
+ .sort(
205
+ (left, right) =>
206
+ (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
207
+ (order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
208
+ )
209
+ .map(({ id, record }) => ({ id, title: record.title, sessionIds: [...record.sessionIds] }));
419
210
  },
420
211
 
421
212
  async loadProjcache(): Promise<StoredProjcacheEntry[]> {
422
- const db = await dbx();
423
- const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
424
- if (rows.length === 0) return [];
425
- const sessions = await allRows<SessionIdentityRow & { fSessionId: string }>(
426
- db.select(sessionIdentity).from(tSessions),
427
- );
428
213
  const entries = new Map<string, StoredProjcacheEntry>();
429
- for (const session of sessions) {
430
- entries.set(session.fSessionId, {
431
- sessionId: SessionId(session.fSessionId),
432
- identity: identityOfSession(session),
214
+ for await (const row of sessionRows.select({
215
+ sessionId: sessionRows.f_session_id,
216
+ version: sessionRows.f_version,
217
+ createdAt: sessionRows.f_created_at,
218
+ cwd: sessionRows.f_cwd,
219
+ seedLength: sessionRows.f_seed_length,
220
+ title: sessionRows.f_title,
221
+ titleSeq: sessionRows.f_title_seq,
222
+ })) {
223
+ entries.set(row.sessionId, {
224
+ sessionId: SessionId(row.sessionId),
225
+ identity: identityOfSession({
226
+ fVersion: row.version,
227
+ // `f_created_at` 是 bigint(读回 BigInt):投影身份的 createdAt 用 number。
228
+ fCreatedAt: Number(row.createdAt),
229
+ fCwd: row.cwd,
230
+ fSeedLength: row.seedLength,
231
+ }),
433
232
  rows: {},
233
+ ...(typeof row.title === "string" && typeof row.titleSeq === "number"
234
+ ? { title: { title: row.title, seq: row.titleSeq } }
235
+ : {}),
434
236
  });
435
237
  }
436
- for (const row of rows) {
437
- const entry = entries.get(row.fSessionId);
238
+ if (entries.size === 0) return [];
239
+ for await (const row of projcache.select({
240
+ sessionId: projcache.f_session_id,
241
+ key: projcache.f_key,
242
+ ver: projcache.f_ver,
243
+ seq: projcache.f_seq,
244
+ val: projcache.f_val,
245
+ })) {
246
+ const entry = entries.get(row.sessionId);
438
247
  if (entry === undefined) continue;
439
- entry.rows[row.fKey] = {
440
- ver: row.fVer,
441
- seq: seqCursor(row.fSeq),
442
- val: JSON.parse(row.fVal),
248
+ entry.rows[row.key] = {
249
+ ver: row.ver,
250
+ seq: seqCursor(row.seq),
251
+ val: JSON.parse(row.val),
443
252
  } satisfies ProjectionCheckpointRow;
444
253
  }
445
-
446
254
  return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
447
255
  },
448
256
 
257
+ async readSessionTitle(sessionId: string): Promise<{ title: string; seq: number } | undefined> {
258
+ const table = sessions.table(host.database);
259
+ for await (const row of table
260
+ .select({ title: table.f_title, seq: table.f_title_seq })
261
+ .where(E.eq(table.f_session_id, sessionId))) {
262
+ return row.title === null || row.seq === null
263
+ ? undefined
264
+ : { title: row.title, seq: row.seq };
265
+ }
266
+ return undefined;
267
+ },
268
+
449
269
  async putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void> {
270
+ const values = Object.entries(rows).map(([key, row]) => ({
271
+ f_session_id: sessionId,
272
+ f_key: key,
273
+ f_ver: row.ver,
274
+ f_seq: row.seq,
275
+ f_val: JSON.stringify(row.val),
276
+ }));
277
+ await host.writeAtomically(async () => {
278
+ await projcache.delete(E.eq(projcache.f_session_id, sessionId));
279
+ if (values.length > 0) await projcache.insertMany(values);
280
+ });
281
+ },
282
+
283
+ async patchProjcache(
284
+ sessionId: string,
285
+ changed: ProjectionCheckpoint,
286
+ removedKeys: readonly string[],
287
+ ): Promise<void> {
288
+ const values = Object.entries(changed).map(([key, row]) => ({
289
+ f_session_id: sessionId,
290
+ f_key: key,
291
+ f_ver: row.ver,
292
+ f_seq: row.seq,
293
+ f_val: JSON.stringify(row.val),
294
+ }));
295
+ const touched = [...Object.keys(changed), ...removedKeys];
296
+ if (touched.length === 0) return;
450
297
  await host.writeAtomically(async () => {
451
- const db = await dbx();
452
- await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
453
- const values = Object.entries(rows).map(([key, row]) => ({
454
- fSessionId: sessionId,
455
- fKey: key,
456
- fVer: row.ver,
457
- fSeq: row.seq,
458
- fVal: JSON.stringify(row.val),
459
- }));
460
- if (values.length > 0) await runQuery(db.insert(tProjcacheRows).values(values));
298
+ // 只碰这些 key 的行:先删(同时承担 removedKeys 的删除),再插入变化行。
299
+ await projcache.delete(
300
+ E.and(E.eq(projcache.f_session_id, sessionId), E.inList(projcache.f_key, touched)),
301
+ );
302
+ if (values.length > 0) await projcache.insertMany(values);
461
303
  });
462
304
  },
463
305
 
464
306
  async pruneStaleProjcache(): Promise<number> {
465
- const db = await dbx();
466
- const stale = await allRows<{ fSessionId: string }>(
467
- db
468
- .select({ fSessionId: tProjcacheRows.fSessionId })
469
- .from(tProjcacheRows)
470
- .where(
471
- and(
472
- lt(tProjcacheRows.fSeq, 0),
473
- inArray(
474
- tProjcacheRows.fSessionId,
475
- db
476
- .select({ fSessionId: tSessions.fSessionId })
477
- .from(tSessions)
478
- .where(gte(tSessions.fHeadSequence, 0)),
479
- ),
307
+ const stale: string[] = [];
308
+ for await (const row of projcache
309
+ .select({ sessionId: projcache.f_session_id })
310
+ .where(
311
+ E.and(
312
+ E.lt(projcache.f_seq, 0),
313
+ E.inSubquery(
314
+ projcache.f_session_id,
315
+ sessionRows
316
+ .select({ id: sessionRows.f_session_id })
317
+ .where(E.gte(sessionRows.f_head_sequence, 0))
318
+ .toFragment(),
480
319
  ),
481
320
  ),
482
- );
321
+ )) {
322
+ stale.push(row.sessionId);
323
+ }
483
324
  if (stale.length === 0) return 0;
484
- await runQuery(
485
- db.delete(tProjcacheRows).where(
486
- inArray(
487
- tProjcacheRows.fSessionId,
488
- stale.map((row) => row.fSessionId),
489
- ),
490
- ),
491
- );
325
+ await host.writeAtomically(async () => {
326
+ await projcache.delete(E.inList(projcache.f_session_id, stale));
327
+ });
492
328
  return stale.length;
493
- },
494
-
495
- ...(host.dbDirect === undefined
496
- ? {}
497
- : {
498
- readProjcacheDirect: (sessionId: string): StoredProjcacheEntry | undefined => {
499
- const db = host.dbDirect!() as any;
500
- const stored = db
501
- .select()
502
- .from(tProjcacheRows)
503
- .where(eq(tProjcacheRows.fSessionId, sessionId))
504
- .all() as ProjcacheRowRecord[];
505
- if (stored.length === 0) return undefined;
506
- const session = db
507
- .select(sessionIdentity)
508
- .from(tSessions)
509
- .where(eq(tSessions.fSessionId, sessionId))
510
- .get() as (SessionIdentityRow & { fSessionId: string }) | undefined;
329
+ }
511
330
 
512
- if (session === undefined) return undefined;
513
- const rows: ProjectionCheckpoint = {};
514
- for (const row of stored) {
515
- rows[row.fKey] = {
516
- ver: row.fVer,
517
- seq: seqCursor(row.fSeq),
518
- val: JSON.parse(row.fVal),
519
- };
520
- }
521
- return { sessionId: SessionId(sessionId), identity: identityOfSession(session), rows };
522
- },
523
- readSessionTitleDirect: (
524
- sessionId: string,
525
- ): { title: string; seq: number } | undefined => {
526
- const db = host.dbDirect!() as any;
527
- const row = db
528
- .select({ fTitle: tSessions.fTitle, fTitleSeq: tSessions.fTitleSeq })
529
- .from(tSessions)
530
- .where(eq(tSessions.fSessionId, sessionId))
531
- .get() as { fTitle: string | null; fTitleSeq: number | null } | undefined;
532
- if (row === undefined || row.fTitle === null || row.fTitleSeq === null)
533
- return undefined;
534
- return { title: row.fTitle, seq: row.fTitleSeq };
535
- },
536
- }),
537
331
  };
538
332
  }