@morlay/session-rdb 0.1.9 → 0.1.10-alpha.2

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 (143) hide show
  1. package/README.md +24 -3
  2. package/dist/artifact.d.mts +118 -3
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
  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 -3
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -3
  12. package/dist/export.mjs +32 -57
  13. package/dist/import.d.mts +1 -3
  14. package/dist/import.mjs +32 -78
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1562 -5
  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 -50
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -5
  25. package/dist/testing.mjs +27 -8
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +8 -42
  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} +125 -112
  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 -1630
  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 -272
  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-query.ts +17 -4
  74. package/src/session-store.ts +675 -0
  75. package/src/stats-fold.ts +44 -0
  76. package/src/storage-takeover/index.ts +2 -5
  77. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  78. package/src/storage-takeover/projection-cache.ts +139 -77
  79. package/src/storage-takeover/read-model.ts +189 -0
  80. package/src/storage-takeover/repository.ts +238 -425
  81. package/src/storage-takeover/storage-backend.ts +47 -149
  82. package/src/storage-takeover/types.ts +45 -14
  83. package/src/storage-takeover/workspace-documents.ts +65 -0
  84. package/src/storage.ts +4 -2
  85. package/src/testing.ts +21 -1
  86. package/src/usage.ts +11 -65
  87. package/src/version.ts +13 -0
  88. package/src/write-path.ts +286 -0
  89. package/dist/branch-DbFW3rJX.mjs +0 -391
  90. package/dist/deletion-ODZFcbZm.mjs +0 -2218
  91. package/dist/gc.d.mts +0 -6
  92. package/dist/gc.mjs +0 -49
  93. package/dist/log-DYxdjJ-l.mjs +0 -562
  94. package/dist/routes-DGBlBE_I.mjs +0 -10
  95. package/dist/sqlite-DjOnMRh2.mjs +0 -1777
  96. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  97. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  99. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  101. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  103. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  104. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  105. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  106. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  107. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  108. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  109. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  110. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  111. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  112. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  114. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  116. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  118. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  120. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  122. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  123. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  124. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  125. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  126. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  127. package/src/adapters/index.ts +0 -12
  128. package/src/adapters/to-postgres.ts +0 -93
  129. package/src/adapters/to-sqlite.ts +0 -83
  130. package/src/adapters/types.ts +0 -44
  131. package/src/drizzle/postgres-v2.ts +0 -10
  132. package/src/drizzle/postgres-v3.ts +0 -18
  133. package/src/drizzle/sqlite-v2.ts +0 -9
  134. package/src/drizzle/sqlite-v3.ts +0 -18
  135. package/src/entities/v2/index.ts +0 -20
  136. package/src/entities/v2/session-events.ts +0 -10
  137. package/src/entities/v3/schedule-tasks.ts +0 -16
  138. package/src/entities/v3/workspace-sessions.ts +0 -19
  139. package/src/entities/v3/workspace-state.ts +0 -12
  140. package/src/entities/v3/workspaces.ts +0 -14
  141. package/src/gc.ts +0 -77
  142. package/src/postgres.ts +0 -1015
  143. package/src/sqlite.ts +0 -1086
@@ -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,439 +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
- );
132
+ async readDomainGlobal(domain: string): Promise<unknown> {
133
+ return await readGlobal(domain);
134
+ },
212
135
 
213
- await runQuery(
214
- db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
136
+ async putDomainRecord(
137
+ domain: string,
138
+ table: string,
139
+ key: string,
140
+ value: unknown,
141
+ ): Promise<void> {
142
+ await host.writeAtomically(async () => {
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"],
215
147
  );
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
148
  });
223
149
  },
224
150
 
225
- async deleteWorkspace(id: string): Promise<void> {
151
+ async deleteDomainRecord(domain: string, table: string, key: string): Promise<void> {
226
152
  await host.writeAtomically(async () => {
227
- const db = await dbx();
228
- await runQuery(
229
- db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
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)),
230
155
  );
231
- await runQuery(db.delete(tWorkspaces).where(eq(tWorkspaces.fWorkspaceId, id)));
232
156
  });
