@morlay/session-rdb 0.0.20 → 0.0.21-alpha.1
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 +2 -2
- package/dist/{schema-BkQN5GyT.d.mts → backend-DpdtxYpz.d.mts} +346 -93
- package/dist/{branch-Co6xlbi0.mjs → branch-5JzX9rUq.mjs} +163 -52
- 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 +28 -11
- package/dist/index.d.mts +2 -3
- package/dist/index.mjs +4 -1631
- 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 +1 -26
- package/dist/testing.mjs +10503 -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 +28 -29
- 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 +1 -4
- package/src/usage.ts +191 -0
- package/dist/index-D55G9n4k.d.mts +0 -271
- package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
package/src/postgres.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { and, desc, eq, gte, sql } from "drizzle-orm";
|
|
4
|
+
import { and, desc, eq, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
|
|
5
5
|
import type { PgAsyncDatabase, PgAsyncTransaction } from "drizzle-orm/pg-core";
|
|
6
6
|
import type { NodePgDatabase, NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
|
|
7
7
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
@@ -16,13 +16,36 @@ import {
|
|
|
16
16
|
} from "./backend.ts";
|
|
17
17
|
import { toPostgresSchema } from "./adapters/index.ts";
|
|
18
18
|
import { postgresTableDefs } from "./entities/index.ts";
|
|
19
|
-
import { sessionConflictRow, sessionInsertRow, titleOfEventData } from "./log.ts";
|
|
19
|
+
import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
|
|
20
20
|
import { createStorageRepository } from "./storage-takeover/repository.ts";
|
|
21
21
|
import type { StorageRepository } from "./storage-takeover/types.ts";
|
|
22
|
+
import type { UsageAggregate, UsageTotals } from "./usage.ts";
|
|
22
23
|
|
|
23
|
-
/** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
|
|
24
24
|
const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
|
|
25
25
|
|
|
26
|
+
/** pg 的 text 列窄化:非字符串(含 null)都不当作文本。 */
|
|
27
|
+
function text(value: unknown): string | null {
|
|
28
|
+
return typeof value === "string" ? value : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** pg 的 count/sum 回落成字符串:统一转成有限数。 */
|
|
32
|
+
function numeric(value: unknown): number {
|
|
33
|
+
const parsed = typeof value === "number" ? value : Number(value ?? 0);
|
|
34
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
|
|
38
|
+
function totalsOfRow(row: Record<string, unknown>): UsageTotals {
|
|
39
|
+
return {
|
|
40
|
+
events: numeric(row["events"]),
|
|
41
|
+
inputTokens: numeric(row["input_tokens"]),
|
|
42
|
+
outputTokens: numeric(row["output_tokens"]),
|
|
43
|
+
cacheReadTokens: numeric(row["cache_read_tokens"]),
|
|
44
|
+
reasoningTokens: numeric(row["reasoning_tokens"]),
|
|
45
|
+
totalTokens: numeric(row["total_tokens"]),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
export interface PostgresBackendOptions {
|
|
27
50
|
identityBase: string;
|
|
28
51
|
|
|
@@ -37,13 +60,12 @@ export class PostgresBackend implements Backend {
|
|
|
37
60
|
|
|
38
61
|
private readonly tables: Record<string, any>;
|
|
39
62
|
|
|
40
|
-
/** storages 接管表访问层;句柄在 open()(含迁移)完成后解析。 */
|
|
41
63
|
readonly storage: StorageRepository;
|
|
42
64
|
|
|
43
65
|
private readonly opened: Promise<void>;
|
|
44
66
|
private resolveOpened!: () => void;
|
|
45
67
|
private rejectOpened!: (error: unknown) => void;
|
|
46
|
-
|
|
68
|
+
|
|
47
69
|
private txOverride: unknown;
|
|
48
70
|
|
|
49
71
|
constructor(
|
|
@@ -55,14 +77,11 @@ export class PostgresBackend implements Backend {
|
|
|
55
77
|
this.resolveOpened = resolve;
|
|
56
78
|
this.rejectOpened = reject;
|
|
57
79
|
});
|
|
58
|
-
|
|
59
|
-
// 守卫只防止 open 失败早于首次使用时的 unhandled rejection。
|
|
80
|
+
|
|
60
81
|
this.opened.catch(() => {});
|
|
61
82
|
this.storage = createStorageRepository({
|
|
62
83
|
db: () => this.opened.then(() => this.txOverride ?? this.db),
|
|
63
|
-
|
|
64
|
-
// 同一记录在 read committed 下是「最后提交者赢」的静默覆盖;提升隔离级别
|
|
65
|
-
// 后冲突以序列化失败暴露(与「并发写入 fail loud」一致),调用方重试即可。
|
|
84
|
+
|
|
66
85
|
writeAtomically: (fn) =>
|
|
67
86
|
this.db.transaction(
|
|
68
87
|
async (tx) => {
|
|
@@ -92,8 +111,7 @@ export class PostgresBackend implements Backend {
|
|
|
92
111
|
|
|
93
112
|
private async doOpen(): Promise<void> {
|
|
94
113
|
const schema = this.options.schema ?? "public";
|
|
95
|
-
|
|
96
|
-
// 本版本只执行 v3 diff(删 f_original_seq)。
|
|
114
|
+
|
|
97
115
|
const qualifiedMeta = schema === "public" ? "t_schema_meta" : `"${schema}".t_schema_meta`;
|
|
98
116
|
const probe = (await this.db.execute(
|
|
99
117
|
sql`SELECT to_regclass(${qualifiedMeta}) IS NOT NULL AS exists`,
|
|
@@ -106,6 +124,7 @@ export class PostgresBackend implements Backend {
|
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
|
|
127
|
+
await this.backfillEventUsage();
|
|
109
128
|
const storeId = await this.db.transaction(async (tx) => {
|
|
110
129
|
await tx
|
|
111
130
|
.insert(this.tables["t_persistence_state"])
|
|
@@ -126,9 +145,8 @@ export class PostgresBackend implements Backend {
|
|
|
126
145
|
this.storeIdentity = `${this.options.identityBase}:store:${storeId}`;
|
|
127
146
|
}
|
|
128
147
|
|
|
129
|
-
/** 标记 v2 baseline 已应用(迁移表 v1 结构:id/hash/created_at/name/applied_at)。 */
|
|
130
148
|
private async baselineV2(): Promise<void> {
|
|
131
|
-
const dir =
|
|
149
|
+
const dir = (await readdir(postgresMigrationsDir)).find((name) => name.endsWith("_v2_initial"));
|
|
132
150
|
if (dir === undefined) throw new Error("missing v2 baseline migration in drizzle/postgres");
|
|
133
151
|
await this.db.execute(sql`
|
|
134
152
|
CREATE SCHEMA IF NOT EXISTS drizzle
|
|
@@ -176,6 +194,28 @@ export class PostgresBackend implements Backend {
|
|
|
176
194
|
.execute() as unknown as EventRow[];
|
|
177
195
|
}
|
|
178
196
|
|
|
197
|
+
async getEventTypeAt(id: SessionId, sequence: number): Promise<string | undefined> {
|
|
198
|
+
const table = this.tables["t_session_events"];
|
|
199
|
+
const rows = (await this.eventRows(this.db)
|
|
200
|
+
.where(and(eq(table.fSessionId, id), eq(table.fSequence, sequence)))
|
|
201
|
+
.limit(1)
|
|
202
|
+
.execute()) as Array<{ fType?: string }>;
|
|
203
|
+
return rows[0]?.fType;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async getEventTypesBefore(
|
|
207
|
+
id: SessionId,
|
|
208
|
+
beforeSequence: number,
|
|
209
|
+
limit: number,
|
|
210
|
+
): Promise<Array<Pick<EventRow, "fSequence" | "fType">>> {
|
|
211
|
+
const table = this.tables["t_session_events"];
|
|
212
|
+
return (await this.eventRows(this.db)
|
|
213
|
+
.where(and(eq(table.fSessionId, id), lt(table.fSequence, beforeSequence)))
|
|
214
|
+
.orderBy(desc(table.fSequence))
|
|
215
|
+
.limit(limit)
|
|
216
|
+
.execute()) as unknown as Array<Pick<EventRow, "fSequence" | "fType">>;
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
async listSessions(): Promise<SessionRow[]> {
|
|
180
220
|
return this.db.select().from(this.tables["t_sessions"]).execute() as unknown as Promise<
|
|
181
221
|
SessionRow[]
|
|
@@ -200,11 +240,11 @@ export class PostgresBackend implements Backend {
|
|
|
200
240
|
refreshTitle: (id) => this.refreshTitle(tx, id),
|
|
201
241
|
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
|
|
202
242
|
getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence),
|
|
243
|
+
deleteSession: (id) => this.deleteSession(tx, id),
|
|
244
|
+
deleteSessions: (ids) => this.deleteSessions(tx, ids),
|
|
203
245
|
};
|
|
204
246
|
}
|
|
205
247
|
|
|
206
|
-
// --- meta helpers ---
|
|
207
|
-
|
|
208
248
|
private async readMeta(
|
|
209
249
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
210
250
|
key: string,
|
|
@@ -217,8 +257,6 @@ export class PostgresBackend implements Backend {
|
|
|
217
257
|
return rows[0]?.fValue;
|
|
218
258
|
}
|
|
219
259
|
|
|
220
|
-
// --- row primitives (transaction-internal) ---
|
|
221
|
-
|
|
222
260
|
private async upsertSession(
|
|
223
261
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
224
262
|
storage: SessionStorageMetadata,
|
|
@@ -248,7 +286,7 @@ export class PostgresBackend implements Backend {
|
|
|
248
286
|
.where(eq(this.tables["t_sessions"].fSessionId, id))
|
|
249
287
|
.execute()
|
|
250
288
|
)[0] as Pick<SessionRow, "fHeadEventId" | "fHeadSequence"> | undefined;
|
|
251
|
-
|
|
289
|
+
|
|
252
290
|
if (head === undefined) throw new Error(`session "${id}" has no materialized row`);
|
|
253
291
|
return head;
|
|
254
292
|
}
|
|
@@ -264,7 +302,7 @@ export class PostgresBackend implements Backend {
|
|
|
264
302
|
.where(eq(this.tables["t_sessions"].fSessionId, id))
|
|
265
303
|
.execute()
|
|
266
304
|
)[0] as { fSeedLength: number | null } | undefined;
|
|
267
|
-
|
|
305
|
+
|
|
268
306
|
if (row === undefined) throw new Error(`session "${id}" has no materialized row`);
|
|
269
307
|
return row.fSeedLength;
|
|
270
308
|
}
|
|
@@ -296,6 +334,14 @@ export class PostgresBackend implements Backend {
|
|
|
296
334
|
)
|
|
297
335
|
.execute();
|
|
298
336
|
}
|
|
337
|
+
// 用量顺带落 t_event_usage:统计不再逐行解析事件 JSON。
|
|
338
|
+
const usage = events.flatMap((event) => {
|
|
339
|
+
const row = usageRowOf(event);
|
|
340
|
+
return row === undefined ? [] : [row];
|
|
341
|
+
});
|
|
342
|
+
if (usage.length > 0) {
|
|
343
|
+
await exec.insert(this.tables["t_event_usage"]).values(usage).onConflictDoNothing().execute();
|
|
344
|
+
}
|
|
299
345
|
}
|
|
300
346
|
|
|
301
347
|
private async insertBridges(
|
|
@@ -340,7 +386,6 @@ export class PostgresBackend implements Backend {
|
|
|
340
386
|
.execute();
|
|
341
387
|
}
|
|
342
388
|
|
|
343
|
-
/** 从事件表重算标题(最后一条 `session/title`),写回会话行的 f_title 列。 */
|
|
344
389
|
private async refreshTitle(
|
|
345
390
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
346
391
|
id: SessionId,
|
|
@@ -381,6 +426,188 @@ export class PostgresBackend implements Backend {
|
|
|
381
426
|
.execute();
|
|
382
427
|
}
|
|
383
428
|
|
|
429
|
+
private async deleteSession(
|
|
430
|
+
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
431
|
+
id: SessionId,
|
|
432
|
+
): Promise<void> {
|
|
433
|
+
await exec
|
|
434
|
+
.delete(this.tables["t_session_events"])
|
|
435
|
+
.where(eq(this.tables["t_session_events"].fSessionId, id))
|
|
436
|
+
.execute();
|
|
437
|
+
await exec
|
|
438
|
+
.delete(this.tables["t_workspace_sessions"])
|
|
439
|
+
.where(eq(this.tables["t_workspace_sessions"].fSessionId, id))
|
|
440
|
+
.execute();
|
|
441
|
+
await exec
|
|
442
|
+
.delete(this.tables["t_session_projcache_row"])
|
|
443
|
+
.where(eq(this.tables["t_session_projcache_row"].fSessionId, id))
|
|
444
|
+
.execute();
|
|
445
|
+
await exec
|
|
446
|
+
.delete(this.tables["t_sessions"])
|
|
447
|
+
.where(eq(this.tables["t_sessions"].fSessionId, id))
|
|
448
|
+
.execute();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。 */
|
|
452
|
+
async collectOrphans(): Promise<number> {
|
|
453
|
+
const tEvents = this.tables["t_events"];
|
|
454
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
455
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
456
|
+
const result = (await this.db
|
|
457
|
+
.delete(tEvents)
|
|
458
|
+
.where(
|
|
459
|
+
notInArray(
|
|
460
|
+
tEvents.fEventId,
|
|
461
|
+
this.db.select({ fEventId: tSessionEvents.fEventId }).from(tSessionEvents),
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
.execute()) as unknown as { rowCount?: number | null };
|
|
465
|
+
// 用量行跟着事件行走:没有事件行的用量不再计入统计。
|
|
466
|
+
await this.db
|
|
467
|
+
.delete(tEventUsage)
|
|
468
|
+
.where(
|
|
469
|
+
notInArray(
|
|
470
|
+
tEventUsage.fEventId,
|
|
471
|
+
this.db.select({ fEventId: tEvents.fEventId }).from(tEvents),
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
.execute();
|
|
475
|
+
return result.rowCount ?? 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。 */
|
|
479
|
+
async listOrphanSubagentSessions(): Promise<SessionId[]> {
|
|
480
|
+
const tSessions = this.tables["t_sessions"];
|
|
481
|
+
const result = (await this.db.execute(sql`
|
|
482
|
+
SELECT s.f_session_id AS id FROM ${tSessions} AS s
|
|
483
|
+
WHERE s.f_origin = 'subagent'
|
|
484
|
+
AND (s.f_parent_session IS NULL
|
|
485
|
+
OR s.f_parent_session NOT IN (SELECT p.f_session_id FROM ${tSessions} AS p))
|
|
486
|
+
`)) as unknown as { rows: Array<{ id: string }> };
|
|
487
|
+
return result.rows.map((row) => row.id as SessionId);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async deleteSessions(
|
|
491
|
+
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
492
|
+
ids: SessionId[],
|
|
493
|
+
): Promise<number> {
|
|
494
|
+
if (ids.length === 0) return 0;
|
|
495
|
+
await exec
|
|
496
|
+
.delete(this.tables["t_session_events"])
|
|
497
|
+
.where(inArray(this.tables["t_session_events"].fSessionId, ids))
|
|
498
|
+
.execute();
|
|
499
|
+
await exec
|
|
500
|
+
.delete(this.tables["t_workspace_sessions"])
|
|
501
|
+
.where(inArray(this.tables["t_workspace_sessions"].fSessionId, ids))
|
|
502
|
+
.execute();
|
|
503
|
+
await exec
|
|
504
|
+
.delete(this.tables["t_session_projcache_row"])
|
|
505
|
+
.where(inArray(this.tables["t_session_projcache_row"].fSessionId, ids))
|
|
506
|
+
.execute();
|
|
507
|
+
const result = (await exec
|
|
508
|
+
.delete(this.tables["t_sessions"])
|
|
509
|
+
.where(inArray(this.tables["t_sessions"].fSessionId, ids))
|
|
510
|
+
.execute()) as unknown as { rowCount?: number | null };
|
|
511
|
+
return result.rowCount ?? 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async vacuum(): Promise<void> {
|
|
515
|
+
await this.db.execute(sql`VACUUM ANALYZE`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 一次性回填:旧库没写过 `t_event_usage`,建表后按事件行补一次(表非空即跳过)。 */
|
|
519
|
+
private async backfillEventUsage(): Promise<void> {
|
|
520
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
521
|
+
const tEvents = this.tables["t_events"];
|
|
522
|
+
const existing = (await this.db.execute(
|
|
523
|
+
sql`SELECT count(*) AS n FROM ${tEventUsage}`,
|
|
524
|
+
)) as unknown as { rows: Array<{ n: string }> };
|
|
525
|
+
if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
|
|
526
|
+
await this.db.execute(sql`
|
|
527
|
+
INSERT INTO ${tEventUsage} (
|
|
528
|
+
f_event_id, f_created_at, f_provider, f_model,
|
|
529
|
+
f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
|
|
530
|
+
SELECT e.f_event_id, e.f_created_at,
|
|
531
|
+
coalesce(e.f_data::json #>> '{data,message,source,provider}',
|
|
532
|
+
e.f_data::json #>> '{message,source,provider}'),
|
|
533
|
+
coalesce(e.f_data::json #>> '{data,message,source,model}',
|
|
534
|
+
e.f_data::json #>> '{message,source,model}'),
|
|
535
|
+
coalesce((e.f_data::json #>> '{data,usage,inputTokens}')::integer,
|
|
536
|
+
(e.f_data::json #>> '{usage,inputTokens}')::integer, 0),
|
|
537
|
+
coalesce((e.f_data::json #>> '{data,usage,outputTokens}')::integer,
|
|
538
|
+
(e.f_data::json #>> '{usage,outputTokens}')::integer, 0),
|
|
539
|
+
coalesce((e.f_data::json #>> '{data,usage,cacheReadTokens}')::integer,
|
|
540
|
+
(e.f_data::json #>> '{usage,cacheReadTokens}')::integer, 0),
|
|
541
|
+
coalesce((e.f_data::json #>> '{data,usage,reasoningTokens}')::integer,
|
|
542
|
+
(e.f_data::json #>> '{usage,reasoningTokens}')::integer, 0),
|
|
543
|
+
coalesce((e.f_data::json #>> '{data,usage,totalTokens}')::integer,
|
|
544
|
+
(e.f_data::json #>> '{usage,totalTokens}')::integer, 0)
|
|
545
|
+
FROM ${tEvents} e
|
|
546
|
+
WHERE e.f_type = 'assistant/message'
|
|
547
|
+
AND (e.f_data::json #> '{data,usage}' IS NOT NULL
|
|
548
|
+
OR e.f_data::json #> '{usage}' IS NOT NULL)
|
|
549
|
+
ON CONFLICT DO NOTHING
|
|
550
|
+
`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
|
|
554
|
+
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
555
|
+
const sinceClause = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
|
|
556
|
+
const tEventUsage = this.tables["t_event_usage"];
|
|
557
|
+
const tSessionEvents = this.tables["t_session_events"];
|
|
558
|
+
const tSessions = this.tables["t_sessions"];
|
|
559
|
+
const buckets = (await this.db.execute(sql`
|
|
560
|
+
SELECT to_char(to_timestamp(u.f_created_at / 1000.0), 'YYYY-MM-DD') AS day,
|
|
561
|
+
u.f_provider AS provider,
|
|
562
|
+
u.f_model AS model,
|
|
563
|
+
EXISTS (SELECT 1 FROM ${tSessionEvents} sb
|
|
564
|
+
JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
|
|
565
|
+
WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent') AS subagent,
|
|
566
|
+
count(*) AS events,
|
|
567
|
+
sum(u.f_input_tokens) AS input_tokens,
|
|
568
|
+
sum(u.f_output_tokens) AS output_tokens,
|
|
569
|
+
sum(u.f_cache_read_tokens) AS cache_read_tokens,
|
|
570
|
+
sum(u.f_reasoning_tokens) AS reasoning_tokens,
|
|
571
|
+
sum(u.f_total_tokens) AS total_tokens
|
|
572
|
+
FROM ${tEventUsage} u
|
|
573
|
+
WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${sinceClause}
|
|
574
|
+
GROUP BY 1, 2, 3, 4
|
|
575
|
+
`)) as unknown as { rows: Array<Record<string, unknown>> };
|
|
576
|
+
const sessions = (await this.db.execute(sql`
|
|
577
|
+
SELECT b.f_session_id AS session_id,
|
|
578
|
+
s.f_title AS title,
|
|
579
|
+
(s.f_origin = 'subagent') AS subagent,
|
|
580
|
+
(s.f_archived_at IS NOT NULL) AS archived,
|
|
581
|
+
count(*) AS events,
|
|
582
|
+
sum(u.f_input_tokens) AS input_tokens,
|
|
583
|
+
sum(u.f_output_tokens) AS output_tokens,
|
|
584
|
+
sum(u.f_cache_read_tokens) AS cache_read_tokens,
|
|
585
|
+
sum(u.f_reasoning_tokens) AS reasoning_tokens,
|
|
586
|
+
sum(u.f_total_tokens) AS total_tokens
|
|
587
|
+
FROM ${tSessionEvents} b
|
|
588
|
+
JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
|
|
589
|
+
JOIN ${tSessions} s ON s.f_session_id = b.f_session_id
|
|
590
|
+
WHERE 1 = 1${sinceClause}
|
|
591
|
+
GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at
|
|
592
|
+
`)) as unknown as { rows: Array<Record<string, unknown>> };
|
|
593
|
+
return {
|
|
594
|
+
buckets: buckets.rows.map((row) => ({
|
|
595
|
+
day: String(row["day"]),
|
|
596
|
+
provider: text(row["provider"]),
|
|
597
|
+
model: text(row["model"]),
|
|
598
|
+
subagent: row["subagent"] === true,
|
|
599
|
+
...totalsOfRow(row),
|
|
600
|
+
})),
|
|
601
|
+
sessions: sessions.rows.map((row) => ({
|
|
602
|
+
sessionId: String(row["session_id"]),
|
|
603
|
+
title: text(row["title"]),
|
|
604
|
+
subagent: row["subagent"] === true,
|
|
605
|
+
archived: row["archived"] === true,
|
|
606
|
+
...totalsOfRow(row),
|
|
607
|
+
})),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
384
611
|
private async getPrevBridge(
|
|
385
612
|
exec: PgAsyncDatabase<NodePgQueryResultHKT>,
|
|
386
613
|
id: SessionId,
|
package/src/schema.ts
CHANGED
|
@@ -8,7 +8,6 @@ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850;
|
|
|
8
8
|
|
|
9
9
|
export const EVENT_ENCODING = "json";
|
|
10
10
|
|
|
11
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
11
|
const sqliteTables: Record<string, any> = toSqliteSchema(sqliteTableDefs);
|
|
13
12
|
|
|
14
13
|
export const tPersistenceState = sqliteTables["t_persistence_state"]!;
|
|
@@ -31,6 +30,8 @@ export const tWorkspaceState = sqliteTables["t_workspace_state"]!;
|
|
|
31
30
|
|
|
32
31
|
export const tSessionProjcacheRows = sqliteTables["t_session_projcache_row"]!;
|
|
33
32
|
|
|
33
|
+
export const tEventUsage = sqliteTables["t_event_usage"]!;
|
|
34
|
+
|
|
34
35
|
export type { SessionRow } from "./backend.ts";
|
|
35
36
|
|
|
36
37
|
export type { EventRow } from "./backend.ts";
|
|
@@ -82,7 +83,7 @@ export function eventKind(event: { type: string; data?: unknown }): EventKind |
|
|
|
82
83
|
case "tool/result":
|
|
83
84
|
case "tool/ptc-dispatch-start":
|
|
84
85
|
case "tool/ptc-dispatch":
|
|
85
|
-
case "tool/code-dispatch-start":
|
|
86
|
+
case "tool/code-dispatch-start":
|
|
86
87
|
case "tool/code-dispatch":
|
|
87
88
|
return "tool";
|
|
88
89
|
case "request/header":
|
|
@@ -139,7 +140,7 @@ export function eventKind(event: { type: string; data?: unknown }): EventKind |
|
|
|
139
140
|
case "web/deepseek-search-llm-request":
|
|
140
141
|
return "web";
|
|
141
142
|
default:
|
|
142
|
-
return "";
|
|
143
|
+
return "";
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
@@ -150,7 +151,7 @@ export function eventDimensions(event: SessionEvent): {
|
|
|
150
151
|
actionId: string;
|
|
151
152
|
} {
|
|
152
153
|
const kind = eventKind(event);
|
|
153
|
-
|
|
154
|
+
|
|
154
155
|
const type = event.type as string;
|
|
155
156
|
const data = event.data as Record<string, unknown>;
|
|
156
157
|
switch (type) {
|
|
@@ -159,7 +160,6 @@ export function eventDimensions(event: SessionEvent): {
|
|
|
159
160
|
case "assistant/message":
|
|
160
161
|
return { kind, role: "assistant", name: "", actionId: "" };
|
|
161
162
|
case "tool/result": {
|
|
162
|
-
// append 可能携带旧形状(无 message);可选链容忍。
|
|
163
163
|
const message = data["message"] as { content?: Array<{ toolCallId?: string }> } | undefined;
|
|
164
164
|
return { kind, role: "tool", name: "", actionId: message?.content?.[0]?.toolCallId ?? "" };
|
|
165
165
|
}
|
|
@@ -172,7 +172,7 @@ export function eventDimensions(event: SessionEvent): {
|
|
|
172
172
|
};
|
|
173
173
|
case "tool/ptc-dispatch-start":
|
|
174
174
|
case "tool/ptc-dispatch":
|
|
175
|
-
case "tool/code-dispatch-start":
|
|
175
|
+
case "tool/code-dispatch-start":
|
|
176
176
|
case "tool/code-dispatch":
|
|
177
177
|
return {
|
|
178
178
|
kind,
|
package/src/session-query.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
2
|
import { SessionQueryEngine, SessionQueryError, type Config } from "@deepseek-ai/dsh-session-query";
|
|
3
3
|
|
|
4
|
-
// 会话查询服务的 rdb 实现:精确读 / 过滤 / 血缘全部复用上游基类(数据面走
|
|
5
|
-
// ctx.sessions 与我们的 ctx.sessionPersistence),这里只接管服务所有权。
|
|
6
|
-
// 全文检索与官方 `openAt: never` 装配保持一致——直接拒绝,不引入 FTS 索引、
|
|
7
|
-
// node:sqlite 或派生库,搜索语义留待显式实现(见 ADR 0006)。
|
|
8
4
|
export class SessionQueryRdb extends SessionQueryEngine {
|
|
9
5
|
constructor(ctx: Context, config: Config = {}) {
|
|
10
6
|
super(ctx, config);
|