@morlay/session-rdb 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +44 -10
  2. package/dist/artifact.d.mts +9 -2
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{import-BPEHfHNk.mjs → import-DZIAjOUr.mjs} +2 -1
  5. package/dist/import.d.mts +1 -1
  6. package/dist/import.mjs +1 -1
  7. package/dist/{index-CgAqCQAb.d.mts → index-BEKoV1Uy.d.mts} +11 -2
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +509 -6
  10. package/dist/{log-BIuXv09P.mjs → log-DO69NQnn.mjs} +16 -1
  11. package/dist/schema-Bo6Hy5gW.d.mts +3476 -0
  12. package/dist/{sqlite-IH5I48aL.mjs → sqlite-BIPdMNQb.mjs} +504 -6
  13. package/dist/storage.d.mts +10 -2
  14. package/dist/storage.mjs +2 -2
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +65 -0
  18. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +1236 -0
  19. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +10 -0
  20. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +1236 -0
  21. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +66 -0
  22. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +958 -0
  23. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +13 -0
  24. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +958 -0
  25. package/package.json +20 -18
  26. package/src/backend.ts +7 -0
  27. package/src/branch.ts +2 -0
  28. package/src/drizzle/postgres-v3.ts +5 -0
  29. package/src/drizzle/sqlite-v3.ts +5 -0
  30. package/src/entities/v3/index.ts +20 -0
  31. package/src/entities/v3/session-projcache-rows.ts +27 -0
  32. package/src/entities/v3/sessions.ts +6 -0
  33. package/src/entities/v3/storage-units.ts +10 -0
  34. package/src/entities/v3/workspace-sessions.ts +24 -0
  35. package/src/entities/v3/workspace-state.ts +18 -0
  36. package/src/entities/v3/workspaces.ts +19 -0
  37. package/src/import-storages.ts +173 -0
  38. package/src/index.ts +53 -0
  39. package/src/log.ts +19 -0
  40. package/src/postgres.ts +70 -2
  41. package/src/schema.ts +16 -2
  42. package/src/sqlite.ts +87 -4
  43. package/src/storage-takeover/index.ts +60 -0
  44. package/src/storage-takeover/projection-cache.ts +445 -0
  45. package/src/storage-takeover/repository.ts +420 -0
  46. package/src/storage-takeover/storage-backend.ts +177 -0
  47. package/src/storage-takeover/types.ts +82 -0
  48. package/dist/schema-COK7-wRV.d.mts +0 -104