233
157
  },
234
158
 
235
- async readWorkspaceState(): Promise<WorkspaceDomainState | null> {
236
- const db = await dbx();
237
- const row = await oneRow<{
238
- fInitialized: number;
239
- fPendingOperation: string | null;
240
- fPendingWorkspaceId: string | null;
241
- }>(db.select().from(tWorkspaceState).where(eq(tWorkspaceState.fSingleton, 1)));
242
- if (row === undefined) return null;
243
- const ordered = await allRows<{ fWorkspaceId: string }>(
244
- db
245
- .select()
246
- .from(tWorkspaces)
247
- .where(gte(tWorkspaces.fPosition, 0))
248
- .orderBy(tWorkspaces.fPosition),
249
- );
250
- const archives = await allRows<{ fSessionId: string }>(
251
- db
252
- .select({ fSessionId: tSessions.fSessionId })
253
- .from(tSessions)
254
- .where(isNotNull(tSessions.fArchivedAt))
255
- .orderBy(tSessions.fArchivedAt),
256
- );
257
- const pinned = await allRows<{ fSessionId: string; fPinnedSeq: number | null }>(
258
- db
259
- .select({ fSessionId: tSessions.fSessionId, fPinnedSeq: tSessions.fPinnedSeq })
260
- .from(tSessions)
261
- .where(isNotNull(tSessions.fPinnedSeq))
262
- .orderBy(tSessions.fPinnedSeq),
263
- );
264
- const pendingMutation = pendingMutationOf(row);
265
- return {
266
- initialized: row.fInitialized !== 0,
267
- workspaceIds: ordered.map((entry) => entry.fWorkspaceId as WorkspaceId),
268
- archivedSessionIds: archives.map((entry) => entry.fSessionId as SessionId),
269
- pinnedSessionIds: pinned.map((entry) => entry.fSessionId as SessionId),
270
- ...(pendingMutation === undefined ? {} : { pendingMutation }),
271
- };
272
- },
273
-
274
- 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;
275
165
  await host.writeAtomically(async () => {
276
- const db = await dbx();
277
- const pending = state.pendingMutation as
278
- | { operation?: unknown; workspaceId?: unknown }
279
- | undefined;
280
- const values = {
281
- fSingleton: 1,
282
- fInitialized: state.initialized ? 1 : 0,
283
- fPendingOperation:
284
- pending === undefined || typeof pending.operation !== "string"
285
- ? null
286
- : pending.operation,
287
- fPendingWorkspaceId:
288
- pending === undefined || typeof pending.workspaceId !== "string"
289
- ? null
290
- : pending.workspaceId,
291
- };
292
- await runQuery(
293
- db
294
- .insert(tWorkspaceState)
295
- .values(values)
296
- .onConflictDoUpdate({ target: tWorkspaceState.fSingleton, set: values }),
297
- );
298
-
299
- await runQuery(db.update(tWorkspaces).set({ fPosition: -1 }));
300
- for (const [position, workspaceId] of state.workspaceIds.entries()) {
301
- await runQuery(
302
- db
303
- .update(tWorkspaces)
304
- .set({ fPosition: position })
305
- .where(eq(tWorkspaces.fWorkspaceId, workspaceId)),
306
- );
307
- }
308
-
309
- // 钉住:未在集合里的清掉,集合里的按数组位置写序号(读回即按它排序)。
310
- // 找不到会话行的 id 跳过:pin 只作用于已存在的会话(与归档不同,不凭空造行)。
311
- // 既有 storages 文档里可能没有这个集合,导入路径按空处理。
312
- const pinned = state.pinnedSessionIds ?? [];
313
- await runQuery(
314
- pinned.length === 0
315
- ? db.update(tSessions).set({ fPinnedSeq: null }).where(isNotNull(tSessions.fPinnedSeq))
316
- : db
317
- .update(tSessions)
318
- .set({ fPinnedSeq: null })
319
- .where(
320
- and(isNotNull(tSessions.fPinnedSeq), notInArray(tSessions.fSessionId, pinned)),
321
- ),
322
- );
323
- for (const [position, sessionId] of pinned.entries()) {
324
- await runQuery(
325
- db
326
- .update(tSessions)
327
- .set({ fPinnedSeq: position })
328
- .where(eq(tSessions.fSessionId, sessionId)),
329
- );
330
- }
331
-
332
- const archived = state.archivedSessionIds;
333
- await runQuery(
334
- archived.length === 0
335
- ? db
336
- .update(tSessions)
337
- .set({ fArchivedAt: null })
338
- .where(isNotNull(tSessions.fArchivedAt))
339
- : db
340
- .update(tSessions)
341
- .set({ fArchivedAt: null })
342
- .where(
343
- and(isNotNull(tSessions.fArchivedAt), notInArray(tSessions.fSessionId, archived)),
344
- ),
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
+ ),
345
172
  );
346
- const stamp = Date.now();
347
- for (const sessionId of archived) {
348
- await runQuery(
349
- db
350
- .insert(tSessions)
351
- .values({
352
- fSessionId: sessionId,
353
- fHeadEventId: "",
354
- fHeadSequence: -1,
355
- fVersion: SESSION_FORMAT_VERSION,
356
- fCreatedAt: stamp,
357
- fCwd: null,
358
- fParentSession: null,
359
- fSeedLength: null,
360
- fOrigin: null,
361
- fDelegationDepth: null,
362
- fIncarnation: randomUUID(),
363
- fRevision: 0,
364
- fArchivedAt: stamp,
365
- })
366
- .onConflictDoUpdate({
367
- target: tSessions.fSessionId,
368
-
369
- set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
370
- }),
371
- );
372
- }
373
173
  });
