@morlay/session-rdb 0.1.9 → 0.1.10-alpha.2

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 (143) hide show
  1. package/README.md +24 -3
  2. package/dist/artifact.d.mts +118 -3
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
  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 -3
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -3
  12. package/dist/export.mjs +32 -57
  13. package/dist/import.d.mts +1 -3
  14. package/dist/import.mjs +32 -78
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1562 -5
  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 -50
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -5
  25. package/dist/testing.mjs +27 -8
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +8 -42
  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} +125 -112
  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 -1630
  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 -272
  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-query.ts +17 -4
  74. package/src/session-store.ts +675 -0
  75. package/src/stats-fold.ts +44 -0
  76. package/src/storage-takeover/index.ts +2 -5
  77. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  78. package/src/storage-takeover/projection-cache.ts +139 -77
  79. package/src/storage-takeover/read-model.ts +189 -0
  80. package/src/storage-takeover/repository.ts +238 -425
  81. package/src/storage-takeover/storage-backend.ts +47 -149
  82. package/src/storage-takeover/types.ts +45 -14
  83. package/src/storage-takeover/workspace-documents.ts +65 -0
  84. package/src/storage.ts +4 -2
  85. package/src/testing.ts +21 -1
  86. package/src/usage.ts +11 -65
  87. package/src/version.ts +13 -0
  88. package/src/write-path.ts +286 -0
  89. package/dist/branch-DbFW3rJX.mjs +0 -391
  90. package/dist/deletion-ODZFcbZm.mjs +0 -2218
  91. package/dist/gc.d.mts +0 -6
  92. package/dist/gc.mjs +0 -49
  93. package/dist/log-DYxdjJ-l.mjs +0 -562
  94. package/dist/routes-DGBlBE_I.mjs +0 -10
  95. package/dist/sqlite-DjOnMRh2.mjs +0 -1777
  96. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  97. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  99. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  101. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  103. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  104. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  105. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  106. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  107. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  108. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  109. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  110. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  111. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  112. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  114. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  116. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  118. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  120. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  122. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  123. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  124. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  125. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  126. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  127. package/src/adapters/index.ts +0 -12
  128. package/src/adapters/to-postgres.ts +0 -93
  129. package/src/adapters/to-sqlite.ts +0 -83
  130. package/src/adapters/types.ts +0 -44
  131. package/src/drizzle/postgres-v2.ts +0 -10
  132. package/src/drizzle/postgres-v3.ts +0 -18
  133. package/src/drizzle/sqlite-v2.ts +0 -9
  134. package/src/drizzle/sqlite-v3.ts +0 -18
  135. package/src/entities/v2/index.ts +0 -20
  136. package/src/entities/v2/session-events.ts +0 -10
  137. package/src/entities/v3/schedule-tasks.ts +0 -16
  138. package/src/entities/v3/workspace-sessions.ts +0 -19
  139. package/src/entities/v3/workspace-state.ts +0 -12
  140. package/src/entities/v3/workspaces.ts +0 -14
  141. package/src/gc.ts +0 -77
  142. package/src/postgres.ts +0 -1015
  143. package/src/sqlite.ts +0 -1086
