@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.
- package/README.md +59 -31
- package/dist/artifact.d.mts +25 -65
- package/dist/artifact.mjs +3 -3
- package/dist/{schema-DPcuEh_a.d.mts → backend-DpdtxYpz.d.mts} +360 -107
- package/dist/branch-5JzX9rUq.mjs +390 -0
- package/dist/deletion.d.mts +7 -0
- package/dist/deletion.mjs +2 -0
- package/dist/dist-vIVO6bA-.mjs +1524 -0
- package/dist/import.d.mts +4 -4
- package/dist/import.mjs +214 -1
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1630
- package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
- package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
- package/dist/src-CWTWV7vx.mjs +1904 -0
- package/dist/storage.d.mts +14 -5
- package/dist/storage.mjs +2 -2
- package/dist/testing.d.mts +2 -26
- package/dist/testing.mjs +10504 -9614
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
- package/package.json +29 -30
- package/src/adapters/to-postgres.ts +1 -3
- package/src/adapters/to-sqlite.ts +0 -2
- package/src/adapters/types.ts +0 -3
- package/src/artifact.ts +0 -2
- package/src/backend.ts +31 -2
- package/src/branch.ts +223 -135
- package/src/deletion.ts +87 -0
- package/src/drizzle/postgres-v2.ts +0 -1
- package/src/drizzle/postgres-v3.ts +0 -1
- package/src/drizzle/sqlite-v2.ts +0 -1
- package/src/drizzle/sqlite-v3.ts +0 -1
- package/src/entities/v2/session-events.ts +0 -1
- package/src/entities/v3/event-usage.ts +25 -0
- package/src/entities/v3/events.ts +1 -3
- package/src/entities/v3/index.ts +4 -0
- package/src/entities/v3/session-events.ts +1 -2
- package/src/entities/v3/session-projcache-rows.ts +0 -8
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-units.ts +0 -1
- package/src/entities/v3/workspace-sessions.ts +0 -5
- package/src/entities/v3/workspace-state.ts +0 -6
- package/src/entities/v3/workspaces.ts +0 -5
- package/src/export.ts +103 -0
- package/src/gc.ts +76 -0
- package/src/import-storages.ts +4 -37
- package/src/import.ts +54 -31
- package/src/index.ts +150 -114
- package/src/legacy.ts +4 -42
- package/src/log.ts +63 -106
- package/src/postgres.ts +249 -22
- package/src/schema.ts +6 -6
- package/src/session-query.ts +0 -4
- package/src/sqlite.ts +231 -55
- package/src/storage-takeover/index.ts +2 -28
- package/src/storage-takeover/projection-cache.ts +30 -134
- package/src/storage-takeover/repository.ts +20 -59
- package/src/storage-takeover/storage-backend.ts +3 -33
- package/src/storage-takeover/types.ts +14 -56
- package/src/storage.ts +0 -2
- package/src/testing/contract.ts +19 -50
- package/src/testing/coordinator-contract.ts +10 -25
- package/src/testing.ts +2 -2
- package/src/usage.ts +191 -0
- package/dist/import-Bc2QQa5G.mjs +0 -473
- package/dist/index-CCcK9tia.d.mts +0 -270
- 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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
192
|
+
|
|
140
193
|
db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
|
|
141
194
|
const dbx = drizzle({ client: db });
|
|
142
|
-
|
|
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
|
-
|
|
203
|
-
// 守卫只防止 open 失败早于首次使用时的 unhandled rejection。
|
|
254
|
+
|
|
204
255
|
this.dbReady.catch(() => {});
|
|
205
256
|
this.storage = createStorageRepository({
|
|
206
257
|
db: () => this.dbReady,
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|