@morlay/session-rdb 0.0.19 → 0.0.21-alpha.0

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 (70) hide show
  1. package/README.md +59 -31
  2. package/dist/artifact.d.mts +25 -65
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{schema-DPcuEh_a.d.mts → backend-DpdtxYpz.d.mts} +360 -107
  5. package/dist/branch-5JzX9rUq.mjs +390 -0
  6. package/dist/deletion.d.mts +7 -0
  7. package/dist/deletion.mjs +2 -0
  8. package/dist/dist-vIVO6bA-.mjs +1524 -0
  9. package/dist/import.d.mts +4 -4
  10. package/dist/import.mjs +214 -1
  11. package/dist/index.d.mts +2 -3
  12. package/dist/index.mjs +4 -1630
  13. package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
  14. package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
  15. package/dist/src-CWTWV7vx.mjs +1904 -0
  16. package/dist/storage.d.mts +14 -5
  17. package/dist/storage.mjs +2 -2
  18. package/dist/testing.d.mts +2 -26
  19. package/dist/testing.mjs +10504 -9614
  20. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
  21. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
  22. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
  23. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
  24. package/package.json +29 -30
  25. package/src/adapters/to-postgres.ts +1 -3
  26. package/src/adapters/to-sqlite.ts +0 -2
  27. package/src/adapters/types.ts +0 -3
  28. package/src/artifact.ts +0 -2
  29. package/src/backend.ts +31 -2
  30. package/src/branch.ts +223 -135
  31. package/src/deletion.ts +87 -0
  32. package/src/drizzle/postgres-v2.ts +0 -1
  33. package/src/drizzle/postgres-v3.ts +0 -1
  34. package/src/drizzle/sqlite-v2.ts +0 -1
  35. package/src/drizzle/sqlite-v3.ts +0 -1
  36. package/src/entities/v2/session-events.ts +0 -1
  37. package/src/entities/v3/event-usage.ts +25 -0
  38. package/src/entities/v3/events.ts +1 -3
  39. package/src/entities/v3/index.ts +4 -0
  40. package/src/entities/v3/session-events.ts +1 -2
  41. package/src/entities/v3/session-projcache-rows.ts +0 -8
  42. package/src/entities/v3/sessions.ts +3 -3
  43. package/src/entities/v3/storage-units.ts +0 -1
  44. package/src/entities/v3/workspace-sessions.ts +0 -5
  45. package/src/entities/v3/workspace-state.ts +0 -6
  46. package/src/entities/v3/workspaces.ts +0 -5
  47. package/src/export.ts +103 -0
  48. package/src/gc.ts +76 -0
  49. package/src/import-storages.ts +4 -37
  50. package/src/import.ts +54 -31
  51. package/src/index.ts +150 -114
  52. package/src/legacy.ts +4 -42
  53. package/src/log.ts +63 -106
  54. package/src/postgres.ts +249 -22
  55. package/src/schema.ts +6 -6
  56. package/src/session-query.ts +0 -4
  57. package/src/sqlite.ts +231 -55
  58. package/src/storage-takeover/index.ts +2 -28
  59. package/src/storage-takeover/projection-cache.ts +30 -134
  60. package/src/storage-takeover/repository.ts +20 -59
  61. package/src/storage-takeover/storage-backend.ts +3 -33
  62. package/src/storage-takeover/types.ts +14 -56
  63. package/src/storage.ts +0 -2
  64. package/src/testing/contract.ts +19 -50
  65. package/src/testing/coordinator-contract.ts +10 -25
  66. package/src/testing.ts +2 -2
  67. package/src/usage.ts +191 -0
  68. package/dist/import-Bc2QQa5G.mjs +0 -473
  69. package/dist/index-CCcK9tia.d.mts +0 -270
  70. package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
package/src/sqlite.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readdirSync, statSync } from "node:fs";
3
- import { mkdir, open } from "node:fs/promises";
2
+ import { mkdir, open, readdir, stat } from "node:fs/promises";
4
3
  import { dirname, resolve } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { DatabaseSync } from "node:sqlite";