package/src/postgres.ts DELETED
@@ -1,1015 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { readdir } from "node:fs/promises";
3
- import { fileURLToPath } from "node:url";
4
- import { and, desc, eq, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
5
- import type { PgAsyncDatabase, PgAsyncTransaction } from "drizzle-orm/pg-core";
6
- import type { NodePgDatabase, NodePgQueryResultHKT } from "drizzle-orm/node-postgres";
7
- import { migrate } from "drizzle-orm/node-postgres/migrator";
8
- import type { SessionId } from "@deepseek-ai/dsh-session";
9
- import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
10
- import {
11
- type Backend,
12
- type BackendTx,
13
- type EventInsert,
14
- type EventRow,
15
- type SessionCountBucket,
16
- type SessionListRowsPage,
17
- type SessionListRowsQuery,
18
- type SessionRow,
19
- type SessionUsageBucket,
20
- } from "./backend.ts";
21
- import { toPostgresSchema } from "./adapters/index.ts";
22
- import { postgresTableDefs } from "./entities/index.ts";
23
- import { sessionConflictRow, sessionInsertRow, titleOfEventData } from "./log.ts";
24
- import type { EventUsageRow } from "./log.ts";
25
- import { createStorageRepository } from "./storage-takeover/repository.ts";
26
- import type { StorageRepository } from "./storage-takeover/types.ts";
27
- import {
28
- COUNTED_EVENT_TYPES,
29
- addActivityTotals,
30
- addTokenTotals,
31
- addTotals,
32
- emptyTotals,
33
- localDayKey,
34
- } from "./usage.ts";
35
- import type { UsageActivityTotals, UsageAggregate, UsageTokenTotals } from "./usage.ts";
36
-
37
- const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
38
-
39
- const pgWriteQueues = new Map<string, Promise<void>>();
40
-
41
- // 引用标记的分批重算:一次绑定的参数有上限,按块走(与 sqlite 侧同值)。
42
- const REFRESH_FLAG_CHUNK = 400;
43
-
44
- // 按介质(连接串 + schema)串行化写事务,与 SQLite 侧的 `enqueueSqliteTx` 同形。
45
- function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
46
- const tail = pgWriteQueues.get(key) ?? Promise.resolve();
47
- const run = tail.then(fn);
48
- pgWriteQueues.set(
49
- key,
50
- run.then(
51
- () => undefined,
52
- () => undefined,
53
- ),
54
- );
55
- return run;
56
- }
57
-
58
- // pg 的 text 列窄化:非字符串(含 null)都不当作文本。
59
- function text(value: unknown): string | null {
60
- return typeof value === "string" ? value : null;
61
- }
62
-
63
- // pg 的 count/sum 回落成字符串:统一转成有限数。
64
- function numeric(value: unknown): number {
65
- const parsed = typeof value === "number" ? value : Number(value ?? 0);
66
- return Number.isFinite(parsed) ? parsed : 0;
67
- }
68
-
69
- // SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。
70
- function tokenTotalsOf(row: Record<string, unknown>): UsageTokenTotals {
71
- return {
72
- inputTokens: numeric(row["input_tokens"]),
73
- outputTokens: numeric(row["output_tokens"]),
74
- cacheReadTokens: numeric(row["cache_read_tokens"]),
75
- reasoningTokens: numeric(row["reasoning_tokens"]),
76
- totalTokens: numeric(row["total_tokens"]),
77
- };
78
- }
79
-
80
- function emptyActivity(): UsageActivityTotals {
81
- return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
82
- }
83
-
84
- // `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。
85
- function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
86
- return {
87
- turns: numeric(row["turns"]),
88
- steps: numeric(row["steps"]),
89
- userInputs: numeric(row["user_inputs"]),
90
- toolCalls: numeric(row["tool_calls"]),
91
- };
92
- }
93
-
94
- // `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。
95
- const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
96
-
97
- export interface PostgresBackendOptions {
98
- identityBase: string;
99
-
100
- schema?: string;
101
-
102
- close: () => Promise<void>;
103
- }
104
-
105
- export class PostgresBackend implements Backend {
106
- readonly kind = "postgres" as const;
107
- storeIdentity!: string;
108
-
109
- private readonly tables: Record<string, any>;
110
-
111
- readonly storage: StorageRepository;
112
-
113
- private readonly opened: Promise<void>;
114
- private resolveOpened!: () => void;
115
- private rejectOpened!: (error: unknown) => void;
116
-
117
- private txOverride: unknown;
118
-
119
- constructor(
120
- private readonly db: NodePgDatabase,
121
- private readonly options: PostgresBackendOptions,
122
- ) {
123
- this.tables = toPostgresSchema(postgresTableDefs, this.options.schema ?? "public");
124
- this.opened = new Promise<void>((resolve, reject) => {
125
- this.resolveOpened = resolve;
126
- this.rejectOpened = reject;
127
- });
128
-
129
- this.opened.catch(() => {});
130
- // 写事务按介质串行(与 SQLite 侧 `enqueueSqliteTx` 同形):并发 `writeAtomically` 会互相覆盖实例级的
131
- // `txOverride`,让语句落进别人的事务或退化成 autocommit;介质身份用 `identityBase`(宿主 + 库 + schema),
132
- // 同一进程内多实例连同一库时共用同一条队列。
133
- const writeQueueKey = options.identityBase;
134
- this.storage = createStorageRepository({
135
- db: () => this.opened.then(() => this.txOverride ?? this.db),
136
-
137
- writeAtomically: (fn) =>
138
- enqueuePgWrite(writeQueueKey, () =>
139
- this.db.transaction(
140
- async (tx) => {
141
- const previous = this.txOverride;
142
- this.txOverride = tx;
143
- try {
144
- return await fn();
145
- } finally {
146
- this.txOverride = previous;
147
- }
148
- },
149
- { isolationLevel: "serializable" },
150
- ),
151
- ),
152
- tables: this.tables,
153
- });
154
- }
155
-
156
- async open(): Promise<void> {
157
- try {
158
- await this.doOpen();
159
- this.resolveOpened();
160
- } catch (error: unknown) {
161
- this.rejectOpened(error);
162
- throw error;
163
- }
164
- }
165
-
166
- private async doOpen(): Promise<void> {
167
- const schema = this.options.schema ?? "public";
168
-
169
- const qualifiedMeta = schema === "public" ? "t_schema_meta" : `"${schema}".t_schema_meta`;
170
- const probe = (await this.db.execute(
171
- sql`SELECT to_regclass(${qualifiedMeta}) IS NOT NULL AS exists`,
172
- )) as unknown as { rows: { exists: boolean }[] };
173
- const metaExists = probe.rows[0]?.exists === true;
174
- if (metaExists) {
175
- const version = await this.readMeta(this.db, "schema_version");
176
- if (version === "2") {
177
- await this.baselineV2();
178
- }
179
- }
180
- await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
181
- await this.backfillUsageTables();
182
- const storeId = await this.db.transaction(async (tx) => {
183
- await tx
184
- .insert(this.tables["t_persistence_state"])
185
- .values({ fSingleton: 1, fStoreId: randomUUID() })
186
- .onConflictDoNothing()
187
- .execute();
188
- const store = await tx
189
- .select({ fStoreId: this.tables["t_persistence_state"].fStoreId })
190
- .from(this.tables["t_persistence_state"])
191
- .where(eq(this.tables["t_persistence_state"].fSingleton, 1))
192
- .execute();
193
- const id = store[0]?.fStoreId;
194
- if (id === undefined || id.length === 0) {
195
- throw new Error("session database has no valid store identity");
196
- }
197
- return id;
198
- });
199
- this.storeIdentity = `${this.options.identityBase}:store:${storeId}`;
200
- }
201
-
202
- private async baselineV2(): Promise<void> {
203
- const dir = (await readdir(postgresMigrationsDir)).find((name) => name.endsWith("_v2_initial"));
204
- if (dir === undefined) throw new Error("missing v2 baseline migration in drizzle/postgres");
205
- await this.db.execute(sql`
206
- CREATE SCHEMA IF NOT EXISTS drizzle
207
- `);
208
- await this.db.execute(sql`
209
- CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
210
- id SERIAL PRIMARY KEY,
211
- hash text NOT NULL,
212
- created_at bigint,
213
- name text,
214
- applied_at timestamp with time zone DEFAULT now()
215
- )
216
- `);
217
- await this.db.execute(
218
- sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at, name) VALUES ('baseline', 0, ${dir})`,
219
- );
220
- }
221
-
222
- async close(): Promise<void> {
223
- await this.options.close();
224
- }
225
-
226
- async getSession(id: SessionId): Promise<SessionRow | undefined> {
227
- return (
228
- await this.db
229
- .select()
230
- .from(this.tables["t_sessions"])
231
- .where(eq(this.tables["t_sessions"].fSessionId, id))
232
- .execute()
233
- )[0] as SessionRow | undefined;
234
- }
235
-
236
- async getEventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]> {
237
- const scoped =
238
- fromSequence === undefined
239
- ? this.eventRows(this.db).where(eq(this.tables["t_session_events"].fSessionId, id))
240
- : this.eventRows(this.db).where(
241
- and(
242
- eq(this.tables["t_session_events"].fSessionId, id),
243
- gte(this.tables["t_session_events"].fSequence, fromSequence),
244
- ),
245
- );
246
- return scoped
247
- .orderBy(this.tables["t_session_events"].fSequence)
248
- .execute() as unknown as EventRow[];
249
- }
250
-
251
- // 只取类型:rewind 的边界 / 窗口探测不该把整个事件 JSON(f_data)拖回来。
252
- async getEventTypeAt(id: SessionId, sequence: number): Promise<string | undefined> {
253
- const table = this.tables["t_session_events"];
254
- const rows = (await this.db
255
- .select({ fType: this.tables["t_events"].fType })
256
- .from(table)
257
- .innerJoin(this.tables["t_events"], eq(table.fEventId, this.tables["t_events"].fEventId))
258
- .where(and(eq(table.fSessionId, id), eq(table.fSequence, sequence)))
259
- .limit(1)
260
- .execute()) as Array<{ fType?: string }>;
261
- return rows[0]?.fType;
262
- }
263
-
264
- async getEventTypesBefore(
265
- id: SessionId,
266
- beforeSequence: number,
267
- limit: number,
268
- ): Promise<Array<Pick<EventRow, "fSequence" | "fType">>> {
269
- const table = this.tables["t_session_events"];
270
- return (await this.db
271
- .select({ fSequence: table.fSequence, fType: this.tables["t_events"].fType })
272
- .from(table)
273
- .innerJoin(this.tables["t_events"], eq(table.fEventId, this.tables["t_events"].fEventId))
274
- .where(and(eq(table.fSessionId, id), lt(table.fSequence, beforeSequence)))
275
- .orderBy(desc(table.fSequence))
276
- .limit(limit)
277
- .execute()) as unknown as Array<Pick<EventRow, "fSequence" | "fType">>;
278
- }
279
-
280
- async listSessions(): Promise<SessionRow[]> {
281
- return this.db.select().from(this.tables["t_sessions"]).execute() as unknown as Promise<
282
- SessionRow[]
283
- >;
284
- }
285
-
286
- async transaction<T>(fn: (tx: BackendTx) => Promise<T>): Promise<T> {
287
- return this.db.transaction(async (tx) => fn(this.txFor(tx)));
288
- }
289
-
290
- private txFor(tx: PgAsyncTransaction<NodePgQueryResultHKT>): BackendTx {
291
- return {
292
- upsertSession: (storage, incarnation) => this.upsertSession(tx, storage, incarnation),
293
- getHead: (id) => this.getHead(tx, id),
294
- getSeedLength: (id) => this.getSeedLength(tx, id),
295
- updateSeedLength: (id, seedLength) => this.updateSeedLength(tx, id, seedLength),
296
- insertEvents: (events, usageRows) => this.insertEvents(tx, events, usageRows),
297
- insertBridges: (rows) => this.insertBridges(tx, rows),
298
- updateHead: (id, headEventId, headSequence) =>
299
- this.updateHead(tx, id, headEventId, headSequence),
300
- bumpRevision: (id, lastEventAt) => this.bumpRevision(tx, id, lastEventAt),
301
- refreshTitle: (id) => this.refreshTitle(tx, id),
302
- deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
303
- getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence),
304
- deleteSession: (id) => this.deleteSession(tx, id),
305
- deleteSessions: (ids) => this.deleteSessions(tx, ids),
306
- };
307
- }
308
-
309
- private async readMeta(
310
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
311
- key: string,
312
- ): Promise<string | undefined> {
313
- const rows = await exec
314
- .select({ fValue: this.tables["t_schema_meta"].fValue })
315
- .from(this.tables["t_schema_meta"])
316
- .where(eq(this.tables["t_schema_meta"].fKey, key))
317
- .execute();
318
- return rows[0]?.fValue;
319
- }
320
-
321
- private async upsertSession(
322
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
323
- storage: SessionStorageMetadata,
324
- incarnation: string,
325
- ): Promise<void> {
326
- await exec
327
- .insert(this.tables["t_sessions"])
328
- .values(sessionInsertRow(storage, incarnation))
329
- .onConflictDoUpdate({
330
- target: this.tables["t_sessions"].fSessionId,
331
- set: sessionConflictRow(storage),
332
- })
333
- .execute();
334
- }
335
-
336
- private async getHead(
337
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
338
- id: SessionId,
339
- ): Promise<Pick<SessionRow, "fHeadEventId" | "fHeadSequence">> {
340
- const head = (
341
- await exec
342
- .select({
343
- fHeadEventId: this.tables["t_sessions"].fHeadEventId,
344
- fHeadSequence: this.tables["t_sessions"].fHeadSequence,
345
- })
346
- .from(this.tables["t_sessions"])
347
- .where(eq(this.tables["t_sessions"].fSessionId, id))
348
- .execute()
349
- )[0] as Pick<SessionRow, "fHeadEventId" | "fHeadSequence"> | undefined;
350
-
351
- if (head === undefined) throw new Error(`session "${id}" has no materialized row`);
352
- return head;
353
- }
354
-
355
- private async getSeedLength(
356
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
357
- id: SessionId,
358
- ): Promise<number | null> {
359
- const row = (
360
- await exec
361
- .select({ fSeedLength: this.tables["t_sessions"].fSeedLength })
362
- .from(this.tables["t_sessions"])
363
- .where(eq(this.tables["t_sessions"].fSessionId, id))
364
- .execute()
365
- )[0] as { fSeedLength: number | null } | undefined;
366
-
367
- if (row === undefined) throw new Error(`session "${id}" has no materialized row`);
368
- return row.fSeedLength;
369
- }
370
-
371
- private async updateSeedLength(
372
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
373
- id: SessionId,
374
- seedLength: number,
375
- ): Promise<void> {
376
- await exec
377
- .update(this.tables["t_sessions"])
378
- .set({ fSeedLength: seedLength })
379
- .where(eq(this.tables["t_sessions"].fSessionId, id))
380
- .execute();
381
- }
382
-
383
- private static readonly INSERT_BATCH_ROWS = 1000;
384
-
385
- private async insertEvents(
386
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
387
- events: EventInsert[],
388
- usageRows: readonly EventUsageRow[],
389
- ): Promise<void> {
390
- if (events.length === 0) return;
391
- for (let i = 0; i < events.length; i += PostgresBackend.INSERT_BATCH_ROWS) {
392
- await exec
393
- .insert(this.tables["t_events"])
394
- .values(
395
- events.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event })),
396
- )
397
- .execute();
398
- }
399
- // 用量行顺带落 t_event_usage(本地日与引用标记已由写路径物化):统计不再解析事件 JSON、
400
- // 也不回连事件表。
401
- if (usageRows.length > 0) {
402
- for (let i = 0; i < usageRows.length; i += PostgresBackend.INSERT_BATCH_ROWS) {
403
- await exec
404
- .insert(this.tables["t_event_usage"])
405
- .values(
406
- usageRows.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row })),
407
- )
408
- .onConflictDoNothing()
409
- .execute();
410
- }
411
- }
412
- }
413
-
414
- private async insertBridges(
415
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
416
- rows: Array<{
417
- fSessionId: SessionId;
418
- fEventId: string;
419
- fSequence: number;
420
- fSurfaceOp: string | null;
421
- }>,
422
- ): Promise<void> {
423
- if (rows.length === 0) return;
424
- for (let i = 0; i < rows.length; i += PostgresBackend.INSERT_BATCH_ROWS) {
425
- await exec
426
- .insert(this.tables["t_session_events"])
427
- .values(rows.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row })))
428
- .execute();
429
- }
430
- }
431
-
432
- private async updateHead(
433
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
434
- id: SessionId,
435
- headEventId: string,
436
- headSequence: number,
437
- ): Promise<void> {
438
- await exec
439
- .update(this.tables["t_sessions"])
440
- .set({ fHeadEventId: headEventId, fHeadSequence: headSequence })
441
- .where(eq(this.tables["t_sessions"].fSessionId, id))
442
- .execute();
443
- }
444
-
445
- private async bumpRevision(
446
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
447
- id: SessionId,
448
- lastEventAt?: number,
449
- ): Promise<void> {
450
- const sessions = this.tables["t_sessions"];
451
- await exec
452
- .update(sessions)
453
- .set({
454
- fRevision: sql`${sessions.fRevision} + 1`,
455
- ...(lastEventAt === undefined
456
- ? {}
457
- : { fLastEventAt: sql`GREATEST(COALESCE(${sessions.fLastEventAt}, 0), ${lastEventAt})` }),
458
- })
459
- .where(eq(sessions.fSessionId, id))
460
- .execute();
461
- }
462
-
463
- private async refreshTitle(
464
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
465
- id: SessionId,
466
- ): Promise<void> {
467
- const bridges = this.tables["t_session_events"];
468
- const entities = this.tables["t_events"];
469
- const row = (
470
- await exec
471
- .select({ fSequence: bridges.fSequence, fData: entities.fData })
472
- .from(bridges)
473
- .innerJoin(entities, eq(entities.fEventId, bridges.fEventId))
474
- .where(and(eq(bridges.fSessionId, id), eq(entities.fType, "session/title")))
475
- .orderBy(desc(bridges.fSequence))
476
- .limit(1)
477
- .execute()
478
- )[0] as { fSequence: number; fData: string } | undefined;
479
- const title = row === undefined ? undefined : titleOfEventData(row.fData);
480
- await exec
481
- .update(this.tables["t_sessions"])
482
- .set({ fTitle: title ?? null, fTitleSeq: title === undefined ? null : row!.fSequence })
483
- .where(eq(this.tables["t_sessions"].fSessionId, id))
484
- .execute();
485
- }
486
-
487
- private async deleteBridgeTail(
488
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
489
- id: SessionId,
490
- fromSequence: number,
491
- ): Promise<readonly string[]> {
492
- const table = this.tables["t_session_events"];
493
- const result = (await exec.execute(sql`
494
- DELETE FROM ${table} WHERE f_session_id = ${id} AND f_sequence >= ${fromSequence}
495
- RETURNING f_event_id
496
- `)) as unknown as { rows: Array<{ f_event_id: string }> };
497
- return result.rows.map((row) => row.f_event_id);
498
- }
499
-
500
- private async deleteSession(
501
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
502
- id: SessionId,
503
- ): Promise<readonly string[]> {
504
- const result = (await exec.execute(sql`
505
- DELETE FROM ${this.tables["t_session_events"]} WHERE f_session_id = ${id} RETURNING f_event_id
506
- `)) as unknown as { rows: Array<{ f_event_id: string }> };
507
- await exec
508
- .delete(this.tables["t_workspace_sessions"])
509
- .where(eq(this.tables["t_workspace_sessions"].fSessionId, id))
510
- .execute();
511
- await exec
512
- .delete(this.tables["t_session_projcache_row"])
513
- .where(eq(this.tables["t_session_projcache_row"].fSessionId, id))
514
- .execute();
515
- await exec
516
- .delete(this.tables["t_sessions"])
517
- .where(eq(this.tables["t_sessions"].fSessionId, id))
518
- .execute();
519
- return result.rows.map((row) => row.f_event_id);
520
- }
521
-
522
- // 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。
523
- async collectOrphans(): Promise<number> {
524
- const tEvents = this.tables["t_events"];
525
- const tEventUsage = this.tables["t_event_usage"];
526
- const tSessionEvents = this.tables["t_session_events"];
527
- const result = (await this.db
528
- .delete(tEvents)
529
- .where(
530
- notInArray(
531
- tEvents.fEventId,
532
- this.db.select({ fEventId: tSessionEvents.fEventId }).from(tSessionEvents),
533
- ),
534
- )
535
- .execute()) as unknown as { rowCount?: number | null };
536
- // 用量行跟着事件行走:没有事件行的用量不再计入统计。
537
- await this.db
538
- .delete(tEventUsage)
539
- .where(
540
- notInArray(
541
- tEventUsage.fEventId,
542
- this.db.select({ fEventId: tEvents.fEventId }).from(tEvents),
543
- ),
544
- )
545
- .execute();
546
- return result.rowCount ?? 0;
547
- }
548
-
549
- // 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。
550
- async listOrphanSubagentSessions(): Promise<SessionId[]> {
551
- const tSessions = this.tables["t_sessions"];
552
- const result = (await this.db.execute(sql`
553
- SELECT s.f_session_id AS id FROM ${tSessions} AS s
554
- WHERE s.f_origin = 'subagent'
555
- AND (s.f_parent_session IS NULL
556
- OR s.f_parent_session NOT IN (SELECT p.f_session_id FROM ${tSessions} AS p))
557
- `)) as unknown as { rows: Array<{ id: string }> };
558
- return result.rows.map((row) => row.id as SessionId);
559
- }
560
-
561
- private async deleteSessions(
562
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
563
- ids: SessionId[],
564
- ): Promise<{ deleted: number; eventIds: readonly string[] }> {
565
- if (ids.length === 0) return { deleted: 0, eventIds: [] };
566
- const removed = (await exec.execute(sql`
567
- DELETE FROM ${this.tables["t_session_events"]}
568
- WHERE f_session_id IN (${sql.join(
569
- ids.map((id) => sql`${id}`),
570
- sql`, `,
571
- )})
572
- RETURNING f_event_id
573
- `)) as unknown as { rows: Array<{ f_event_id: string }> };
574
- await exec
575
- .delete(this.tables["t_workspace_sessions"])
576
- .where(inArray(this.tables["t_workspace_sessions"].fSessionId, ids))
577
- .execute();
578
- await exec
579
- .delete(this.tables["t_session_projcache_row"])
580
- .where(inArray(this.tables["t_session_projcache_row"].fSessionId, ids))
581
- .execute();
582
- const result = (await exec
583
- .delete(this.tables["t_sessions"])
584
- .where(inArray(this.tables["t_sessions"].fSessionId, ids))
585
- .execute()) as unknown as { rowCount?: number | null };
586
- return {
587
- deleted: Number(result.rowCount ?? 0),
588
- eventIds: removed.rows.map((row) => row.f_event_id),
589
- };
590
- }
591
-
592
- async vacuum(): Promise<void> {
593
- await this.db.execute(sql`VACUUM ANALYZE`);
594
- }
595
-
596
- // 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。
597
- private async backfillUsageTables(): Promise<void> {
598
- // 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
599
- await this.backfillEventUsage();
600
- await this.backfillSessionUsage();
601
- await this.backfillSessionCounts();
602
- }
603
-
604
- private async tableIsEmpty(table: string): Promise<boolean> {
605
- const existing = (await this.db.execute(
606
- sql.raw(`SELECT count(*) AS n FROM ${table}`),
607
- )) as unknown as { rows: Array<Record<string, unknown>> };
608
- return Number(existing.rows[0]?.["n"] ?? 0) === 0;
609
- }
610
-
611
- // 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
612
- // 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
613
- private async backfillEventUsage(): Promise<void> {
614
- if (!(await this.tableIsEmpty(this.qualifiedTable("t_event_usage")))) return;
615
- const tEventUsage = this.tables["t_event_usage"];
616
- const tEvents = this.tables["t_events"];
617
- const tSessionEvents = this.tables["t_session_events"];
618
- const tSessions = this.tables["t_sessions"];
619
- await this.db.execute(sql`
620
- INSERT INTO ${tEventUsage} (
621
- f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
622
- f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
623
- SELECT e.f_event_id, e.f_created_at,
624
- to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
625
- coalesce(e.f_data::json #>> '{data,message,source,provider}',
626
- e.f_data::json #>> '{message,source,provider}'),
627
- coalesce(e.f_data::json #>> '{data,message,source,model}',
628
- e.f_data::json #>> '{message,source,model}'),
629
- (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
630
- WHERE rb.f_event_id = e.f_event_id))::int,
631
- (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
632
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
633
- WHERE sb.f_event_id = e.f_event_id AND ss.f_origin = 'subagent'))::int,
634
- coalesce((e.f_data::json #>> '{data,usage,inputTokens}')::integer,
635
- (e.f_data::json #>> '{usage,inputTokens}')::integer, 0),
636
- coalesce((e.f_data::json #>> '{data,usage,outputTokens}')::integer,
637
- (e.f_data::json #>> '{usage,outputTokens}')::integer, 0),
638
- coalesce((e.f_data::json #>> '{data,usage,cacheReadTokens}')::integer,
639
- (e.f_data::json #>> '{usage,cacheReadTokens}')::integer, 0),
640
- coalesce((e.f_data::json #>> '{data,usage,reasoningTokens}')::integer,
641
- (e.f_data::json #>> '{usage,reasoningTokens}')::integer, 0),
642
- coalesce((e.f_data::json #>> '{data,usage,totalTokens}')::integer,
643
- (e.f_data::json #>> '{usage,totalTokens}')::integer, 0)
644
- FROM ${tEvents} e
645
- WHERE e.f_type = 'assistant/message'
646
- AND (e.f_data::json #> '{data,usage}' IS NOT NULL
647
- OR e.f_data::json #> '{usage}' IS NOT NULL)
648
- ON CONFLICT DO NOTHING
649
- `);
650
- }
651
-
652
- // 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。
653
- private async backfillSessionUsage(): Promise<void> {
654
- if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_usage")))) return;
655
- const tSessionUsage = this.tables["t_session_usage"];
656
- const tSessionEvents = this.tables["t_session_events"];
657
- const tEventUsage = this.tables["t_event_usage"];
658
- await this.db.execute(sql`
659
- INSERT INTO ${tSessionUsage} (
660
- f_session_id, f_day, f_provider, f_model,
661
- f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
662
- SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
663
- sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
664
- sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
665
- FROM ${tSessionEvents} b
666
- JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
667
- GROUP BY 1, 2, 3, 4
668
- ON CONFLICT DO NOTHING
669
- `);
670
- }
671
-
672
- // 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。
673
- private async backfillSessionCounts(): Promise<void> {
674
- if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_counts")))) return;
675
- const tSessionCounts = this.tables["t_session_counts"];
676
- const tSessionEvents = this.tables["t_session_events"];
677
- const tEvents = this.tables["t_events"];
678
- await this.db.execute(sql`
679
- INSERT INTO ${tSessionCounts} (
680
- f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
681
- SELECT b.f_session_id,
682
- to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
683
- sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
684
- sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
685
- sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
686
- sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
687
- FROM ${tSessionEvents} b
688
- JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
689
- WHERE e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
690
- GROUP BY 1, 2
691
- ON CONFLICT DO NOTHING
692
- `);
693
- }
694
-
695
- // 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。
696
- async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
697
- const needle = query.query?.trim().toLowerCase() ?? "";
698
- const like = `%${needle}%`;
699
- const includeSubagents = query.includeSubagents === true;
700
- const limit = query.limit ?? 20;
701
- const offset = query.offset ?? 0;
702
- const tSessions = this.tables["t_sessions"];
703
- const tWorkspaces = this.tables["t_workspaces"];
704
- const tWorkspaceSessions = this.tables["t_workspace_sessions"];
705
- // 归属可能有多行(一个会话可在多个工作区):取 position 最小的那条,避免放大结果行数。
706
- const workspaceTitle = sql`(SELECT w.f_title FROM ${tWorkspaceSessions} b
707
- JOIN ${tWorkspaces} w ON w.f_workspace_id = b.f_workspace_id
708
- WHERE b.f_session_id = s.f_session_id
709
- ORDER BY b.f_position LIMIT 1)`;
710
- const where = sql`WHERE (${needle} = ''
711
- OR lower(COALESCE(s.f_title, '')) LIKE ${like}
712
- OR lower(COALESCE(${workspaceTitle}, '')) LIKE ${like})
713
- AND (${includeSubagents} OR s.f_origin IS DISTINCT FROM 'subagent')`;
714
- const rows = (await this.db.execute(sql`
715
- SELECT s.f_session_id AS session_id,
716
- s.f_title AS title,
717
- s.f_origin AS origin,
718
- s.f_cwd AS cwd,
719
- s.f_created_at AS created_at,
720
- (s.f_archived_at IS NOT NULL) AS archived,
721
- COALESCE(s.f_last_event_at, s.f_created_at) AS updated_at,
722
- ${workspaceTitle} AS workspace
723
- FROM ${tSessions} s
724
- ${where}
725
- ORDER BY updated_at DESC, s.f_session_id
726
- LIMIT ${limit} OFFSET ${offset}
727
- `)) as unknown as { rows: Array<Record<string, unknown>> };
728
- const counted = (await this.db.execute(sql`
729
- SELECT count(*) AS n FROM ${tSessions} s ${where}
730
- `)) as unknown as { rows: Array<Record<string, unknown>> };
731
- return {
732
- items: rows.rows.map((row) => ({
733
- sessionId:
734
- typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
735
- title: typeof row["title"] === "string" ? row["title"] : null,
736
- origin: typeof row["origin"] === "string" ? row["origin"] : null,
737
- cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
738
- createdAt: numeric(row["created_at"]),
739
- updatedAt: numeric(row["updated_at"]),
740
- archived: row["archived"] === true,
741
- subagent: row["origin"] === "subagent",
742
- workspace: typeof row["workspace"] === "string" ? row["workspace"] : null,
743
- })),
744
- total: numeric(counted.rows[0]?.["n"] ?? 0),
745
- };
746
- }
747
-
748
- // 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
749
- // 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
750
- async usageReport(sinceMs?: number): Promise<UsageAggregate> {
751
- const usageSince = sinceMs === undefined ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
752
- const dayFilter = (column: string): ReturnType<typeof sql> =>
753
- sinceMs === undefined ? sql`` : sql` AND ${sql.raw(column)} >= ${localDayKey(sinceMs)}`;
754
- const tEventUsage = this.tables["t_event_usage"];
755
- const tSessionUsage = this.tables["t_session_usage"];
756
- const tSessionCounts = this.tables["t_session_counts"];
757
- const tSessions = this.tables["t_sessions"];
758
- const tokenColumns = (alias: string): ReturnType<typeof sql> =>
759
- sql.raw(`sum(${alias}.f_input_tokens) AS input_tokens,
760
- sum(${alias}.f_output_tokens) AS output_tokens,
761
- sum(${alias}.f_cache_read_tokens) AS cache_read_tokens,
762
- sum(${alias}.f_reasoning_tokens) AS reasoning_tokens,
763
- sum(${alias}.f_total_tokens) AS total_tokens`);
764
-
765
- const buckets = (await this.db.execute(sql`
766
- SELECT a.f_day AS day,
767
- a.f_provider AS provider,
768
- a.f_model AS model,
769
- (a.f_subagent = 1) AS subagent,
770
- ${tokenColumns("a")}
771
- FROM ${tEventUsage} a
772
- WHERE a.f_referenced = 1${usageSince}
773
- GROUP BY 1, 2, 3, 4
774
- `)) as unknown as { rows: Array<Record<string, unknown>> };
775
- const tokenScope = (await this.db.execute(sql`
776
- SELECT (a.f_subagent = 1) AS subagent,
777
- ${tokenColumns("a")}
778
- FROM ${tEventUsage} a
779
- WHERE a.f_referenced = 1${usageSince}
780
- GROUP BY 1
781
- `)) as unknown as { rows: Array<Record<string, unknown>> };
782
- const activityScope = (await this.db.execute(sql`
783
- SELECT (s.f_origin = 'subagent') AS subagent,
784
- sum(c.f_turns) AS turns,
785
- sum(c.f_steps) AS steps,
786
- sum(c.f_user_inputs) AS user_inputs,
787
- sum(c.f_tool_calls) AS tool_calls
788
- FROM ${tSessionCounts} c
789
- JOIN ${tSessions} s ON s.f_session_id = c.f_session_id
790
- WHERE EXISTS (SELECT 1 FROM ${tSessionUsage} ub WHERE ub.f_session_id = c.f_session_id)${dayFilter("c.f_day")}
791
- GROUP BY 1
792
- `)) as unknown as { rows: Array<Record<string, unknown>> };
793
- const tokenSessions = (await this.db.execute(sql`
794
- SELECT u.f_session_id AS session_id,
795
- s.f_title AS title,
796
- (s.f_origin = 'subagent') AS subagent,
797
- (s.f_archived_at IS NOT NULL) AS archived,
798
- ${tokenColumns("u")}
799
- FROM ${tSessionUsage} u
800
- JOIN ${tSessions} s ON s.f_session_id = u.f_session_id
801
- WHERE 1 = 1${dayFilter("u.f_day")}
802
- GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at
803
- `)) as unknown as { rows: Array<Record<string, unknown>> };
804
- const activitySessions = (await this.db.execute(sql`
805
- SELECT c.f_session_id AS session_id,
806
- sum(c.f_turns) AS turns,
807
- sum(c.f_steps) AS steps,
808
- sum(c.f_user_inputs) AS user_inputs,
809
- sum(c.f_tool_calls) AS tool_calls
810
- FROM ${tSessionCounts} c
811
- WHERE 1 = 1${dayFilter("c.f_day")}
812
- GROUP BY c.f_session_id
813
- `)) as unknown as { rows: Array<Record<string, unknown>> };
814
- const human = emptyTotals();
815
- const subagent = emptyTotals();
816
- for (const row of tokenScope.rows) {
817
- addTokenTotals(row["subagent"] === true ? subagent : human, tokenTotalsOf(row));
818
- }
819
- for (const row of activityScope.rows) {
820
- addActivityTotals(row["subagent"] === true ? subagent : human, activityTotalsOf(row));
821
- }
822
- const totals = addTotals(addTotals(emptyTotals(), human), subagent);
823
-
824
- const activityBySession = new Map<string, UsageActivityTotals>();
825
- for (const row of activitySessions.rows) {
826
- activityBySession.set(String(row["session_id"]), activityTotalsOf(row));
827
- }
828
- return {
829
- totals,
830
- subagent,
831
- human,
832
- buckets: buckets.rows.map((row) => ({
833
- day: String(row["day"]),
834
- provider: text(row["provider"]),
835
- model: text(row["model"]),
836
- subagent: row["subagent"] === true,
837
- ...tokenTotalsOf(row),
838
- })),
839
- sessions: tokenSessions.rows.map((row) => ({
840
- sessionId:
841
- typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
842
- title: text(row["title"]),
843
- subagent: row["subagent"] === true,
844
- archived: row["archived"] === true,
845
- ...tokenTotalsOf(row),
846
- ...(activityBySession.get(String(row["session_id"])) ?? emptyActivity()),
847
- })),
848
- };
849
- }
850
-
851
- // 表名限定到配置的 schema。
852
- private qualifiedTable(name: string): string {
853
- const schema = this.options.schema ?? "public";
854
- return schema === "public" ? name : `"${schema}".${name}`;
855
- }
856
-
857
- // token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。
858
- async incrementSessionUsage(
859
- id: SessionId,
860
- buckets: readonly SessionUsageBucket[],
861
- ): Promise<void> {
862
- if (buckets.length === 0) return;
863
- const tSessionUsage = this.tables["t_session_usage"];
864
- for (const bucket of buckets) {
865
- await this.db.execute(sql`
866
- INSERT INTO ${tSessionUsage} (
867
- f_session_id, f_day, f_provider, f_model,
868
- f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
869
- VALUES (${id}, ${bucket.day}, ${bucket.provider ?? ""}, ${bucket.model ?? ""},
870
- ${bucket.inputTokens}, ${bucket.outputTokens}, ${bucket.cacheReadTokens},
871
- ${bucket.reasoningTokens}, ${bucket.totalTokens})
872
- ON CONFLICT (f_session_id, f_day, f_provider, f_model) DO UPDATE SET
873
- f_input_tokens = ${tSessionUsage}.f_input_tokens + EXCLUDED.f_input_tokens,
874
- f_output_tokens = ${tSessionUsage}.f_output_tokens + EXCLUDED.f_output_tokens,
875
- f_cache_read_tokens = ${tSessionUsage}.f_cache_read_tokens + EXCLUDED.f_cache_read_tokens,
876
- f_reasoning_tokens = ${tSessionUsage}.f_reasoning_tokens + EXCLUDED.f_reasoning_tokens,
877
- f_total_tokens = ${tSessionUsage}.f_total_tokens + EXCLUDED.f_total_tokens
878
- `);
879
- }
880
- }
881
-
882
- // 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。
883
- async incrementSessionCounts(
884
- id: SessionId,
885
- buckets: readonly SessionCountBucket[],
886
- ): Promise<void> {
887
- if (buckets.length === 0) return;
888
- const tSessionCounts = this.tables["t_session_counts"];
889
- for (const bucket of buckets) {
890
- await this.db.execute(sql`
891
- INSERT INTO ${tSessionCounts} (
892
- f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
893
- VALUES (${id}, ${bucket.day}, ${bucket.turns}, ${bucket.steps},
894
- ${bucket.userInputs}, ${bucket.toolCalls})
895
- ON CONFLICT (f_session_id, f_day) DO UPDATE SET
896
- f_turns = ${tSessionCounts}.f_turns + EXCLUDED.f_turns,
897
- f_steps = ${tSessionCounts}.f_steps + EXCLUDED.f_steps,
898
- f_user_inputs = ${tSessionCounts}.f_user_inputs + EXCLUDED.f_user_inputs,
899
- f_tool_calls = ${tSessionCounts}.f_tool_calls + EXCLUDED.f_tool_calls
900
- `);
901
- }
902
- }
903
-
904
- // 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。
905
- async rebuildSessionStats(id: SessionId): Promise<void> {
906
- const tSessionUsage = this.tables["t_session_usage"];
907
- const tSessionCounts = this.tables["t_session_counts"];
908
- const tSessionEvents = this.tables["t_session_events"];
909
- const tEventUsage = this.tables["t_event_usage"];
910
- const tEvents = this.tables["t_events"];
911
- await this.db.execute(sql`DELETE FROM ${tSessionUsage} WHERE f_session_id = ${id}`);
912
- await this.db.execute(sql`
913
- INSERT INTO ${tSessionUsage} (
914
- f_session_id, f_day, f_provider, f_model,
915
- f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
916
- SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
917
- sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
918
- sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
919
- FROM ${tSessionEvents} b
920
- JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
921
- WHERE b.f_session_id = ${id}
922
- GROUP BY 1, 2, 3, 4
923
- `);
924
- await this.db.execute(sql`DELETE FROM ${tSessionCounts} WHERE f_session_id = ${id}`);
925
- await this.db.execute(sql`
926
- INSERT INTO ${tSessionCounts} (
927
- f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
928
- SELECT b.f_session_id,
929
- to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
930
- sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
931
- sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
932
- sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
933
- sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
934
- FROM ${tSessionEvents} b
935
- JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
936
- WHERE b.f_session_id = ${id} AND e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
937
- GROUP BY 1, 2
938
- `);
939
- }
940
-
941
- // 重算这些用量行的引用标记(会话删除 / 日志重写之后;撤回与 fork 都不该动统计)。
942
- async refreshEventUsageFlags(eventIds: readonly string[]): Promise<void> {
943
- const tEventUsage = this.tables["t_event_usage"];
944
- const tSessionEvents = this.tables["t_session_events"];
945
- const tSessions = this.tables["t_sessions"];
946
- for (let at = 0; at < eventIds.length; at += REFRESH_FLAG_CHUNK) {
947
- const chunk = eventIds.slice(at, at + REFRESH_FLAG_CHUNK);
948
- await this.db.execute(sql`
949
- UPDATE ${tEventUsage} SET
950
- f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
951
- WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
952
- f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
953
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
954
- WHERE sb.f_event_id = ${tEventUsage}.f_event_id
955
- AND ss.f_origin = 'subagent'))::int
956
- WHERE f_event_id IN (${sql.join(
957
- chunk.map((id) => sql`${id}`),
958
- sql`, `,
959
- )})
960
- `);
961
- }
962
- }
963
-
964
- async deleteSessionStats(id: SessionId): Promise<void> {
965
- await this.db.execute(
966
- sql`DELETE FROM ${this.tables["t_session_usage"]} WHERE f_session_id = ${id}`,
967
- );
968
- await this.db.execute(
969
- sql`DELETE FROM ${this.tables["t_session_counts"]} WHERE f_session_id = ${id}`,
970
- );
971
- }
972
-
973
- private async getPrevBridge(
974
- exec: PgAsyncDatabase<NodePgQueryResultHKT>,
975
- id: SessionId,
976
- sequence: number,
977
- ): Promise<{ fEventId: string; fSequence: number } | undefined> {
978
- return (
979
- await exec
980
- .select({
981
- fEventId: this.tables["t_session_events"].fEventId,
982
- fSequence: this.tables["t_session_events"].fSequence,
983
- })
984
- .from(this.tables["t_session_events"])
985
- .where(
986
- and(
987
- eq(this.tables["t_session_events"].fSessionId, id),
988
- eq(this.tables["t_session_events"].fSequence, sequence),
989
- ),
990
- )
991
- .execute()
992
- )[0] as { fEventId: string; fSequence: number } | undefined;
993
- }
994
-
995
- private eventRows(exec: PgAsyncDatabase<NodePgQueryResultHKT>) {
996
- return exec
997
- .select({
998
- fEventId: this.tables["t_session_events"].fEventId,
999
- fSequence: this.tables["t_session_events"].fSequence,
1000
- fType: this.tables["t_events"].fType,
1001
- fKind: this.tables["t_events"].fKind,
1002
- fRole: this.tables["t_events"].fRole,
1003
- fName: this.tables["t_events"].fName,
1004
- fActionId: this.tables["t_events"].fActionId,
1005
- fCreatedAt: this.tables["t_events"].fCreatedAt,
1006
- fData: this.tables["t_events"].fData,
1007
- fSurfaceOp: this.tables["t_session_events"].fSurfaceOp,
1008
- })
1009
- .from(this.tables["t_session_events"])
1010
- .innerJoin(
1011
- this.tables["t_events"],
1012
- eq(this.tables["t_session_events"].fEventId, this.tables["t_events"].fEventId),
1013
- );
1014
- }
1015
- }