374
174
  },
375
175
 
376
- async listScheduleTasks(): Promise<Array<{ id: string; task: ScheduleTask }>> {
377
- const db = await dbx();
378
- const rows = await allRows<ScheduleTaskRowRecord>(db.select().from(tScheduleTasks));
379
- return rows.map((row) => ({ id: row.fId, task: taskFromRow(row) }));
380
- },
381
-
382
- async putScheduleTask(id: string, task: ScheduleTask): Promise<void> {
176
+ async writeDomainGlobal(domain: string, value: unknown): Promise<void> {
383
177
  await host.writeAtomically(async () => {
384
- const db = await dbx();
385
- const values = rowFromTask(id, task);
386
- await runQuery(
387
- db
388
- .insert(tScheduleTasks)
389
- .values(values)
390
- .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"],
391
182
  );
183
+ // workspace 的归档 / 钉住仍是会话行上的标记(列表与删除都按会话查),由这里写穿。
184
+ if (domain === WORKSPACE_DOMAIN) {
185
+ await syncSessionFlags(host.database, value as WorkspaceDomainState);
186
+ }
392
187
  });
393
188
  },
394
189
 
395
- async deleteScheduleTask(id: string): Promise<void> {
396
- await host.writeAtomically(async () => {
397
- const db = await dbx();
398
- await runQuery(db.delete(tScheduleTasks).where(eq(tScheduleTasks.fId, id)));
399
- });
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] }));
400
210
  },
401
211
 
402
212
  async loadProjcache(): Promise<StoredProjcacheEntry[]> {
403
- const db = await dbx();
404
- const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
405
- if (rows.length === 0) return [];
406
- const sessions = await allRows<SessionIdentityRow & { fSessionId: string }>(
407
- db.select(sessionIdentity).from(tSessions),
408
- );
409
213
  const entries = new Map<string, StoredProjcacheEntry>();
410
- for (const session of sessions) {
411
- entries.set(session.fSessionId, {
412
- sessionId: SessionId(session.fSessionId),
413
- 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
+ }),
414
232
  rows: {},
233
+ ...(typeof row.title === "string" && typeof row.titleSeq === "number"
234
+ ? { title: { title: row.title, seq: row.titleSeq } }
235
+ : {}),
415
236
  });
416
237
  }
417
- for (const row of rows) {
418
- 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);
419
247
  if (entry === undefined) continue;