7
- import { and, desc, eq, gte, sql } from "drizzle-orm";
6
+ import { and, desc, eq, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
8
7
  import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
9
8
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
10
9
  import type { SessionId } from "@deepseek-ai/dsh-session";
@@ -16,11 +15,13 @@ import {
16
15
  type EventRow,
17
16
  type SessionRow,
18
17
  } from "./backend.ts";
19
- import { sessionConflictRow, sessionInsertRow, titleOfEventData } from "./log.ts";
18
+ import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
19
+ import type { UsageAggregate, UsageTotals } from "./usage.ts";
20
20
  import {
21
21
  DEFAULT_BUSY_TIMEOUT_MS,
22
22
  SCHEMA_VERSION,
23
23
  SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
24
+ tEventUsage,
24
25
  tEvents,
25
26
  tPersistenceState,
26
27
  tSessionEvents,
@@ -37,7 +38,62 @@ import type { StorageRepository } from "./storage-takeover/types.ts";
37
38
 
38
39
  type SqliteDb = NodeSQLiteDatabase & { $client: DatabaseSync };
39
40
 
40
- /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
41
+ /** 用量聚合的原始行(列名是 SQL 别名)。 */
42
+ interface RawUsageRow {
43
+ day: string;
44
+ provider: string | null;
45
+ model: string | null;
46
+ subagent: number;
47
+ events: number;
48
+ input_tokens: number | null;
49
+ output_tokens: number | null;
50
+ cache_read_tokens: number | null;
51
+ reasoning_tokens: number | null;
52
+ total_tokens: number | null;
53
+ }
54
+
55
+ /**
56
+ * 一次性回填:旧库的用量从未写过 `t_event_usage`,建表后按事件行补一次(幂等:表非空即跳过)。
57
+ */
58
+ function backfillEventUsage(db: DatabaseSync): void {
59
+ const pending = db.prepare("SELECT count(*) AS n FROM t_event_usage").get() as { n: number };
60
+ if (pending.n > 0) return;
61
+ db.exec(`INSERT OR IGNORE INTO t_event_usage (
62
+ f_event_id, f_created_at, f_provider, f_model,
63
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
64
+ SELECT e.f_event_id, e.f_created_at,
65
+ coalesce(json_extract(e.f_data, '$.data.message.source.provider'),
66
+ json_extract(e.f_data, '$.message.source.provider')),
67
+ coalesce(json_extract(e.f_data, '$.data.message.source.model'),
68
+ json_extract(e.f_data, '$.message.source.model')),
69
+ coalesce(json_extract(e.f_data, '$.data.usage.inputTokens'),
70
+ json_extract(e.f_data, '$.usage.inputTokens'), 0),
71
+ coalesce(json_extract(e.f_data, '$.data.usage.outputTokens'),
72
+ json_extract(e.f_data, '$.usage.outputTokens'), 0),
73
+ coalesce(json_extract(e.f_data, '$.data.usage.cacheReadTokens'),
74
+ json_extract(e.f_data, '$.usage.cacheReadTokens'), 0),
75
+ coalesce(json_extract(e.f_data, '$.data.usage.reasoningTokens'),
76
+ json_extract(e.f_data, '$.usage.reasoningTokens'), 0),
77
+ coalesce(json_extract(e.f_data, '$.data.usage.totalTokens'),
78
+ json_extract(e.f_data, '$.usage.totalTokens'), 0)
79
+ FROM t_events e
80
+ WHERE e.f_type = 'assistant/message'
81
+ AND (json_extract(e.f_data, '$.data.usage') IS NOT NULL
82
+ OR json_extract(e.f_data, '$.usage') IS NOT NULL)`);
83
+ }
84
+
85
+ /** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
86
+ function totalsOf(row: RawUsageRow): UsageTotals {
87
+ return {
88
+ events: row.events,
89
+ inputTokens: row.input_tokens ?? 0,
90
+ outputTokens: row.output_tokens ?? 0,
91
+ cacheReadTokens: row.cache_read_tokens ?? 0,
92
+ reasoningTokens: row.reasoning_tokens ?? 0,
93
+ totalTokens: row.total_tokens ?? 0,
94
+ };
95
+ }
96
+
41
97
  const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
42
98
 
43
99
  const sqliteTxQueues = new Map<string, Promise<void>>();
@@ -45,7 +101,7 @@ const sqliteTxQueues = new Map<string, Promise<void>>();
45
101
  function enqueueSqliteTx<T>(path: string, fn: () => Promise<T>): Promise<T> {
46
102
  const tail = sqliteTxQueues.get(path) ?? Promise.resolve();
47
103
  const run = tail.then(fn);
48
- // 失败的事务不得毒化后续队列。
104
+
49
105
  sqliteTxQueues.set(
50
106
  path,
51
107
  run.then(
@@ -65,11 +121,11 @@ async function createDatabaseFile(path: string): Promise<void> {
65
121
  }
66
122
  }
67
123
 
68
- export function openDatabase(
124
+ export async function openDatabase(
69
125
  path: string,
70
126
  journalMode: JournalMode,
71
127
  busyTimeout = DEFAULT_BUSY_TIMEOUT_MS,
72
- ): DatabaseSync {
128
+ ): Promise<DatabaseSync> {
73
129
  const db = new DatabaseSync(path);
74
130
  try {
75
131
  configureDatabase(db, path, journalMode, busyTimeout);
@@ -77,13 +133,13 @@ export function openDatabase(
77
133
  const { user_version: onDisk } = db.prepare("PRAGMA user_version").get() as {
78
134
  user_version: number;
79
135
  };
80
- // v2 库(旧版本建表)首次打开:建迁移表并标记 v2 baseline 已应用,
81
- // 使 migrate 只执行 v3 diff(删 f_original_seq、建 t_schema_meta)。
136
+
82
137
  if (onDisk === 2 && !hasMigrationsTable(db)) {
83
- baselineV2(db);
138
+ await baselineV2(db);
84
139
  }
85
140
  migrate(dbx, { migrationsFolder: sqliteMigrationsDir });
86
- // store 身份单例:新库由 migrate 建表后插入;v2/v3 库已有行(no-op)。
141
+ backfillEventUsage(db);
142
+
87
143
  dbx
88
144
  .insert(tPersistenceState)
89
145
  .values({ fSingleton: 1, fStoreId: randomUUID() })
@@ -92,8 +148,7 @@ export function openDatabase(
92
148
  if (onDisk === 0 || onDisk === 2) {
93
149
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
94
150
  }
95
- // journal_mode 不可绑定,经校验后的联合可直接插值;在归属校验、迁移
96
- // 与身份写入全部成功之后应用(失败时保持磁盘原状)。
151
+
97
152
  db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`);
98
153
  return db;
99
154
  } catch (error: unknown) {
@@ -110,9 +165,8 @@ function hasMigrationsTable(db: DatabaseSync): boolean {
110
165
  );
111
166
  }
112
167
 
113
- /** 标记 v2 baseline 已应用(迁移表 v1 结构:id/hash/created_at/name/applied_at)。 */
114
- function baselineV2(db: DatabaseSync): void {
115
- const dir = readdirSync(sqliteMigrationsDir).find((name) => name.endsWith("_v2_initial"));
168
+ async function baselineV2(db: DatabaseSync): Promise<void> {
169
+ const dir = (await readdir(sqliteMigrationsDir)).find((name) => name.endsWith("_v2_initial"));
116
170
  if (dir === undefined) throw new Error("missing v2 baseline migration in drizzle/sqlite");
117
171
  db.exec(`
118
172
  CREATE TABLE __drizzle_migrations (
@@ -134,12 +188,11 @@ function configureDatabase(
134
188
  journalMode: JournalMode,
135
189
  busyTimeout: number,
136
190
  ): void {
137
- // 其余是驱动级 SQLite 操作(无 drizzle API):连接 pragma 与 sqlite_schema 探测。
138
191
  db.exec("PRAGMA foreign_keys = ON");
139
- // busy_timeout 必须先于一切锁获取(初始化事务与每次写事务)。
192
+
140
193
  db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
141
194
  const dbx = drizzle({ client: db });
142
- // 初始化在单个 `BEGIN IMMEDIATE` 事务内完成,持写锁校验 schema 归属。
195
+
143
196
  dbx.transaction(
144
197
  (tx) => {
145
198
  const { user_version: onDisk } = tx.get(sql`PRAGMA user_version`) as {
@@ -185,7 +238,6 @@ export class SqliteBackend implements Backend {
185
238
  readonly kind = "sqlite" as const;
186
239
  storeIdentity!: string;
187
240
 
188
- /** storages 接管表访问层;句柄在 open() 完成后解析。 */
189
241
  readonly storage: StorageRepository;
190
242
 
191
243
  private dbPath = "";
@@ -199,13 +251,12 @@ export class SqliteBackend implements Backend {
199
251
  this.resolveDb = resolve;
200
252
  this.rejectDb = reject;
201
253
  });
202
- // 每个 storage 原语都会重新 await 同一 promise,失败仍逐一可见;这个
203
- // 守卫只防止 open 失败早于首次使用时的 unhandled rejection。
254
+
204
255
  this.dbReady.catch(() => {});
205
256
  this.storage = createStorageRepository({
206
257
  db: () => this.dbReady,
207
- // 同步驱动:读路径直读介质(open 之前的调用是装配错误,fail loud)。
208
- dbSync: () => {
258
+
259
+ dbDirect: () => {
209
260
  if (this.db === undefined) throw new Error("sqlite session database is not open");
210
261
  return this.db;
211
262
  },
@@ -220,9 +271,7 @@ export class SqliteBackend implements Backend {
220
271
  } catch (error: unknown) {
221
272
  try {
222
273
  db.$client.exec("ROLLBACK");
223
- } catch {
224
- // 原始 SQLite 失败仍是可操作的根因。
225
- }
274
+ } catch {}
226
275
  throw error;
227
276
  }
228
277
  }),
@@ -255,15 +304,12 @@ export class SqliteBackend implements Backend {
255
304
  await mkdir(dirname(actual), { recursive: true, mode: 0o700 });
256
305
  await createDatabaseFile(actual);
257
306
  }
258
- // openDatabase 内的初始化事务是同步 `BEGIN IMMEDIATE`,须排进同一
259
- // per-path 写队列——否则与进行中的写事务竞争会在持有锁的异步回调
260
- // 让步期间忙等,冻结事件循环(死锁直到 busy_timeout)。
307
+
261
308
  await enqueueSqliteTx(actual, async () => {
262
309
  this.db = drizzle({
263
- client: openDatabase(actual, this.options.journalMode, this.options.busyTimeout),
310
+ client: await openDatabase(actual, this.options.journalMode, this.options.busyTimeout),
264
311
  });
265
- // drizzle-kit 迁移:v2 baseline 由旧版本建表(首次打开时标记为已应用),
266
- // 本版本只执行 v3 diff(删 f_original_seq、建 t_schema_meta)。
312
+
267
313
  migrate(this.db, { migrationsFolder: sqliteMigrationsDir });
268
314
  });
269
315
  try {
@@ -272,7 +318,7 @@ export class SqliteBackend implements Backend {
272
318
  .from(tPersistenceState)
273
319
  .where(eq(tPersistenceState.fSingleton, 1))
274
320
  .get() as { fStoreId: string } | undefined;
275
- /* v8 ignore next -- openDatabase inserts the singleton before returning. */
321
+
276
322
  if (row === undefined) {
277
323
  throw new Error(`session database at "${actual}" has no store identity`);
278
324
  }
@@ -280,7 +326,7 @@ export class SqliteBackend implements Backend {
280
326
  throw new Error(`session database at "${actual}" has no valid store identity`);
281
327
  }
282
328
  if (actual !== ":memory:") {
283
- const identity = statSync(actual, { bigint: true });
329
+ const identity = await stat(actual, { bigint: true });
284
330
  this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.fStoreId}`;
285
331
  } else {
286
332
  this.storeIdentity = `memory:store:${row.fStoreId}`;
@@ -292,8 +338,6 @@ export class SqliteBackend implements Backend {
292
338
  }
293
339
 
294
340
  async close(): Promise<void> {
295
- // open 可能未及赋值 db 就失败(队列内初始化抛错);close 不得在
296
- // coordinator 的 dispose 之上再崩。
297
341
  if (this.db === undefined) return;
298
342
  this.db.$client.close();
299
343
  }
@@ -314,17 +358,30 @@ export class SqliteBackend implements Backend {
314
358
  return scoped.orderBy(tSessionEvents.fSequence).all() as unknown as EventRow[];
315
359
  }
316
360
 
361
+ async getEventTypeAt(id: SessionId, sequence: number): Promise<string | undefined> {
362
+ const row = this.eventRows()
363
+ .where(and(eq(tSessionEvents.fSessionId, id), eq(tSessionEvents.fSequence, sequence)))
364
+ .get() as { fType?: string } | undefined;
365
+ return row?.fType;
366
+ }
367
+
368
+ async getEventTypesBefore(
369
+ id: SessionId,
370
+ beforeSequence: number,
371
+ limit: number,
372
+ ): Promise<Array<Pick<EventRow, "fSequence" | "fType">>> {
373
+ return this.eventRows()
374
+ .where(and(eq(tSessionEvents.fSessionId, id), lt(tSessionEvents.fSequence, beforeSequence)))
375
+ .orderBy(desc(tSessionEvents.fSequence))
376
+ .limit(limit)
377
+ .all() as unknown as Array<Pick<EventRow, "fSequence" | "fType">>;
378
+ }
379
+
317
380
  async listSessions(): Promise<SessionRow[]> {
318
381
  return this.db.select().from(tSessions).all() as SessionRow[];
319
382
  }
320
383
 
321
384
  async transaction<T>(fn: (tx: BackendTx) => Promise<T>): Promise<T> {
322
- // drizzle 的 SQLite 驱动只支持同步事务回调,而共享 BackendTx 接口因
323
- // PostgreSQL 是异步的——BEGIN/COMMIT/ROLLBACK 语句因此走驱动层。
324
- // 异步回调在持写锁期间让出微任务间隙:本进程内第二个连接此时同步
325
- // `BEGIN IMMEDIATE` 会忙等并冻结事件循环(锁持有者无法提交,死锁直到
326
- // busy_timeout)。per-path 写队列串行化消除该间隙——SQLite 本就单写者;
327
- // 跨进程竞争仍经 busy_timeout 解决,不同数据库文件互不串行。
328
385
  return enqueueSqliteTx(this.dbPath, async () => {
329
386
  this.db.$client.exec("BEGIN IMMEDIATE");
330
387
  try {
@@ -332,15 +389,10 @@ export class SqliteBackend implements Backend {
332
389
  this.db.$client.exec("COMMIT");
333
390
  return result;
334
391
  } catch (error: unknown) {
335
- // DELETE+INSERT 不会冲突;这里回滚 DB 级失败(磁盘满等),测试不可达。
336
- /* v8 ignore start */
337
392
  try {
338
393
  this.db.$client.exec("ROLLBACK");
339
- } catch {
340
- // 原始 SQLite 失败仍是可操作的根因。
341
- }
394
+ } catch {}
342
395
  throw error;
343
- /* v8 ignore stop */
344
396
  }
345
397
  });
346
398
  }
@@ -357,10 +409,10 @@ export class SqliteBackend implements Backend {
357
409
  refreshTitle: (id) => this.refreshTitle(id),
358
410
  deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(id, fromSequence),
359
411
  getPrevBridge: (id, sequence) => this.getPrevBridge(id, sequence),
412
+ deleteSession: (id) => this.deleteSession(id),
413
+ deleteSessions: (ids) => this.deleteSessions(ids),
360
414
  };
361
415
 
362
- // --- row primitives (transaction-internal or standalone) ---
363
-
364
416
  private async upsertSession(storage: SessionStorageMetadata, incarnation: string): Promise<void> {
365
417
  this.db
366
418
  .insert(tSessions)
@@ -380,7 +432,7 @@ export class SqliteBackend implements Backend {
380
432
  .from(tSessions)
381
433
  .where(eq(tSessions.fSessionId, id))
382
434
  .get() as Pick<SessionRow, "fHeadEventId" | "fHeadSequence"> | undefined;
383
- /* v8 ignore next -- appendBatch/commitRepair always materialize the row before reading the head */
435
+
384
436
  if (head === undefined) throw new Error(`session "${id}" has no materialized row`);
385
437
  return head;
386
438
  }
@@ -391,7 +443,7 @@ export class SqliteBackend implements Backend {
391
443
  .from(tSessions)
392
444
  .where(eq(tSessions.fSessionId, id))
393
445
  .get() as { fSeedLength: number | null } | undefined;
394
- /* v8 ignore next -- rewind always materializes the row before reading the seed length */
446
+
395
447
  if (row === undefined) throw new Error(`session "${id}" has no materialized row`);
396
448
  return row.fSeedLength;
397
449
  }
@@ -414,6 +466,14 @@ export class SqliteBackend implements Backend {
414
466
  .values(events.slice(i, i + SqliteBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event })))
415
467
  .run();
416
468
  }
469
+ // 用量顺带落 t_event_usage:统计不再逐行解析事件 JSON(重复事件行忽略,fork 复用不重复记)。
470
+ const usage = events.flatMap((event) => {
471
+ const row = usageRowOf(event);
472
+ return row === undefined ? [] : [row];
473
+ });
474
+ if (usage.length > 0) {
475
+ this.db.insert(tEventUsage).values(usage).onConflictDoNothing().run();
476
+ }
417
477
  }
418
478
 
419
479
  private async insertBridges(
@@ -453,7 +513,6 @@ export class SqliteBackend implements Backend {
453
513
  .run();
454
514
  }
455
515
 
456
- /** 从事件表重算标题(最后一条 `session/title`),写回会话行的 f_title 列。 */
457
516
  private async refreshTitle(id: SessionId): Promise<void> {
458
517
  const row = this.db
459
518
  .select({ fSequence: tSessionEvents.fSequence, fData: tEvents.fData })
@@ -489,6 +548,123 @@ export class SqliteBackend implements Backend {
489
548
  .get() as { fEventId: string; fSequence: number } | undefined;
490
549
  }
491
550
 
551
+ private async deleteSession(id: SessionId): Promise<void> {
552
+ this.db.delete(tSessionEvents).where(eq(tSessionEvents.fSessionId, id)).run();
553
+ this.db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fSessionId, id)).run();
554
+ this.db.delete(tSessionProjcacheRows).where(eq(tSessionProjcacheRows.fSessionId, id)).run();
555
+ this.db.delete(tSessions).where(eq(tSessions.fSessionId, id)).run();
556
+ }
557
+
558
+ /** 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。 */
559
+ async collectOrphans(): Promise<number> {
560
+ const referenced = this.db.select({ fEventId: tSessionEvents.fEventId }).from(tSessionEvents);
561
+ const info = this.db.delete(tEvents).where(notInArray(tEvents.fEventId, referenced)).run();
562
+ // 用量行跟着事件行走:没有事件行的用量不再计入统计。
563
+ this.db
564
+ .delete(tEventUsage)
565
+ .where(
566
+ notInArray(
567
+ tEventUsage.fEventId,
568
+ this.db.select({ fEventId: tEvents.fEventId }).from(tEvents),
569
+ ),
570
+ )
571
+ .run();
572
+ return Number(info.changes);
573
+ }
574
+
575
+ /** 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。 */
576
+ async listOrphanSubagentSessions(): Promise<SessionId[]> {
577
+ const rows = this.db.$client
578
+ .prepare(
579
+ `SELECT f_session_id AS id FROM t_sessions
580
+ WHERE f_origin = 'subagent'
581
+ AND (f_parent_session IS NULL
582
+ OR f_parent_session NOT IN (SELECT f_session_id FROM t_sessions))`,
583
+ )
584
+ .all() as Array<{ id: string }>;
585
+ return rows.map((row) => row.id as SessionId);
586
+ }
587
+
588
+ private async deleteSessions(ids: SessionId[]): Promise<number> {
589
+ if (ids.length === 0) return 0;
590
+ this.db.delete(tSessionEvents).where(inArray(tSessionEvents.fSessionId, ids)).run();
591
+ this.db.delete(tWorkspaceSessions).where(inArray(tWorkspaceSessions.fSessionId, ids)).run();
592
+ this.db
593
+ .delete(tSessionProjcacheRows)
594
+ .where(inArray(tSessionProjcacheRows.fSessionId, ids))
595
+ .run();
596
+ const info = this.db.delete(tSessions).where(inArray(tSessions.fSessionId, ids)).run();
597
+ return Number(info.changes);
598
+ }
599
+
600
+ /** 用量聚合:只读 `t_event_usage`;可选只算 `sinceMs` 之后的事件行。 */
601
+ async usageReport(sinceMs?: number): Promise<UsageAggregate> {
602
+ const sinceClause = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
603
+ const sinceParams = sinceMs === undefined ? [] : [sinceMs];
604
+ const buckets = this.db.$client
605
+ .prepare(
606
+ `SELECT date(u.f_created_at / 1000, 'unixepoch', 'localtime') AS day,
607
+ u.f_provider AS provider,
608
+ u.f_model AS model,
609
+ EXISTS (SELECT 1 FROM t_session_events sb
610
+ JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
611
+ WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent') AS subagent,
612
+ count(*) AS events,
613
+ sum(u.f_input_tokens) AS input_tokens,
614
+ sum(u.f_output_tokens) AS output_tokens,
615
+ sum(u.f_cache_read_tokens) AS cache_read_tokens,
616
+ sum(u.f_reasoning_tokens) AS reasoning_tokens,
617
+ sum(u.f_total_tokens) AS total_tokens
618
+ FROM t_event_usage u
619
+ WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${sinceClause}
620
+ GROUP BY day, provider, model, subagent`,
621
+ )
622
+ .all(...sinceParams) as unknown as RawUsageRow[];
623
+ const sessions = this.db.$client
624
+ .prepare(
625
+ `SELECT b.f_session_id AS session_id,
626
+ s.f_title AS title,
627
+ (s.f_origin = 'subagent') AS subagent,
628
+ (s.f_archived_at IS NOT NULL) AS archived,
629
+ count(*) AS events,
630
+ sum(u.f_input_tokens) AS input_tokens,
631
+ sum(u.f_output_tokens) AS output_tokens,
632
+ sum(u.f_cache_read_tokens) AS cache_read_tokens,
633
+ sum(u.f_reasoning_tokens) AS reasoning_tokens,
634
+ sum(u.f_total_tokens) AS total_tokens
635
+ FROM t_session_events b
636
+ JOIN t_event_usage u ON u.f_event_id = b.f_event_id
637
+ JOIN t_sessions s ON s.f_session_id = b.f_session_id
638
+ WHERE 1 = 1${sinceClause}
639
+ GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
640
+ )
641
+ .all(...sinceParams) as unknown as Array<
642
+ RawUsageRow & { session_id: string; title: string | null; archived: number }
643
+ >;
644
+ return {
645
+ buckets: buckets.map((row) => ({
646
+ day: row.day,
647
+ provider: row.provider,
648
+ model: row.model,
649
+ subagent: row.subagent === 1,
650
+ ...totalsOf(row),
651
+ })),
652
+ sessions: sessions.map((row) => ({
653
+ sessionId: row.session_id,
654
+ title: row.title,
655
+ subagent: row.subagent === 1,
656
+ archived: row.archived === 1,
657
+ ...totalsOf(row),
658
+ })),
659
+ };
660
+ }
661
+
662
+ async vacuum(): Promise<void> {
663
+ await enqueueSqliteTx(this.dbPath, async () => {
664
+ this.db.$client.exec("VACUUM");
665
+ });
666
+ }
667
+
492
668
  private eventRows() {
493
669
  return this.db
494
670
  .select({
@@ -1,16 +1,3 @@
1
- /**
2
- * storages 接管的装配入口:在 session-rdb 插件内注册
3
- *
4
- * 1. storage hub 的 `rdb` KV 后端——上游 `storage-domain` 的 `StorageBackend.kv`
5
- * 契约实现(名字来自上游),把 workspace 域映射到 rdb 语义专用表;
6
- * 2. `ctx.sessionProjectionCache` 服务——替换上游 `session-projection-cache`
7
- * 插件,直接读写 rdb 的投影 checkpoint 表。
8
- *
9
- * 两者都经 `ctx.inject` 挂在可选依赖上:装配里缺 `storage` 或
10
- * `sessionProjections`(纯 cordis 单测)时静默跳过,消费者按上游的缺服务
11
- * 路径降级。
12
- */
13
-
14
1
  import type { Context } from "@deepseek-ai/cordis";
15
2
  import { storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
16
3
  import type { ProjectionCacheConfig } from "./projection-cache.ts";
@@ -18,24 +5,15 @@ import { SessionProjectionCacheRdb } from "./projection-cache.ts";
18
5
  import { RDB_STORAGE_BACKEND, RdbStorageBackend } from "./storage-backend.ts";
19
6
  import type { StorageRepository } from "./types.ts";
20
7
 
21
- /** 装配参数。 */
22
8
  export interface StorageTakeoverOptions {
23
- /** storages 接管表访问层(与事件日志同介质)。 */
24
9
  repository: StorageRepository;
25
- /** 投影缓存的写节流参数(上游 base 装配的部署值)。 */
10
+
26
11
  projectionCache: ProjectionCacheConfig;
27
- /** 介质就绪信号:投影缓存直读介质前必须等到。 */
12
+
28
13
  ready: Promise<unknown>;
29
14
  }
30
15
 
31
- /**
32
- * Install the storages takeover on the session-rdb plugin context.
33
- * @param ctx - session-rdb plugin context.
34
- * @param options - repository and projection-cache throttle parameters.
35
- */
36
16
  export function installStorageTakeover(ctx: Context, options: StorageTakeoverOptions): void {
37
- // workspace 域:上游 workspaceRegistry 仍持有服务与内存态,这里只提供它
38
- // 依赖的 storage-domain 介质(backend 名 `rdb`,由 profile patch 路由)。
39
17
  ctx.inject(["storage"], (storageCtx) => {
40
18
  const backend = new RdbStorageBackend(options.repository);
41
19
  storageCtx.effect(() => {
@@ -48,10 +26,6 @@ export function installStorageTakeover(ctx: Context, options: StorageTakeoverOpt
48
26
  storageCtx.provide(storageBackendServiceKey(RDB_STORAGE_BACKEND), backend);
49
27
  });
50
28
 
51
- // 投影 checkpoint:上游插件被禁用后由本服务提供同名 API。按 cordis
52
- // class plugin 装载(而不是直接 new):`static inject` 负责等依赖就绪,
53
- // 框架负责调用 `[Service.init]` 安装写入路径——直接 new 会静默跳过 init,
54
- // 导致 session/created、turn/end、计数与定时器的 checkpoint 写入全部失效。
55
29
  ctx.plugin(SessionProjectionCacheRdb, {
56
30
  ...options.projectionCache,
57
31
  repository: options.repository,