@@ -0,0 +1,420 @@
1
+ /**
2
+ * storages 接管表的方言无关实现:SQLite 与 PostgreSQL 的 drizzle 查询构建器
3
+ * 共享同一套调用形状(SQLite 走同步 `.all()`/`.get()`/`.run()`,PostgreSQL
4
+ * 走 thenable),差别由本模块的执行 helper 吸收。
5
+ *
6
+ * workspace 域是权威数据:记录、归属、显示顺序与归档集都拆成语义表,每次写入
7
+ * 在介质事务内完成(`host.writeAtomically`),不留部分应用的中间态。
8
+ */
9
+
10
+ import { and, eq, gte, isNotNull, notInArray, sql } from "drizzle-orm";
11
+ import { SessionId, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session";
12
+ import type { SessionSeqCursor } from "@deepseek-ai/dsh-session";
13
+ import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
14
+ import type {
15
+ CheckpointIdentity,
16
+ ProjectionCheckpoint,
17
+ ProjectionCheckpointRow,
18
+ StorageRepository,
19
+ StoredProjcacheEntry,
20
+ WorkspaceDomainState,
21
+ WorkspaceRecord,
22
+ } from "./types.ts";
23
+
24
+ /** 注入口:介质句柄 + 方言表对象 + 事务。 */
25
+ export interface StorageRepositoryHost {
26
+ /** 解析 drizzle 句柄;介质未就绪时调用方等待其 open 完成。 */
27
+ db: () => Promise<unknown>;
28
+ /**
29
+ * 同步驱动的 drizzle 句柄(SQLite)。缺席时 {@link StorageRepository.readProjcacheSync}
30
+ * 不提供:异步驱动(PostgreSQL)没有同步读,调用方只能维护写穿镜像。
31
+ * @returns the open drizzle handle.
32
+ */
33
+ dbSync?: () => unknown;
34
+ /** `toSqliteSchema` / `toPostgresSchema` 产出的方言表对象。 */
35
+ tables: Record<string, unknown>;
36
+ /**
37
+ * Run `fn` inside one medium transaction: every statement issued through
38
+ * {@link StorageRepositoryHost.db} while it runs joins that transaction, and
39
+ * a failure rolls the whole write back.
40
+ * @param fn - statements to run atomically.
41
+ * @returns the callback's result.
42
+ */
43
+ writeAtomically: <T>(fn: () => Promise<T>) => Promise<T>;
44
+ }
45
+
46
+ /** 多行查询:SQLite 同步 `.all()`,PostgreSQL await。 */
47
+ async function allRows<T>(query: unknown): Promise<T[]> {
48
+ const q = query as { all?: () => T[] };
49
+ if (typeof q.all === "function") return q.all();
50
+ return (await query) as T[];
51
+ }
52
+
53
+ /** 单行查询:SQLite 同步 `.get()`,PostgreSQL await 后取首行。 */
54
+ async function oneRow<T>(query: unknown): Promise<T | undefined> {
55
+ const q = query as { get?: () => T | undefined };
56
+ if (typeof q.get === "function") return q.get();
57
+ const rows = (await query) as T[];
58
+ return rows[0];
59
+ }
60
+
61
+ /** 写入语句:SQLite 同步 `.run()`,PostgreSQL await。 */
62
+ async function runQuery(query: unknown): Promise<void> {
63
+ const q = query as { run?: () => unknown };
64
+ if (typeof q.run === "function") {
65
+ q.run();
66
+ return;
67
+ }
68
+ await query;
69
+ }
70
+
71
+ /** `t_sessions` 上承载 checkpoint identity 的列(与投影行 1:1,不另存一份)。 */
72
+ interface SessionIdentityRow {
73
+ fVersion: number;
74
+ fCreatedAt: number;
75
+ fCwd: string | null;
76
+ fSeedLength: number | null;
77
+ }
78
+
79
+ /** `t_session_projcache_row` 的行形状(存储层视角)。 */
80
+ interface ProjcacheRowRecord {
81
+ fSessionId: string;
82
+ fKey: string;
83
+ fVer: number;
84
+ fSeq: number;
85
+ fVal: string;
86
+ }
87
+
88
+ /** 一行会话元数据 → 上游 `CheckpointIdentity`。 */
89
+ function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
90
+ return {
91
+ formatVersion: row.fVersion,
92
+ createdAt: row.fCreatedAt,
93
+ ...(row.fCwd === null ? {} : { cwd: row.fCwd }),
94
+ isSeeded: row.fSeedLength !== null,
95
+ ...(row.fSeedLength === null
96
+ ? {}
97
+ : { inheritedEventCount: SessionLogOffset(row.fSeedLength) }),
98
+ };
99
+ }
100
+
101
+ /** 存储的整数水位 → 上游 seq cursor(-1 是空日志哨兵)。 */
102
+ function seqCursor(seq: number): SessionSeqCursor {
103
+ return seq === -1 ? -1 : SessionSeq(seq);
104
+ }
105
+
106
+ /** 两写标记的两列 → 上游 `pendingMutation`(未知操作按缺失处理)。 */
107
+ function pendingMutationOf(row: {
108
+ fPendingOperation: string | null;
109
+ fPendingWorkspaceId: string | null;
110
+ }): WorkspaceDomainState["pendingMutation"] {
111
+ const operation = row.fPendingOperation;
112
+ if (operation !== "create" && operation !== "delete") return undefined;
113
+ return { operation, workspaceId: (row.fPendingWorkspaceId ?? "") as WorkspaceId };
114
+ }
115
+
116
+ /**
117
+ * 构建一个介质的 storages 接管访问层。
118
+ * @param host - 介质句柄、方言表对象与事务执行器。
119
+ * @returns 方言无关的访问层。
120
+ */
121
+ export function createStorageRepository(host: StorageRepositoryHost): StorageRepository {
122
+ /* eslint-disable @typescript-eslint/no-explicit-any -- drizzle 双方言表对象只在运行期共享查询形状 */
123
+ const tables = host.tables as Record<string, any>;
124
+ const tUnits = tables["t_storage_units"]!;
125
+ const tSessions = tables["t_sessions"]!;
126
+ const tWorkspaces = tables["t_workspaces"]!;
127
+ const tWorkspaceSessions = tables["t_workspace_sessions"]!;
128
+ const tWorkspaceState = tables["t_workspace_state"]!;
129
+ const tProjcacheRows = tables["t_session_projcache_row"]!;
130
+ /** checkpoint identity 的列投影:直接复用会话行,不再单存记录头。 */
131
+ const sessionIdentity = {
132
+ fSessionId: tSessions.fSessionId,
133
+ fVersion: tSessions.fVersion,
134
+ fCreatedAt: tSessions.fCreatedAt,
135
+ fCwd: tSessions.fCwd,
136
+ fSeedLength: tSessions.fSeedLength,
137
+ };
138
+ /* eslint-enable @typescript-eslint/no-explicit-any */
139
+
140
+ const dbx = async (): Promise<any> => (await host.db()) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
141
+
142
+ return {
143
+ async readUnitVersion(name: string): Promise<number | undefined> {
144
+ const db = await dbx();
145
+ const row = await oneRow<{ fVersion: number }>(
146
+ db.select().from(tUnits).where(eq(tUnits.fName, name)),
147
+ );
148
+ return row?.fVersion;
149
+ },
150
+
151
+ async insertUnitVersion(name: string, version: number): Promise<void> {
152
+ const db = await dbx();
153
+ await runQuery(
154
+ db.insert(tUnits).values({ fName: name, fVersion: version }).onConflictDoNothing(),
155
+ );
156
+ },
157
+
158
+ async listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>> {
159
+ const db = await dbx();
160
+ const rows = await allRows<{
161
+ fWorkspaceId: string;
162
+ fPath: string;
163
+ fTitle: string;
164
+ fCreatedAt: string;
165
+ fUpdatedAt: string;
166
+ }>(db.select().from(tWorkspaces));
167
+ const links = await allRows<{
168
+ fWorkspaceId: string;
169
+ fSessionId: string;
170
+ fPosition: number;
171
+ }>(db.select().from(tWorkspaceSessions));
172
+ const sessions = new Map<string, Array<{ id: string; position: number }>>();
173
+ for (const link of links) {
174
+ const owned = sessions.get(link.fWorkspaceId) ?? [];
175
+ owned.push({ id: link.fSessionId, position: link.fPosition });
176
+ sessions.set(link.fWorkspaceId, owned);
177
+ }
178
+ return rows.map((row) => ({
179
+ id: row.fWorkspaceId,
180
+ record: {
181
+ path: row.fPath,
182
+ title: row.fTitle,
183
+ sessionIds: (sessions.get(row.fWorkspaceId) ?? [])
184
+ .sort((left, right) => left.position - right.position)
185
+ .map((entry) => SessionId(entry.id)),
186
+ createdAt: row.fCreatedAt,
187
+ updatedAt: row.fUpdatedAt,
188
+ },
189
+ }));
190
+ },
191
+
192
+ async putWorkspace(id: string, record: WorkspaceRecord): Promise<void> {
193
+ await host.writeAtomically(async () => {
194
+ const db = await dbx();
195
+ const values = {
196
+ fWorkspaceId: id,
197
+ fPath: record.path,
198
+ fTitle: record.title,
199
+ fCreatedAt: record.createdAt,
200
+ fUpdatedAt: record.updatedAt,
201
+ };
202
+ await runQuery(
203
+ db
204
+ .insert(tWorkspaces)
205
+ .values(values)
206
+ .onConflictDoUpdate({ target: tWorkspaces.fWorkspaceId, set: values }),
207
+ );
208
+ // 归属整体替换:DELETE + 有序 INSERT 在同一事务内,读不到中间态。
209
+ await runQuery(
210
+ db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
211
+ );
212
+ const links = record.sessionIds.map((sessionId, position) => ({
213
+ fWorkspaceId: id,
214
+ fSessionId: sessionId,
215
+ fPosition: position,
216
+ }));
217
+ if (links.length > 0) await runQuery(db.insert(tWorkspaceSessions).values(links));
218
+ });
219
+ },
220
+
221
+ async deleteWorkspace(id: string): Promise<void> {
222
+ await host.writeAtomically(async () => {
223
+ const db = await dbx();
224
+ await runQuery(
225
+ db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
226
+ );
227
+ await runQuery(db.delete(tWorkspaces).where(eq(tWorkspaces.fWorkspaceId, id)));
228
+ });
229
+ },
230
+
231
+ async readWorkspaceState(): Promise<WorkspaceDomainState | null> {
232
+ const db = await dbx();
233
+ const row = await oneRow<{
234
+ fInitialized: number;
235
+ fPendingOperation: string | null;
236
+ fPendingWorkspaceId: string | null;
237
+ }>(db.select().from(tWorkspaceState).where(eq(tWorkspaceState.fSingleton, 1)));
238
+ if (row === undefined) return null;
239
+ const ordered = await allRows<{ fWorkspaceId: string }>(
240
+ db
241
+ .select()
242
+ .from(tWorkspaces)
243
+ .where(gte(tWorkspaces.fPosition, 0))
244
+ .orderBy(tWorkspaces.fPosition),
245
+ );
246
+ const archives = await allRows<{ fSessionId: string }>(
247
+ db
248
+ .select({ fSessionId: tSessions.fSessionId })
249
+ .from(tSessions)
250
+ .where(isNotNull(tSessions.fArchivedAt))
251
+ .orderBy(tSessions.fArchivedAt),
252
+ );
253
+ const pendingMutation = pendingMutationOf(row);
254
+ return {
255
+ initialized: row.fInitialized !== 0,
256
+ workspaceIds: ordered.map((entry) => entry.fWorkspaceId as WorkspaceId),
257
+ archivedSessionIds: archives.map((entry) => entry.fSessionId as SessionId),
258
+ ...(pendingMutation === undefined ? {} : { pendingMutation }),
259
+ };
260
+ },
261
+
262
+ async writeWorkspaceState(state: WorkspaceDomainState): Promise<void> {
263
+ await host.writeAtomically(async () => {
264
+ const db = await dbx();
265
+ const pending = state.pendingMutation as
266
+ | { operation?: unknown; workspaceId?: unknown }
267
+ | undefined;
268
+ const values = {
269
+ fSingleton: 1,
270
+ fInitialized: state.initialized ? 1 : 0,
271
+ fPendingOperation:
272
+ pending === undefined || typeof pending.operation !== "string"
273
+ ? null
274
+ : pending.operation,
275
+ fPendingWorkspaceId:
276
+ pending === undefined || typeof pending.workspaceId !== "string"
277
+ ? null
278
+ : pending.workspaceId,
279
+ };
280
+ await runQuery(
281
+ db
282
+ .insert(tWorkspaceState)
283
+ .values(values)
284
+ .onConflictDoUpdate({ target: tWorkspaceState.fSingleton, set: values }),
285
+ );
286
+ // 显示顺序整体替换:先清位,再按数组顺序定位(不在集合里的记录保持 -1)。
287
+ await runQuery(db.update(tWorkspaces).set({ fPosition: -1 }));
288
+ for (const [position, workspaceId] of state.workspaceIds.entries()) {
289
+ await runQuery(
290
+ db
291
+ .update(tWorkspaces)
292
+ .set({ fPosition: position })
293
+ .where(eq(tWorkspaces.fWorkspaceId, workspaceId)),
294
+ );
295
+ }
296
+ // 归档集整体替换:标记落在会话行(f_archived_at),未列出的会话清空标记。
297
+ const archived = state.archivedSessionIds;
298
+ await runQuery(
299
+ archived.length === 0
300
+ ? db
301
+ .update(tSessions)
302
+ .set({ fArchivedAt: null })
303
+ .where(isNotNull(tSessions.fArchivedAt))
304
+ : db
305
+ .update(tSessions)
306
+ .set({ fArchivedAt: null })
307
+ .where(
308
+ and(
309
+ isNotNull(tSessions.fArchivedAt),
310
+ notInArray(tSessions.fSessionId, archived),
311
+ ),
312
+ ),
313
+ );
314
+ const stamp = Date.now();
315
+ for (const sessionId of archived) {
316
+ // 首次归档时间保留:重复写入同一集合不刷新标记。
317
+ await runQuery(
318
+ db
319
+ .update(tSessions)
320
+ .set({ fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` })
321
+ .where(eq(tSessions.fSessionId, sessionId)),
322
+ );
323
+ }
324
+ });
325
+ },
326
+
327
+ async loadProjcache(): Promise<StoredProjcacheEntry[]> {
328
+ const db = await dbx();
329
+ const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
330
+ if (rows.length === 0) return [];
331
+ const sessions = await allRows<SessionIdentityRow & { fSessionId: string }>(
332
+ db.select(sessionIdentity).from(tSessions),
333
+ );
334
+ const entries = new Map<string, StoredProjcacheEntry>();
335
+ for (const session of sessions) {
336
+ entries.set(session.fSessionId, {
337
+ sessionId: SessionId(session.fSessionId),
338
+ identity: identityOfSession(session),
339
+ rows: {},
340
+ });
341
+ }
342
+ for (const row of rows) {
343
+ // 没有会话行的行读作缺失:checkpoint 的 identity 由会话行承载。
344
+ const entry = entries.get(row.fSessionId);
345
+ if (entry === undefined) continue;
346
+ entry.rows[row.fKey] = {
347
+ ver: row.fVer,
348
+ seq: seqCursor(row.fSeq),
349
+ val: JSON.parse(row.fVal),
350
+ } satisfies ProjectionCheckpointRow;
351
+ }
352
+ // 没有投影行的会话没有 checkpoint(避免把空记录装进异步镜像)。
353
+ return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
354
+ },
355
+
356
+ async putProjcache(
357
+ sessionId: string,
358
+ rows: ProjectionCheckpoint,
359
+ ): Promise<void> {
360
+ await host.writeAtomically(async () => {
361
+ const db = await dbx();
362
+ await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
363
+ const values = Object.entries(rows).map(([key, row]) => ({
364
+ fSessionId: sessionId,
365
+ fKey: key,
366
+ fVer: row.ver,
367
+ fSeq: row.seq,
368
+ fVal: JSON.stringify(row.val),
369
+ }));
370
+ if (values.length > 0) await runQuery(db.insert(tProjcacheRows).values(values));
371
+ });
372
+ },
373
+
374
+ async deleteProjcache(sessionId: string): Promise<void> {
375
+ const db = await dbx();
376
+ await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
377
+ },
378
+
379
+ // 同步驱动(SQLite)才有:读路径直接查介质,进程内不维护 checkpoint 镜像。
380
+ ...(host.dbSync === undefined
381
+ ? {}
382
+ : {
383
+ readProjcacheSync: (sessionId: string): StoredProjcacheEntry | undefined => {
384
+ const db = host.dbSync!() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
385
+ const stored = db
386
+ .select()
387
+ .from(tProjcacheRows)
388
+ .where(eq(tProjcacheRows.fSessionId, sessionId))
389
+ .all() as ProjcacheRowRecord[];
390
+ if (stored.length === 0) return undefined;
391
+ const session = db
392
+ .select(sessionIdentity)
393
+ .from(tSessions)
394
+ .where(eq(tSessions.fSessionId, sessionId))
395
+ .get() as (SessionIdentityRow & { fSessionId: string }) | undefined;
396
+ // 没有会话行(未物化或已删)就没有可校验的 identity → 读作缺失。
397
+ if (session === undefined) return undefined;
398
+ const rows: ProjectionCheckpoint = {};
399
+ for (const row of stored) {
400
+ rows[row.fKey] = {
401
+ ver: row.fVer,
402
+ seq: seqCursor(row.fSeq),
403
+ val: JSON.parse(row.fVal),
404
+ };
405
+ }
406
+ return { sessionId: SessionId(sessionId), identity: identityOfSession(session), rows };
407
+ },
408
+ readSessionTitleSync: (sessionId: string): { title: string; seq: number } | undefined => {
409
+ const db = host.dbSync!() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
410
+ const row = db
411
+ .select({ fTitle: tSessions.fTitle, fTitleSeq: tSessions.fTitleSeq })
412
+ .from(tSessions)
413
+ .where(eq(tSessions.fSessionId, sessionId))
414
+ .get() as { fTitle: string | null; fTitleSeq: number | null } | undefined;
415
+ if (row === undefined || row.fTitle === null || row.fTitleSeq === null) return undefined;
416
+ return { title: row.fTitle, seq: row.fTitleSeq };
417
+ },
418
+ }),
419
+ };
420
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * storages 接管:注册在 storage hub 上的 `rdb` KV 后端。它把 workspace 域
3
+ * (上游 single 布局:一张 `workspaces` 表 + global 单例)映射到 session-rdb
4
+ * 的语义专用表,与事件日志同库同连接;域层(`ctx.storageDomain`)通过
5
+ * `routes` 把 `workspace` 域路由到本后端。
6
+ *
7
+ * 本后端只服务已知域的专用表:未知 unit 名 fail loud,而不是静默落到通用
8
+ * 存储——表结构是显式维护的(见 docs/schema.md)。
9
+ */
10
+
11
+ import { StorageError } from "@deepseek-ai/dsh-storage";
12
+ import type { SessionId } from "@deepseek-ai/dsh-session";
13
+ import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
14
+ import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
15
+ import type { StorageRepository, WorkspaceRecord, WorkspaceDomainState } from "./types.ts";
16
+
17
+ /** 注册到 storage hub 的后端名(storage-domain 的 routes 引用它)。 */
18
+ export const RDB_STORAGE_BACKEND = "rdb";
19
+
20
+ /** 本后端唯一服务的域:上游 `workspaceDomainSpec`(v2,single 布局)。 */
21
+ const WORKSPACE_UNIT = "workspace";
22
+ const WORKSPACE_TABLE = "workspaces";
23
+
24
+ /** 只服务 workspace 域的 KV 后端。 */
25
+ export class RdbStorageBackend implements StorageBackend {
26
+ readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
27
+
28
+ /** 已打开(或打开中)的 unit 名;重复 open 是调用方 bug。 */
29
+ private readonly open = new Set<string>();
30
+ private closed = false;
31
+
32
+ /**
33
+ * @param repository - storages 接管表访问层(与事件日志同介质)。
34
+ */
35
+ constructor(private readonly repository: StorageRepository) {}
36
+
37
+ private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
38
+ if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
39
+ if (descriptor.name !== WORKSPACE_UNIT) {
40
+ throw new Error(
41
+ `rdb storage backend serves only the '${WORKSPACE_UNIT}' domain (requested '${descriptor.name}')`,
42
+ );
43
+ }
44
+ if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) {
45
+ throw new Error(
46
+ `rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape ` +
47
+ `(table '${WORKSPACE_TABLE}' plus a global slot)`,
48
+ );
49
+ }
50
+ if (this.open.has(descriptor.name)) {
51
+ throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
52
+ }
53
+ const stored = await this.repository.readUnitVersion(descriptor.name);
54
+ if (stored === undefined) {
55
+ await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
56
+ } else if (stored !== descriptor.version) {
57
+ throw new StorageError(
58
+ "version-mismatch",
59
+ `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
60
+ `incompatible with descriptor version ${descriptor.version}`,
61
+ );
62
+ }
63
+ this.open.add(descriptor.name);
64
+ return new WorkspaceKvUnit(this.repository, descriptor, () => {
65
+ this.open.delete(descriptor.name);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Release the backend. The medium (the session database) belongs to the
71
+ * owning session-rdb plugin, so closing open units here does not close it.
72
+ * @returns resolution after the name table is cleared.
73
+ */
74
+ async close(): Promise<void> {
75
+ this.closed = true;
76
+ this.open.clear();
77
+ }
78
+ }
79
+
80
+ /** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
81
+ class WorkspaceKvUnit implements KvUnit {
82
+ private closed = false;
83
+
84
+ constructor(
85
+ private readonly repository: StorageRepository,
86
+ private readonly descriptor: KvUnitDescriptor,
87
+ private readonly onClose: () => void,
88
+ ) {}
89
+
90
+ async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
91
+ this.assertOpen();
92
+ const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
93
+ for (const { id, record } of await this.repository.listWorkspaces()) {
94
+ records[id] = record;
95
+ }
96
+ const state = await this.repository.readWorkspaceState();
97
+ return { tables: { [WORKSPACE_TABLE]: records }, global: state };
98
+ }
99
+
100
+ async putRecord(table: string, key: string, value: unknown): Promise<void> {
101
+ this.assertOpen();
102
+ this.assertTable(table);
103
+ await this.repository.putWorkspace(key, workspaceRecordOf(value));
104
+ }
105
+
106
+ async deleteRecord(table: string, key: string): Promise<void> {
107
+ this.assertOpen();
108
+ this.assertTable(table);
109
+ await this.repository.deleteWorkspace(key);
110
+ }
111
+
112
+ async setGlobal(value: unknown): Promise<void> {
113
+ this.assertOpen();
114
+ await this.repository.writeWorkspaceState(workspaceStateOf(value));
115
+ }
116
+
117
+ async close(): Promise<void> {
118
+ if (this.closed) return;
119
+ this.closed = true;
120
+ this.onClose();
121
+ }
122
+
123
+ private assertOpen(): void {
124
+ if (this.closed) {
125
+ throw new StorageError("closed", `kv unit '${this.descriptor.name}' is closed`);
126
+ }
127
+ }
128
+
129
+ private assertTable(table: string): void {
130
+ if (table !== WORKSPACE_TABLE) {
131
+ throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Narrow one opaque KV record to the workspace record the domain shipped. */
137
+ function workspaceRecordOf(value: unknown): WorkspaceRecord {
138
+ const record = value as Partial<WorkspaceRecord> | null;
139
+ if (
140
+ typeof record !== "object" ||
141
+ record === null ||
142
+ typeof record.path !== "string" ||
143
+ typeof record.title !== "string" ||
144
+ !Array.isArray(record.sessionIds) ||
145
+ typeof record.createdAt !== "string" ||
146
+ typeof record.updatedAt !== "string"
147
+ ) {
148
+ throw new TypeError("workspace record does not match the stored shape");
149
+ }
150
+ return {
151
+ path: record.path,
152
+ title: record.title,
153
+ sessionIds: record.sessionIds.map((id) => id as SessionId),
154
+ createdAt: record.createdAt,
155
+ updatedAt: record.updatedAt,
156
+ };
157
+ }
158
+
159
+ /** Narrow one opaque KV global to the workspace registry state the domain shipped. */
160
+ function workspaceStateOf(value: unknown): WorkspaceDomainState {
161
+ const state = value as Partial<WorkspaceDomainState> | null;
162
+ if (
163
+ typeof state !== "object" ||
164
+ state === null ||
165
+ typeof state.initialized !== "boolean" ||
166
+ !Array.isArray(state.workspaceIds) ||
167
+ !Array.isArray(state.archivedSessionIds)
168
+ ) {
169
+ throw new TypeError("workspace registry state does not match the stored shape");
170
+ }
171
+ return {
172
+ initialized: state.initialized,
173
+ workspaceIds: state.workspaceIds.map((id) => id as WorkspaceId),
174
+ archivedSessionIds: state.archivedSessionIds.map((id) => id as SessionId),
175
+ ...(state.pendingMutation === undefined ? {} : { pendingMutation: state.pendingMutation }),
176
+ };
177
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * storages 接管的类型:**能复用上游契约的一律复用**——记录形状来自上游
3
+ * domain/projection 的 `z.infer` 产物(`WorkspaceRecord` / `WorkspaceDomainState`
4
+ * / `CheckpointIdentity` / `ProjectionCheckpoint`),本文件只声明存储层的访问
5
+ * 接口与"上游类型 + 会话键"的组合。上游 spec 变化时这里直接编译报错,不会
6
+ * 静默偏移。
7
+ *
8
+ * 这些 import 全是类型导入(无运行时依赖):rdb 是通用插件,不能硬依赖
9
+ * web 层的 workspace 包或已被替换的 projcache 包。
10
+ */
11
+
12
+ import type { SessionId } from "@deepseek-ai/dsh-session";
13
+ import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
14
+ import type { ProjectionCheckpoint, ProjectionCheckpointRow } from "@deepseek-ai/dsh-session-projection";
15
+ import type { WorkspaceDomainState, WorkspaceRecord } from "@deepseek-ai/dsh-workspace";
16
+
17
+ export type {
18
+ CheckpointIdentity,
19
+ ProjectionCheckpoint,
20
+ ProjectionCheckpointRow,
21
+ WorkspaceDomainState,
22
+ WorkspaceRecord,
23
+ };
24
+
25
+ /**
26
+ * 一个会话的完整 checkpoint:上游 `CheckpointIdentity`(日志身份,唯一权威定义
27
+ * 在被替换的 projcache spec 里)+ 上游 `ProjectionCheckpoint`(逐 key 行)+ 会话键。
28
+ */
29
+ export interface StoredProjcacheEntry {
30
+ sessionId: SessionId;
31
+ identity: CheckpointIdentity;
32
+ rows: ProjectionCheckpoint;
33
+ }
34
+
35
+ /**
36
+ * storages 接管表的方言无关访问层:SQLite 与 PostgreSQL 两个 Backend 各提供
37
+ * 同一实现,调用方(KV 后端与投影缓存服务)不感知介质差异。读方法返回持久
38
+ * 值的语义视图,workspace 的写入在介质事务内整体替换。
39
+ */
40
+ export interface StorageRepository {
41
+ /** 读一个域的版本戳;域从未打开时 undefined。 */
42
+ readUnitVersion(name: string): Promise<number | undefined>;
43
+ /** 写入域的首次版本戳;已存在时保持原值。 */
44
+ insertUnitVersion(name: string, version: number): Promise<void>;
45
+ /** 读全部 workspace 记录(按 workspace id)。 */
46
+ listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>>;
47
+ /** 覆盖写一条 workspace 记录(含归属行)。 */
48
+ putWorkspace(id: string, record: WorkspaceRecord): Promise<void>;
49
+ /** 删除一条 workspace 记录(缺失为 no-op)。 */
50
+ deleteWorkspace(id: string): Promise<void>;
51
+ /** 读 workspace 单例;从未写入时 null。 */
52
+ readWorkspaceState(): Promise<WorkspaceDomainState | null>;
53
+ /** 覆盖写 workspace 单例(显示顺序与归档集一并整体替换)。 */
54
+ writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
55
+ /** 读全部投影 checkpoint(含逐 key 行)。 */
56
+ loadProjcache(): Promise<StoredProjcacheEntry[]>;
57
+ /**
58
+ * 同步读一个会话的 checkpoint:只有同步驱动(SQLite)提供,读路径直接落到
59
+ * 介质;异步驱动(PostgreSQL)缺席,调用方退化为写穿镜像。上游的
60
+ * `cachedSnapshot` 系列是同步签名,这里的存在性就是"能否直读库"的开关。
61
+ * @param sessionId - 会话 id。
62
+ * @returns 该会话的 checkpoint,缺失时 `undefined`。
63
+ */
64
+ readProjcacheSync?(sessionId: string): StoredProjcacheEntry | undefined;
65
+ /**
66
+ * 同步读一个会话的标题列(`t_sessions.f_title` / `f_title_seq`,rdb 写路径与
67
+ * rewind 维护):只有同步驱动提供,列表消费直接取会话数据,不依赖 checkpoint
68
+ * 行是否存在。
69
+ * @param sessionId - 会话 id。
70
+ * @returns 标题与其事件 seq,未写过标题时 `undefined`。
71
+ */
72
+ readSessionTitleSync?(sessionId: string): { title: string; seq: number } | undefined;
73
+ /**
74
+ * 覆盖写一个会话的 checkpoint 行(先清旧行再写新行,同一事务)。记录头
75
+ * 不在这里:它与 `t_sessions` 行 1:1,identity 直接复用该行的列。
76
+ * @param sessionId - 会话 id。
77
+ * @param rows - 每个投影 key 一行。
78
+ */
79
+ putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
80
+ /** 删除一个会话的 checkpoint(缺失为 no-op)。 */
81
+ deleteProjcache(sessionId: string): Promise<void>;
82
+ }