@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,19 +1,12 @@
1
1
  import { StorageError } from "@deepseek-ai/dsh-storage";
2
- import type { SessionId } from "@deepseek-ai/dsh-session";
3
- import type { ScheduleTask } from "@deepseek-ai/dsh-schedule";
4
- import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
5
2
  import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
6
- import type { StorageRepository, WorkspaceRecord, WorkspaceDomainState } from "./types.ts";
3
+ import type { StorageRepository } from "./types.ts";
7
4
 
8
5
  export const RDB_STORAGE_BACKEND = "rdb";
9
6
 
10
- // 本后端服务哪些域:一个域一个显式映射(表结构显式维护,未知域名 fail loud——见
11
- // `.agents/adrs/20260917-接管storages到rdb语义表.md`)。官方那几行谁读哪个域是上游的事,
12
- // 这里只回答"这个域的数据落在哪张表"。
13
- const WORKSPACE_UNIT = "workspace";
14
- const WORKSPACE_TABLE = "workspaces";
15
- const SCHEDULE_UNIT = "schedule";
16
- const SCHEDULE_TABLE = "tasks";
7
+ // 本后端服务**所有** storage 域:`(域, 表, 键)` 一行落 `t_storage_records`,global 单例用空表名与空键。
8
+ // 上游 `KvUnit` 的契约只有「按表名读写记录 + 一个 global」这几种操作,行存储直接承载它,所以再加一个域
9
+ // (不论它自带几张表、还是只有 global)都不需要新表或新映射——域名就是行的一部分。
17
10
 
18
11
  // 本后端对 unit 的内部约定:关闭时把域名让回后端(上游 `KvUnit` 没有这个入口,它归"谁能再开一次"这件事)。