420
- entry.rows[row.fKey] = {
421
- ver: row.fVer,
422
- seq: seqCursor(row.fSeq),
423
- 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),
424
252
  } satisfies ProjectionCheckpointRow;
425
253
  }
426
-
427
254
  return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
428
255
  },
429
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
+
430
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
+ }));
431
277
  await host.writeAtomically(async () => {
432
- const db = await dbx();
433
- await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
434
- const values = Object.entries(rows).map(([key, row]) => ({
435
- fSessionId: sessionId,
436
- fKey: key,
437
- fVer: row.ver,
438
- fSeq: row.seq,
439
- fVal: JSON.stringify(row.val),
440
- }));
441
- if (values.length > 0) await runQuery(db.insert(tProjcacheRows).values(values));
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;
297
+ await host.writeAtomically(async () => {
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);
442
303
  });
443
304
  },
444
305
 
445
306
  async pruneStaleProjcache(): Promise<number> {
446
- const db = await dbx();
447
- const stale = await allRows<{ fSessionId: string }>(
448
- db
449
- .select({ fSessionId: tProjcacheRows.fSessionId })
450
- .from(tProjcacheRows)
451
- .where(
452
- and(
453
- lt(tProjcacheRows.fSeq, 0),
454
- inArray(
455
- tProjcacheRows.fSessionId,
456
- db
457
- .select({ fSessionId: tSessions.fSessionId })
458
- .from(tSessions)
459
- .where(gte(tSessions.fHeadSequence, 0)),
460
- ),
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(),
461
319
  ),
462
320
  ),
463
- );
321
+ )) {
322
+ stale.push(row.sessionId);
323
+ }
464
324
  if (stale.length === 0) return 0;
465
- await runQuery(
466
- db.delete(tProjcacheRows).where(
467
- inArray(
468
- tProjcacheRows.fSessionId,
469
- stale.map((row) => row.fSessionId),
470
- ),
471
- ),
472
- );
325
+ await host.writeAtomically(async () => {
326
+ await projcache.delete(E.inList(projcache.f_session_id, stale));
327
+ });
473
328
  return stale.length;
474
- },
475
-
476
- ...(host.dbDirect === undefined
477
- ? {}
478
- : {
479
- readProjcacheDirect: (sessionId: string): StoredProjcacheEntry | undefined => {
480
- const db = host.dbDirect!() as any;
481
- const stored = db
482
- .select()
483
- .from(tProjcacheRows)
484
- .where(eq(tProjcacheRows.fSessionId, sessionId))
485
- .all() as ProjcacheRowRecord[];
486
- if (stored.length === 0) return undefined;
487
- const session = db
488
- .select(sessionIdentity)
489
- .from(tSessions)
490
- .where(eq(tSessions.fSessionId, sessionId))
491
- .get() as (SessionIdentityRow & { fSessionId: string }) | undefined;
329
+ }
492
330
 
493
- if (session === undefined) return undefined;
494
- const rows: ProjectionCheckpoint = {};
495
- for (const row of stored) {
496
- rows[row.fKey] = {
497
- ver: row.fVer,
498
- seq: seqCursor(row.fSeq),
499
- val: JSON.parse(row.fVal),
500
- };
501
- }
502
- return { sessionId: SessionId(sessionId), identity: identityOfSession(session), rows };
503
- },
504
- readSessionTitleDirect: (
505
- sessionId: string,
506
- ): { title: string; seq: number } | undefined => {
507
- const db = host.dbDirect!() as any;
508
- const row = db
509
- .select({ fTitle: tSessions.fTitle, fTitleSeq: tSessions.fTitleSeq })
510
- .from(tSessions)
511
- .where(eq(tSessions.fSessionId, sessionId))
512
- .get() as { fTitle: string | null; fTitleSeq: number | null } | undefined;
513
- if (row === undefined || row.fTitle === null || row.fTitleSeq === null)
514
- return undefined;
515
- return { title: row.fTitle, seq: row.fTitleSeq };
516
- },
517
- }),
518
331
  };
519
332
  }