19
12
  interface ReleasableKvUnit extends KvUnit {
@@ -24,29 +17,31 @@ export class RdbStorageBackend implements StorageBackend {
24
17
  readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
25
18
 
26
19
  private readonly open = new Map<string, ReleasableKvUnit>();
20
+ // 正在打开的名字:`open()` 要先等介质,占位必须在 await 之前,否则并发 open 会双双通过检查。
21
+ private readonly opening = new Set<string>();
27
22
  private closed = false;
28
23
 
29
- constructor(private readonly repository: StorageRepository) {}
24
+ constructor(private readonly repository: () => Promise<StorageRepository>) {}
30
25
 
31
26
  private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
32
27
  if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
33
- if (descriptor.layout !== undefined && descriptor.layout !== "single") {
34
- throw new Error(
35
- `rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
36
- `declares '${descriptor.layout}')`,
37
- );
38
- }
39
- if (this.open.has(descriptor.name)) {
28
+ if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
40
29
  throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
41
30
  }
31
+ this.opening.add(descriptor.name);
42
32
 
43
- const unit = this.unitFor(descriptor);
33
+ const repository = await this.repository();
34
+ const unit = new RdbKvUnit(repository, descriptor);
44
35
  this.open.set(descriptor.name, unit);
36
+ this.opening.delete(descriptor.name);
45
37
  try {
46
- const stored = await this.repository.readUnitVersion(descriptor.name);
38
+ const stored = await repository.readUnitVersion(descriptor.name);
39
+ const accepted =
40
+ stored === descriptor.version ||
41
+ (descriptor.compatibleVersions ?? []).includes(stored ?? -1);
47
42
  if (stored === undefined) {
48
- await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
49
- } else if (stored !== descriptor.version) {
43
+ await repository.insertUnitVersion(descriptor.name, descriptor.version);
44
+ } else if (!accepted) {
50
45
  throw new StorageError(
51
46
  "version-mismatch",
52
47
  `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
@@ -55,6 +50,7 @@ export class RdbStorageBackend implements StorageBackend {
55
50
  }
56
51
  } catch (error) {
57
52
  this.open.delete(descriptor.name);
53
+ this.opening.delete(descriptor.name);
58
54
  throw error;
59
55
  }
60
56
  unit.onClose(() => {
@@ -63,31 +59,6 @@ export class RdbStorageBackend implements StorageBackend {
63
59
  return unit;
64
60
  }
65
61
 
66
- private unitFor(descriptor: KvUnitDescriptor): ReleasableKvUnit {
67
- if (descriptor.name === WORKSPACE_UNIT) {
68
- if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) {
69
- throw new Error(
70
- `rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape ` +
71
- `(table '${WORKSPACE_TABLE}' plus a global slot)`,
72
- );
73
- }
74
- return new WorkspaceKvUnit(this.repository, descriptor);
75
- }
76
- if (descriptor.name === SCHEDULE_UNIT) {
77
- if (!descriptor.tables.includes(SCHEDULE_TABLE) || descriptor.hasGlobal !== false) {
78
- throw new Error(
79
- `rdb storage backend expects the '${SCHEDULE_UNIT}' domain shape ` +
80
- `(table '${SCHEDULE_TABLE}' without a global slot)`,
81
- );
82
- }
83
- return new ScheduleKvUnit(this.repository, descriptor);
84
- }
85
- throw new Error(
86
- `rdb storage backend serves only the '${WORKSPACE_UNIT}' and '${SCHEDULE_UNIT}' domains ` +
87
- `(requested '${descriptor.name}')`,
88
- );
89
- }
90
-
91
62
  async close(): Promise<void> {
92
63
  this.closed = true;
93
64
  const units = [...this.open.values()];
@@ -96,8 +67,7 @@ export class RdbStorageBackend implements StorageBackend {
96
67
  }
97
68
  }
98
69
 
99
- // 一个 KV unit 的公共形态:关闭后拒写、关闭前把已接受的写排干、关闭回调把名字让回给后端。
100
- // 域自己的读写在子类里(`unitFor` 的返回类型就地校验它们合得上 `KvUnit`)。
70
+ // 一个 unit 的公共形态:关闭后拒写、关闭前把已接受的写排干、关闭回调把名字让回给后端。
101
71
  abstract class TrackedKvUnit {
102
72
  private closed = false;
103
73
 
@@ -110,9 +80,6 @@ abstract class TrackedKvUnit {
110
80
  protected readonly descriptor: KvUnitDescriptor,
111
81
  ) {}
112
82
 
113
- // 这个 unit 认的表名(由各自的域决定)。
114
- protected abstract readonly tables: readonly string[];
115
-
116
83
  onClose(release: () => void): void {
117
84
  this.onClosed = release;
118
85
  }
@@ -146,139 +113,57 @@ abstract class TrackedKvUnit {
146
113
  }
147
114
 
148
115
  protected assertTable(table: string): void {
149
- if (!this.tables.includes(table)) {
116
+ if (!this.descriptor.tables.includes(table)) {
150
117
  throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
151
118
  }
152
119
  }
153
120
  }
154
121
 
155
- class WorkspaceKvUnit extends TrackedKvUnit {
156
- protected readonly tables = [WORKSPACE_TABLE];
157
-
122
+ // 行存储:记录按 `(域, 表, 键)` 一行,global 单例是空表名的那一行。`single` 与 `per-record` 两种布局
123
+ // 在这里同构(一行一条记录),所以布局不再拒绝——版本仍按域戳。
124
+ class RdbKvUnit extends TrackedKvUnit {
158
125
  async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
159
126
  this.assertOpen();
160
- const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
161
- for (const { id, record } of await this.repository.listWorkspaces()) {
162
- records[id] = record;
127
+ const tables: Record<string, Record<string, unknown>> = Object.create(null) as Record<
128
+ string,
129
+ Record<string, unknown>
130
+ >;
131
+ for (const record of await this.repository.listDomainRecords(this.descriptor.name)) {
132
+ (tables[record.table] ??= {})[record.key] = record.value;
163
133
  }
164
- const state = await this.repository.readWorkspaceState();
165
- return { tables: { [WORKSPACE_TABLE]: records }, global: state };
134
+ // 声明了但从未写过的表也要出现在快照里(域层按 spec.tables 逐表读)。
135
+ for (const table of this.descriptor.tables) tables[table] ??= {};
136
+ return {
137
+ tables,
138
+ global: this.descriptor.hasGlobal
139
+ ? await this.repository.readDomainGlobal(this.descriptor.name)
140
+ : null,
141
+ };
166
142
  }
167
143
 
168
144
  async putRecord(table: string, key: string, value: unknown): Promise<void> {
169
145
  this.assertOpen();
170
146
  this.assertTable(table);
171
- await this.track(this.repository.putWorkspace(key, workspaceRecordOf(value)));
147
+ await this.track(this.repository.putDomainRecord(this.descriptor.name, table, key, value));
172
148
  }
173
149
 
174
150
  async deleteRecord(table: string, key: string): Promise<void> {
175
151
  this.assertOpen();
176
152
  this.assertTable(table);
177
- await this.track(this.repository.deleteWorkspace(key));
153
+ await this.track(this.repository.deleteDomainRecord(this.descriptor.name, table, key));
178
154
  }
179
155
 
180
- // 上游 `KvUnit.deleteRecords`:行存储一次批量做完(不让调用方逐条重试)。
181
156
  async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
182
157
  this.assertOpen();
183
158
  this.assertTable(table);
184
- await this.track(this.repository.deleteWorkspaces([...new Set(keys)]));
159
+ await this.track(this.repository.deleteDomainRecords(this.descriptor.name, table, keys));
185
160
  }
186
161
 
187
162
  async setGlobal(value: unknown): Promise<void> {
188
163
  this.assertOpen();
189
- await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
190
- }
191
- }
192
-
193
- class ScheduleKvUnit extends TrackedKvUnit {
194
- protected readonly tables = [SCHEDULE_TABLE];
195
-
196
- async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
197
- this.assertOpen();
198
- const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
199
- for (const { id, task } of await this.repository.listScheduleTasks()) {
200
- records[id] = task;
164
+ if (!this.descriptor.hasGlobal) {
165
+ throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
201
166
  }
202
- // 这个域没有 global 槽(`scheduleDomain` 不声明):`null` = "从未写过",域层不会读它。
203
- return { tables: { [SCHEDULE_TABLE]: records }, global: null };
204
- }
205
-
206
- async putRecord(table: string, key: string, value: unknown): Promise<void> {
207
- this.assertOpen();
208
- this.assertTable(table);
209
- await this.track(this.repository.putScheduleTask(key, scheduleTaskOf(value)));
210
- }
211
-
212
- async deleteRecord(table: string, key: string): Promise<void> {
213
- this.assertOpen();
214
- this.assertTable(table);
215
- await this.track(this.repository.deleteScheduleTask(key));
216
- }
217
-
218
- async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
219
- this.assertOpen();
220
- this.assertTable(table);
221
- await this.track(this.repository.deleteScheduleTasks([...new Set(keys)]));
222
- }
223
-
224
- setGlobal(_value: unknown): Promise<void> {
225
- throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
226
- }
227
- }
228
-
229
- function workspaceRecordOf(value: unknown): WorkspaceRecord {
230
- const record = value as Partial<WorkspaceRecord> | null;
231
- if (
232
- typeof record !== "object" ||
233
- record === null ||
234
- typeof record.path !== "string" ||
235
- typeof record.title !== "string" ||
236
- !Array.isArray(record.sessionIds) ||
237
- typeof record.createdAt !== "string" ||
238
- typeof record.updatedAt !== "string"
239
- ) {
240
- throw new TypeError("workspace record does not match the stored shape");
241
- }
242
- return {
243
- path: record.path,
244
- title: record.title,
245
- sessionIds: record.sessionIds.map((id) => id as SessionId),
246
- createdAt: record.createdAt,
247
- updatedAt: record.updatedAt,
248
- };
249
- }
250
-
251
- function workspaceStateOf(value: unknown): WorkspaceDomainState {
252
- const state = value as Partial<WorkspaceDomainState> | null;
253
- if (
254
- typeof state !== "object" ||
255
- state === null ||
256
- typeof state.initialized !== "boolean" ||
257
- !Array.isArray(state.workspaceIds) ||
258
- !Array.isArray(state.archivedSessionIds) ||
259
- !Array.isArray(state.pinnedSessionIds)
260
- ) {
261
- throw new TypeError("workspace registry state does not match the stored shape");
262
- }
263
- return {
264
- initialized: state.initialized,
265
- workspaceIds: state.workspaceIds.map((id) => id as WorkspaceId),
266
- archivedSessionIds: state.archivedSessionIds.map((id) => id as SessionId),
267
- pinnedSessionIds: state.pinnedSessionIds.map((id) => id as SessionId),
268
- ...(state.pendingMutation === undefined ? {} : { pendingMutation: state.pendingMutation }),
269
- };
270
- }
271
-
272
- function scheduleTaskOf(value: unknown): ScheduleTask {
273
- const task = value as Partial<ScheduleTask> | null;
274
- if (
275
- typeof task !== "object" ||
276
- task === null ||
277
- typeof task.sessionId !== "string" ||
278
- typeof task.record !== "object" ||
279
- task.record === null
280
- ) {
281
- throw new TypeError("schedule task does not match the stored shape");
167
+ await this.track(this.repository.writeDomainGlobal(this.descriptor.name, value));
282
168
  }
283
- return task as ScheduleTask;
284
169
  }
@@ -6,6 +6,7 @@ import type {
6
6
  ProjectionCheckpointRow,
7
7
  } from "@deepseek-ai/dsh-session-projection";
8
8
  import type { WorkspaceDomainState, WorkspaceRecord } from "@deepseek-ai/dsh-workspace";
9
+ import type { Database } from "@morlay/rdbkit";
9
10
 
10
11
  export type {
11
12
  CheckpointIdentity,
@@ -16,10 +17,32 @@ export type {
16
17
  WorkspaceRecord,
17
18
  };
18
19
 
20
+ export interface DomainRecord {
21
+ table: string;
22
+ key: string;
23
+ value: unknown;
24
+ }
25
+
26
+ export interface WorkspaceIndexEntry {
27
+ id: string;
28
+ title: string;
29
+ sessionIds: string[];
30
+ }
31
+
32
+ export interface StorageRepositoryHost {
33
+ /** 统一的查询面(结构、迁移与域读写都走它)。 */
34
+ database: Database;
35
+
36
+ /** 把一组写做成一次事务(宿主管连接与隔离级别)。 */
37
+ writeAtomically: <T>(fn: (database: Database) => Promise<T>) => Promise<T>;
38
+ }
39
+
19
40
  export interface StoredProjcacheEntry {
20
41
  sessionId: SessionId;
21
42
  identity: CheckpointIdentity;
22
43
  rows: ProjectionCheckpoint;
44
+ /** 会话标题(随快照一起加载 / 写路径刷新;`cachedSnapshot` 缺标题时用它补,不回库直读)。 */
45
+ title?: { title: string; seq: number };
23
46
  }
24
47
 
25
48
  export interface StorageRepository {
@@ -27,36 +50,39 @@ export interface StorageRepository {
27
50
 
28
51
  insertUnitVersion(name: string, version: number): Promise<void>;
29
52
 
30
- listWorkspaces(): Promise<Array<{ id: string; record: WorkspaceRecord }>>;
53
+ // 所有 storage 域共用:`(domain, table, key)` 一行,值是整份记录文档。global 单例用
54
+ // `table = ''`、`key = ''`;读不到返回 `null`(域层据此改用 spec 的 initial,与上游
55
+ // `KvUnit.loadAll` 的哨兵语义一致)。
56
+ listDomainRecords(domain: string): Promise<DomainRecord[]>;
31
57
 
32
- putWorkspace(id: string, record: WorkspaceRecord): Promise<void>;
58
+ readDomainGlobal(domain: string): Promise<unknown>;
33
59
 
34
- deleteWorkspace(id: string): Promise<void>;
60
+ putDomainRecord(domain: string, table: string, key: string, value: unknown): Promise<void>;
35
61
 
36
- // 一次批量(上游 `KvUnit.deleteRecords` 的契约:行存储要么一次做完、要么不做)。
37
- deleteWorkspaces(ids: readonly string[]): Promise<void>;
38
-
39
- readWorkspaceState(): Promise<WorkspaceDomainState | null>;
40
-
41
- writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
42
-
43
- // 官方 `schedule` 域的 `tasks` 表:一个提醒一行(key 是 `ScheduleId`)。
44
- listScheduleTasks(): Promise<Array<{ id: string; task: ScheduleTask }>>;
62
+ deleteDomainRecord(domain: string, table: string, key: string): Promise<void>;
45
63
 
46
- putScheduleTask(id: string, task: ScheduleTask): Promise<void>;
64
+ // 一次批量(上游 `KvUnit.deleteRecords` 的契约:行存储要么一次做完、要么不做)。
65
+ deleteDomainRecords(domain: string, table: string, keys: readonly string[]): Promise<void>;
47
66
 
48
- deleteScheduleTask(id: string): Promise<void>;
67
+ writeDomainGlobal(domain: string, value: unknown): Promise<void>;
49
68
 
50
- deleteScheduleTasks(ids: readonly string[]): Promise<void>;
69
+ // 会话面读 workspace 文档:列表的标题与搜索、删除会话时清理归属。数据只有 `t_storage_records`
70
+ // 一份,所以这两条走文档(workspace 数量级小,整份读没问题)。
71
+ listWorkspaceIndex(): Promise<WorkspaceIndexEntry[]>;
51
72
 
52
73
  loadProjcache(): Promise<StoredProjcacheEntry[]>;
53
74
 
54
- // 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。
55
- readProjcacheDirect?(sessionId: string): StoredProjcacheEntry | undefined;
56
-
57
- readSessionTitleDirect?(sessionId: string): { title: string; seq: number } | undefined;
75
+ /** 会话标题(投影缓存的镜像刷新与 `cachedSnapshot` 补标题用)。 */
76
+ readSessionTitle(sessionId: string): Promise<{ title: string; seq: number } | undefined>;
58
77
 
59
78
  putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
60
79
 
80
+ /** 增量写回:只 upsert `changed` 里的行、删掉 `removedKeys`,其余行原样不动。 */
81
+ patchProjcache(
82
+ sessionId: string,
83
+ changed: ProjectionCheckpoint,
84
+ removedKeys: readonly string[],
85
+ ): Promise<void>;
86
+
61
87
  pruneStaleProjcache(): Promise<number>;
62
88
  }
@@ -0,0 +1,65 @@
1
+ import { E, type Database } from "@morlay/rdbkit";
2
+ import { storageRecords } from "../entities/index.ts";
3
+ import type { WorkspaceDomainState, WorkspaceRecord } from "./types.ts";
4
+
5
+ const WORKSPACE_DOMAIN = "workspace";
6
+ const WORKSPACE_TABLE = "workspaces";
7
+ const GLOBAL_TABLE = "";
8
+
9
+ /**
10
+ * 删除会话后清掉 workspace 文档里的归属与归档 / 钉住集合。
11
+ *
12
+ * 域存储只有 `t_storage_records` 一份,没有再 join 的归属表,所以删除路径得读改文档。调用方保证在
13
+ * 写事务内:SQLite 用同一连接上的 `Database`,PG 用当前事务的 scoped `Database`。
14
+ * @param database - 当前执行面(事务内就是 scoped 实例)。
15
+ * @param ids - 刚删除的会话 id。
16
+ */
17
+ export async function pruneWorkspaceDocuments(
18
+ database: Database,
19
+ ids: readonly string[],
20
+ ): Promise<void> {
21
+ if (ids.length === 0) return;
22
+ const records = database.table(storageRecords);
23
+ const scope = E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, WORKSPACE_TABLE));
24
+ const drop = new Set<string>(ids);
25
+
26
+ for await (const row of records
27
+ .select({ key: records.f_key, value: records.f_value })
28
+ .where(scope)) {
29
+ const record = JSON.parse(row.value) as WorkspaceRecord;
30
+ const kept = record.sessionIds.filter((sessionId) => !drop.has(sessionId));
31
+ if (kept.length === record.sessionIds.length) continue;
32
+ await records.update(
33
+ { f_value: JSON.stringify({ ...record, sessionIds: kept }) },
34
+ E.and(
35
+ E.eq(records.f_domain, WORKSPACE_DOMAIN),
36
+ E.eq(records.f_table, WORKSPACE_TABLE),
37
+ E.eq(records.f_key, row.key),
38
+ ),
39
+ );
40
+ }
41
+
42
+ let globalValue: string | undefined;
43
+ for await (const row of records
44
+ .select({ value: records.f_value })
45
+ .where(E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, GLOBAL_TABLE)))) {
46
+ globalValue = row.value;
47
+ }
48
+ if (globalValue === undefined) return;
49
+ const state = JSON.parse(globalValue) as WorkspaceDomainState;
50
+ const archivedSessionIds = state.archivedSessionIds.filter((sessionId) => !drop.has(sessionId));
51
+ const pinnedSessionIds = (state.pinnedSessionIds ?? []).filter(
52
+ (sessionId) => !drop.has(sessionId),
53
+ );
54
+ if (
55
+ archivedSessionIds.length === state.archivedSessionIds.length &&
56
+ pinnedSessionIds.length === (state.pinnedSessionIds ?? []).length
57
+ ) {
58
+ return;
59
+ }
60
+ const next: WorkspaceDomainState = { ...state, archivedSessionIds, pinnedSessionIds };
61
+ await records.update(
62
+ { f_value: JSON.stringify(next) },
63
+ E.and(E.eq(records.f_domain, WORKSPACE_DOMAIN), E.eq(records.f_table, GLOBAL_TABLE)),
64
+ );
65
+ }
package/src/storage.ts CHANGED
@@ -1,3 +1,5 @@
1
- export * from "./schema.ts";
2
- export * from "./sqlite.ts";
1
+ export * from "./version.ts";
2
+ export * from "./event-shape.ts";
3
+ export * from "./backend-rdb.ts";
3
4
  export * from "./write-guard.ts";
5
+ export type { EventRow, SessionRow } from "./backend.ts";
package/src/testing.ts CHANGED
@@ -1,6 +1,26 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import { openSqliteHandle } from "@morlay/rdbkit/testing";
3
+ import { openRdbBackend } from "./db/open.ts";
4
+ import { DEFAULT_BUSY_TIMEOUT_MS, type JournalMode } from "./version.ts";
5
+
1
6
  export type { ContractBackend } from "./testing/contract.ts";
2
7
  export { appendLog, meta, oneTurnLog, runPersistenceContract } from "./testing/contract.ts";
3
8
  export type { CoordinatorFixture } from "./testing/coordinator-contract.ts";
4
9
  export { runCoordinatorContract } from "./testing/coordinator-contract.ts";
5
10
 
6
- export { truncateLiveSession } from "./branch.ts";
11
+ export { truncateLiveSession } from "./branch/index.ts";
12
+
13
+ /**
14
+ * 测试辅助:先把库初始化(建表 / 迁移 / 身份),再给一个同步句柄——造损坏数据、读 `PRAGMA` 用。
15
+ *
16
+ * 同步口只在测试里存在:生产路径拿到的永远是 `Database`。
17
+ */
18
+ export async function openInitializedSqlite(
19
+ path: string,
20
+ journalMode: JournalMode = "wal",
21
+ busyTimeout = DEFAULT_BUSY_TIMEOUT_MS,
22
+ ): Promise<DatabaseSync> {
23
+ const backend = await openRdbBackend({ type: "sqlite", path, journalMode, busyTimeout });
24
+ await backend.close();
25
+ return openSqliteHandle(path, { journalMode, busyTimeout });
26
+ }
package/src/usage.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import { SESSION_USAGE_PATH } from "./routes.ts";
3
- import type { SessionPersistenceRdb } from "./index.ts";
3
+ import { mountJsonRoute } from "./http/route.ts";
4
+ import type { SessionPersistenceRdb } from "./session-store.ts";
4
5
 
5
6
  export { SESSION_USAGE_PATH };
6
7
 
@@ -179,69 +180,14 @@ export function addTotals(target: UsageTotals, row: UsageTotals): UsageTotals {
179
180
  // 用量统计通道:一次请求回报总览(含 subagent 拆分)、按天 × 模型的桶与按会话的行。
180
181
  // 聚合只算被会话引用的事件行——fork 共享行因此只计一次,已删会话留下的孤儿行不计。
181
182
  export function registerSessionUsage(ctx: Context, persistence: SessionPersistenceRdb): void {
182
- ctx.inject(["webServer", "connection"] as const, (webCtx) => {
183
- const webServer = webCtx.webServer as unknown as {
184
- register(route: {
185
- kind: "exact";
186
- path: string;
187
- handler: (
188
- req: import("node:http").IncomingMessage,
189
- res: import("node:http").ServerResponse,
190
- ) => void | Promise<void>;
191
- }): () => void;
192
- };
193
- const connection = webCtx.get("connection") as unknown as {
194
- requestRejection(request: {
195
- headers: import("node:http").IncomingHttpHeaders;
196
- }): number | undefined;
197
- };
198
- return webCtx.effect(
199
- () =>
200
- webServer.register({
201
- kind: "exact",
202
- path: SESSION_USAGE_PATH,
203
- handler: async (req, res) => {
204
- const rejection = connection.requestRejection(req);
205
- if (rejection !== undefined) {
206
- res.writeHead(rejection);
207
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
208
- return;
209
- }
210
- if (req.method !== "POST") {
211
- res.writeHead(405, { "content-type": "application/json" });
212
- res.end(JSON.stringify({ error: "method not allowed" }));
213
- return;
214
- }
215
- const chunks: Buffer[] = [];
216
- for await (const chunk of req) chunks.push(chunk as Buffer);
217
- const raw = Buffer.concat(chunks).toString("utf8");
218
- let envelope: { range?: unknown } = {};
219
- if (raw !== "") {
220
- try {
221
- envelope = JSON.parse(raw) as { range?: unknown };
222
- } catch {
223
- res.writeHead(400, { "content-type": "application/json" });
224
- res.end(JSON.stringify({ error: "request body is not JSON" }));
225
- return;
226
- }
227
- }
228
- try {
229
- const report = await persistence.usageReport(
230
- resolveUsageSince(parseUsageRange(envelope.range), Date.now()),
231
- );
232
- res.writeHead(200, { "content-type": "application/json" });
233
- res.end(JSON.stringify(report));
234
- } catch (error: unknown) {
235
- res.writeHead(500, { "content-type": "application/json" });
236
- res.end(
237
- JSON.stringify({
238
- error: error instanceof Error ? error.message : "usage report failed",
239
- }),
240
- );
241
- }
242
- },
243
- }),
244
- `session-rdb: ${SESSION_USAGE_PATH} route`,
245
- );
183
+ mountJsonRoute(ctx, {
184
+ path: SESSION_USAGE_PATH,
185
+ handle: async (body) => {
186
+ const { range } = body as { range?: unknown };
187
+ const report = await persistence.usageReport(
188
+ resolveUsageSince(parseUsageRange(range), Date.now()),
189
+ );
190
+ return { body: report };
191
+ },
246
192
  });
247
193
  }
package/src/version.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** 库结构版本(写入 SQLite 的 `user_version`,不匹配即拒绝打开)。 */
2
+ export const SCHEMA_VERSION = 3;
3
+
4
+ /** SQLite 文件头的 application id(`0x44534850` = "DSHP"),防止误开别的库。 */
5
+ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850;
6
+
7
+ /** 事件 `f_data` 的编码(目前只有 JSON)。 */
8
+ export const EVENT_ENCODING = "json";
9
+
10
+ export type JournalMode = "wal" | "delete" | "truncate" | "persist";
11
+
12
+ /** SQLite 的默认 busy timeout(毫秒)。 */
13
+ export const DEFAULT_BUSY_TIMEOUT_MS = 5000;