@morlay/session-rdb 0.0.21-alpha.7 → 0.0.21-alpha.8

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 (37) hide show
  1. package/README.md +17 -10
  2. package/dist/artifact.d.mts +2 -101
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{branch-BjUR6wfe.mjs → branch-7eOh_tbO.mjs} +3 -3
  5. package/dist/deletion.d.mts +1 -1
  6. package/dist/deletion.mjs +1 -1
  7. package/dist/import.d.mts +1 -1
  8. package/dist/import.mjs +1 -1
  9. package/dist/index.d.mts +1 -1
  10. package/dist/index.mjs +3 -3
  11. package/dist/{backend-E0bmTXpz.d.mts → log-BbleqD1M.d.mts} +171 -24
  12. package/dist/{log-qCNkDjh5.mjs → log-DTcvsMz7.mjs} +189 -12
  13. package/dist/{sqlite-zcXVyMxr.mjs → sqlite-ChM_sHAG.mjs} +320 -259
  14. package/dist/{src-B9X8BKyh.mjs → src-BWqePRA7.mjs} +290 -162
  15. package/dist/storage.d.mts +20 -7
  16. package/dist/storage.mjs +2 -2
  17. package/dist/testing.d.mts +1 -1
  18. package/dist/testing.mjs +1 -1
  19. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
  20. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
  21. package/package.json +20 -20
  22. package/src/backend.ts +41 -17
  23. package/src/branch.ts +4 -4
  24. package/src/drizzle/postgres-v3.ts +2 -0
  25. package/src/drizzle/sqlite-v3.ts +2 -0
  26. package/src/entities/v3/event-usage.ts +8 -1
  27. package/src/entities/v3/index.ts +8 -0
  28. package/src/entities/v3/session-counts.ts +23 -0
  29. package/src/entities/v3/session-usage.ts +29 -0
  30. package/src/index.ts +109 -43
  31. package/src/log.ts +58 -17
  32. package/src/postgres.ts +248 -149
  33. package/src/rows.ts +2 -1
  34. package/src/schema.ts +4 -0
  35. package/src/sqlite.ts +263 -124
  36. package/src/storage-takeover/repository.ts +1 -4
  37. package/src/usage.ts +10 -6
package/src/postgres.ts CHANGED
@@ -10,21 +10,23 @@ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistenc
10
10
  import {
11
11
  type Backend,
12
12
  type BackendTx,
13
- type EventCountBucket,
14
13
  type EventInsert,
15
14
  type EventRow,
15
+ type SessionCountBucket,
16
16
  type SessionListRowsPage,
17
17
  type SessionListRowsQuery,
18
18
  type SessionRow,
19
+ type SessionUsageBucket,
19
20
  } from "./backend.ts";
20
21
  import { toPostgresSchema } from "./adapters/index.ts";
21
22
  import { postgresTableDefs } from "./entities/index.ts";
22
- import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
23
+ import { sessionConflictRow, sessionInsertRow, titleOfEventData } from "./log.ts";
24
+ import type { EventUsageRow } from "./log.ts";
23
25
  import { createStorageRepository } from "./storage-takeover/repository.ts";
24
26
  import type { StorageRepository } from "./storage-takeover/types.ts";
25
27
  import {
26
28
  COUNTED_EVENT_TYPES,
27
- addActivityCount,
29
+ addActivityTotals,
28
30
  addTokenTotals,
29
31
  addTotals,
30
32
  emptyTotals,
@@ -76,6 +78,16 @@ function emptyActivity(): UsageActivityTotals {
76
78
  return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
77
79
  }
78
80
 
81
+ /** `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。 */
82
+ function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
83
+ return {
84
+ turns: numeric(row["turns"]),
85
+ steps: numeric(row["steps"]),
86
+ userInputs: numeric(row["user_inputs"]),
87
+ toolCalls: numeric(row["tool_calls"]),
88
+ };
89
+ }
90
+
79
91
  /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
80
92
  const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
81
93
 
@@ -164,9 +176,7 @@ export class PostgresBackend implements Backend {
164
176
  }
165
177
  }
166
178
  await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
167
- await this.ensureEventCountsTable();
168
- await this.backfillEventCounts();
169
- await this.backfillEventUsage();
179
+ await this.backfillUsageTables();
170
180
  const storeId = await this.db.transaction(async (tx) => {
171
181
  await tx
172
182
  .insert(this.tables["t_persistence_state"])
@@ -281,7 +291,7 @@ export class PostgresBackend implements Backend {
281
291
  getHead: (id) => this.getHead(tx, id),
282
292
  getSeedLength: (id) => this.getSeedLength(tx, id),
283
293
  updateSeedLength: (id, seedLength) => this.updateSeedLength(tx, id, seedLength),
284
- insertEvents: (events) => this.insertEvents(tx, events),
294
+ insertEvents: (events, usageRows) => this.insertEvents(tx, events, usageRows),
285
295
  insertBridges: (rows) => this.insertBridges(tx, rows),
286
296
  updateHead: (id, headEventId, headSequence) =>
287
297
  this.updateHead(tx, id, headEventId, headSequence),
@@ -373,6 +383,7 @@ export class PostgresBackend implements Backend {
373
383
  private async insertEvents(
374
384
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
375
385
  events: EventInsert[],
386
+ usageRows: readonly EventUsageRow[],
376
387
  ): Promise<void> {
377
388
  if (events.length === 0) return;
378
389
  for (let i = 0; i < events.length; i += PostgresBackend.INSERT_BATCH_ROWS) {
@@ -383,13 +394,18 @@ export class PostgresBackend implements Backend {
383
394
  )
384
395
  .execute();
385
396
  }
386
- // 用量顺带落 t_event_usage:统计不再逐行解析事件 JSON。
387
- const usage = events.flatMap((event) => {
388
- const row = usageRowOf(event);
389
- return row === undefined ? [] : [row];
390
- });
391
- if (usage.length > 0) {
392
- await exec.insert(this.tables["t_event_usage"]).values(usage).onConflictDoNothing().execute();
397
+ // 用量行顺带落 t_event_usage(本地日与引用标记已由写路径物化):统计不再解析事件 JSON、
398
+ // 也不回连事件表。
399
+ if (usageRows.length > 0) {
400
+ for (let i = 0; i < usageRows.length; i += PostgresBackend.INSERT_BATCH_ROWS) {
401
+ await exec
402
+ .insert(this.tables["t_event_usage"])
403
+ .values(
404
+ usageRows.slice(i, i + PostgresBackend.INSERT_BATCH_ROWS).map((row) => ({ ...row })),
405
+ )
406
+ .onConflictDoNothing()
407
+ .execute();
408
+ }
393
409
  }
394
410
  }
395
411
 
@@ -571,23 +587,46 @@ export class PostgresBackend implements Backend {
571
587
  await this.db.execute(sql`VACUUM ANALYZE`);
572
588
  }
573
589
 
574
- /** 一次性回填:旧库没写过 `t_event_usage`,建表后按事件行补一次(表非空即跳过)。 */
590
+ /** 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。 */
591
+ private async backfillUsageTables(): Promise<void> {
592
+ // 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
593
+ await this.backfillEventUsage();
594
+ await this.backfillSessionUsage();
595
+ await this.backfillSessionCounts();
596
+ }
597
+
598
+ private async tableIsEmpty(table: string): Promise<boolean> {
599
+ const existing = (await this.db.execute(
600
+ sql.raw(`SELECT count(*) AS n FROM ${table}`),
601
+ )) as unknown as { rows: Array<Record<string, unknown>> };
602
+ return Number(existing.rows[0]?.["n"] ?? 0) === 0;
603
+ }
604
+
605
+ /**
606
+ * 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
607
+ * 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
608
+ */
575
609
  private async backfillEventUsage(): Promise<void> {
610
+ if (!(await this.tableIsEmpty(this.qualifiedTable("t_event_usage")))) return;
576
611
  const tEventUsage = this.tables["t_event_usage"];
577
612
  const tEvents = this.tables["t_events"];
578
- const existing = (await this.db.execute(
579
- sql`SELECT count(*) AS n FROM ${tEventUsage}`,
580
- )) as unknown as { rows: Array<{ n: string }> };
581
- if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
613
+ const tSessionEvents = this.tables["t_session_events"];
614
+ const tSessions = this.tables["t_sessions"];
582
615
  await this.db.execute(sql`
583
616
  INSERT INTO ${tEventUsage} (
584
- f_event_id, f_created_at, f_provider, f_model,
617
+ f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
585
618
  f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
586
619
  SELECT e.f_event_id, e.f_created_at,
620
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
587
621
  coalesce(e.f_data::json #>> '{data,message,source,provider}',
588
622
  e.f_data::json #>> '{message,source,provider}'),
589
623
  coalesce(e.f_data::json #>> '{data,message,source,model}',
590
624
  e.f_data::json #>> '{message,source,model}'),
625
+ (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
626
+ WHERE rb.f_event_id = e.f_event_id))::int,
627
+ (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
628
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
629
+ WHERE sb.f_event_id = e.f_event_id AND ss.f_origin = 'subagent'))::int,
591
630
  coalesce((e.f_data::json #>> '{data,usage,inputTokens}')::integer,
592
631
  (e.f_data::json #>> '{usage,inputTokens}')::integer, 0),
593
632
  coalesce((e.f_data::json #>> '{data,usage,outputTokens}')::integer,
@@ -606,6 +645,49 @@ export class PostgresBackend implements Backend {
606
645
  `);
607
646
  }
608
647
 
648
+ /** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
649
+ private async backfillSessionUsage(): Promise<void> {
650
+ if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_usage")))) return;
651
+ const tSessionUsage = this.tables["t_session_usage"];
652
+ const tSessionEvents = this.tables["t_session_events"];
653
+ const tEventUsage = this.tables["t_event_usage"];
654
+ await this.db.execute(sql`
655
+ INSERT INTO ${tSessionUsage} (
656
+ f_session_id, f_day, f_provider, f_model,
657
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
658
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
659
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
660
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
661
+ FROM ${tSessionEvents} b
662
+ JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
663
+ GROUP BY 1, 2, 3, 4
664
+ ON CONFLICT DO NOTHING
665
+ `);
666
+ }
667
+
668
+ /** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
669
+ private async backfillSessionCounts(): Promise<void> {
670
+ if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_counts")))) return;
671
+ const tSessionCounts = this.tables["t_session_counts"];
672
+ const tSessionEvents = this.tables["t_session_events"];
673
+ const tEvents = this.tables["t_events"];
674
+ await this.db.execute(sql`
675
+ INSERT INTO ${tSessionCounts} (
676
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
677
+ SELECT b.f_session_id,
678
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
679
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
680
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
681
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
682
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
683
+ FROM ${tSessionEvents} b
684
+ JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
685
+ WHERE e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
686
+ GROUP BY 1, 2
687
+ ON CONFLICT DO NOTHING
688
+ `);
689
+ }
690
+
609
691
  /** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
610
692
  async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
611
693
  const needle = query.query?.trim().toLowerCase() ?? "";
@@ -644,7 +726,8 @@ export class PostgresBackend implements Backend {
644
726
  `)) as unknown as { rows: Array<Record<string, unknown>> };
645
727
  return {
646
728
  items: rows.rows.map((row) => ({
647
- sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
729
+ sessionId:
730
+ typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
648
731
  title: typeof row["title"] === "string" ? row["title"] : null,
649
732
  origin: typeof row["origin"] === "string" ? row["origin"] : null,
650
733
  cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
@@ -658,77 +741,73 @@ export class PostgresBackend implements Backend {
658
741
  };
659
742
  }
660
743
 
744
+ /**
745
+ * 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
746
+ * 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
747
+ */
661
748
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
662
- const usageSince = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
663
- // 活动计数走派生表的「本地日」列(与桶的本地日口径一致)。
664
- const daySince = sinceMs === undefined ? sql`` : sql` AND c.f_day >= ${localDayKey(sinceMs)}`;
749
+ const usageSince = sinceMs === undefined ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
750
+ const dayFilter = (column: string): ReturnType<typeof sql> =>
751
+ sinceMs === undefined ? sql`` : sql` AND ${sql.raw(column)} >= ${localDayKey(sinceMs)}`;
665
752
  const tEventUsage = this.tables["t_event_usage"];
666
- const tSessionEvents = this.tables["t_session_events"];
753
+ const tSessionUsage = this.tables["t_session_usage"];
754
+ const tSessionCounts = this.tables["t_session_counts"];
667
755
  const tSessions = this.tables["t_sessions"];
668
- const eventCountsTable = this.qualifiedTable("t_event_counts");
669
- const subagentOfUsage = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
670
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
671
- WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent')`;
672
- const subagentOfCounts = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
673
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
674
- WHERE sb.f_session_id = c.f_session_id AND ss.f_origin = 'subagent')`;
675
- // 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致。
676
- const hasUsageSession = (
677
- column: string,
678
- ): ReturnType<typeof sql> => sql`EXISTS (SELECT 1 FROM ${tSessionEvents} ub
679
- JOIN ${tEventUsage} uu ON uu.f_event_id = ub.f_event_id
680
- WHERE ub.f_session_id = ${sql.raw(column)})`;
681
- const tokenColumns = sql.raw(`sum(u.f_input_tokens) AS input_tokens,
682
- sum(u.f_output_tokens) AS output_tokens,
683
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
684
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
685
- sum(u.f_total_tokens) AS total_tokens`);
756
+ const tokenColumns = (alias: string): ReturnType<typeof sql> =>
757
+ sql.raw(`sum(${alias}.f_input_tokens) AS input_tokens,
758
+ sum(${alias}.f_output_tokens) AS output_tokens,
759
+ sum(${alias}.f_cache_read_tokens) AS cache_read_tokens,
760
+ sum(${alias}.f_reasoning_tokens) AS reasoning_tokens,
761
+ sum(${alias}.f_total_tokens) AS total_tokens`);
686
762
 
687
763
  const buckets = (await this.db.execute(sql`
688
- SELECT to_char(to_timestamp(u.f_created_at / 1000.0), 'YYYY-MM-DD') AS day,
689
- u.f_provider AS provider,
690
- u.f_model AS model,
691
- ${subagentOfUsage} AS subagent,
692
- ${tokenColumns}
693
- FROM ${tEventUsage} u
694
- WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
764
+ SELECT a.f_day AS day,
765
+ a.f_provider AS provider,
766
+ a.f_model AS model,
767
+ (a.f_subagent = 1) AS subagent,
768
+ ${tokenColumns("a")}
769
+ FROM ${tEventUsage} a
770
+ WHERE a.f_referenced = 1${usageSince}
695
771
  GROUP BY 1, 2, 3, 4
696
772
  `)) as unknown as { rows: Array<Record<string, unknown>> };
697
773
  const tokenScope = (await this.db.execute(sql`
698
- SELECT ${subagentOfUsage} AS subagent,
699
- ${tokenColumns}
700
- FROM ${tEventUsage} u
701
- WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
774
+ SELECT (a.f_subagent = 1) AS subagent,
775
+ ${tokenColumns("a")}
776
+ FROM ${tEventUsage} a
777
+ WHERE a.f_referenced = 1${usageSince}
702
778
  GROUP BY 1
703
779
  `)) as unknown as { rows: Array<Record<string, unknown>> };
704
780
  const activityScope = (await this.db.execute(sql`
705
- SELECT ${subagentOfCounts} AS subagent,
706
- c.f_type AS f_type,
707
- sum(c.f_count) AS n
708
- FROM ${sql.raw(eventCountsTable)} c
781
+ SELECT (s.f_origin = 'subagent') AS subagent,
782
+ sum(c.f_turns) AS turns,
783
+ sum(c.f_steps) AS steps,
784
+ sum(c.f_user_inputs) AS user_inputs,
785
+ sum(c.f_tool_calls) AS tool_calls
786
+ FROM ${tSessionCounts} c
709
787
  JOIN ${tSessions} s ON s.f_session_id = c.f_session_id
710
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
711
- GROUP BY 1, 2
788
+ WHERE EXISTS (SELECT 1 FROM ${tSessionUsage} ub WHERE ub.f_session_id = c.f_session_id)${dayFilter("c.f_day")}
789
+ GROUP BY 1
712
790
  `)) as unknown as { rows: Array<Record<string, unknown>> };
713
791
  const tokenSessions = (await this.db.execute(sql`
714
- SELECT b.f_session_id AS session_id,
792
+ SELECT u.f_session_id AS session_id,
715
793
  s.f_title AS title,
716
794
  (s.f_origin = 'subagent') AS subagent,
717
795
  (s.f_archived_at IS NOT NULL) AS archived,
718
- ${tokenColumns}
719
- FROM ${tSessionEvents} b
720
- JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
721
- JOIN ${tSessions} s ON s.f_session_id = b.f_session_id
722
- WHERE 1 = 1${usageSince}
723
- GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at
796
+ ${tokenColumns("u")}
797
+ FROM ${tSessionUsage} u
798
+ JOIN ${tSessions} s ON s.f_session_id = u.f_session_id
799
+ WHERE 1 = 1${dayFilter("u.f_day")}
800
+ GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at
724
801
  `)) as unknown as { rows: Array<Record<string, unknown>> };
725
802
  const activitySessions = (await this.db.execute(sql`
726
803
  SELECT c.f_session_id AS session_id,
727
- c.f_type AS f_type,
728
- sum(c.f_count) AS n
729
- FROM ${sql.raw(eventCountsTable)} c
730
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
731
- GROUP BY c.f_session_id, c.f_type
804
+ sum(c.f_turns) AS turns,
805
+ sum(c.f_steps) AS steps,
806
+ sum(c.f_user_inputs) AS user_inputs,
807
+ sum(c.f_tool_calls) AS tool_calls
808
+ FROM ${tSessionCounts} c
809
+ WHERE 1 = 1${dayFilter("c.f_day")}
810
+ GROUP BY c.f_session_id
732
811
  `)) as unknown as { rows: Array<Record<string, unknown>> };
733
812
  const human = emptyTotals();
734
813
  const subagent = emptyTotals();
@@ -736,20 +815,13 @@ export class PostgresBackend implements Backend {
736
815
  addTokenTotals(row["subagent"] === true ? subagent : human, tokenTotalsOf(row));
737
816
  }
738
817
  for (const row of activityScope.rows) {
739
- addActivityCount(
740
- row["subagent"] === true ? subagent : human,
741
- String(row["f_type"]),
742
- numeric(row["n"]),
743
- );
818
+ addActivityTotals(row["subagent"] === true ? subagent : human, activityTotalsOf(row));
744
819
  }
745
820
  const totals = addTotals(addTotals(emptyTotals(), human), subagent);
746
821
 
747
822
  const activityBySession = new Map<string, UsageActivityTotals>();
748
823
  for (const row of activitySessions.rows) {
749
- const key = String(row["session_id"]);
750
- const target = activityBySession.get(key) ?? emptyActivity();
751
- addActivityCount(target, String(row["f_type"]), numeric(row["n"]));
752
- activityBySession.set(key, target);
824
+ activityBySession.set(String(row["session_id"]), activityTotalsOf(row));
753
825
  }
754
826
  return {
755
827
  totals,
@@ -763,7 +835,8 @@ export class PostgresBackend implements Backend {
763
835
  ...tokenTotalsOf(row),
764
836
  })),
765
837
  sessions: tokenSessions.rows.map((row) => ({
766
- sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
838
+ sessionId:
839
+ typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
767
840
  title: text(row["title"]),
768
841
  subagent: row["subagent"] === true,
769
842
  archived: row["archived"] === true,
@@ -773,93 +846,119 @@ export class PostgresBackend implements Backend {
773
846
  };
774
847
  }
775
848
 
776
- /** 表名限定到配置的 schema(派生表不进 drizzle schema / 迁移,建表幂等)。 */
849
+ /** 表名限定到配置的 schema。 */
777
850
  private qualifiedTable(name: string): string {
778
851
  const schema = this.options.schema ?? "public";
779
852
  return schema === "public" ? name : `"${schema}".${name}`;
780
853
  }
781
854
 
782
- private async ensureEventCountsTable(): Promise<void> {
783
- const table = this.qualifiedTable("t_event_counts");
784
- await this.db.execute(
785
- sql.raw(`CREATE TABLE IF NOT EXISTS ${table} (
786
- f_id serial PRIMARY KEY,
787
- f_session_id text NOT NULL,
788
- f_day text NOT NULL,
789
- f_type text NOT NULL,
790
- f_count integer NOT NULL DEFAULT 0
791
- )`),
792
- );
793
- await this.db.execute(
794
- sql.raw(
795
- `CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type ON ${table}(f_session_id, f_day, f_type)`,
796
- ),
797
- );
798
- await this.db.execute(
799
- sql.raw(`CREATE INDEX IF NOT EXISTS idx_event_counts_day ON ${table}(f_day)`),
800
- );
801
- }
802
-
803
- /** 一次性回填活动计数(表为空时从事件表重算;幂等)。 */
804
- private async backfillEventCounts(): Promise<void> {
805
- const table = this.qualifiedTable("t_event_counts");
806
- const existing = (await this.db.execute(
807
- sql.raw(`SELECT count(*) AS n FROM ${table}`),
808
- )) as unknown as { rows: Array<Record<string, unknown>> };
809
- if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
810
- const tSessionEvents = this.tables["t_session_events"];
811
- const tEvents = this.tables["t_events"];
812
- await this.db.execute(sql`
813
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
814
- SELECT ${tSessionEvents}.f_session_id,
815
- to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
816
- ${tEvents}.f_type,
817
- count(*)
818
- FROM ${tSessionEvents}
819
- JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
820
- WHERE ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
821
- GROUP BY 1, 2, 3
822
- ON CONFLICT DO NOTHING
823
- `);
855
+ /** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
856
+ async incrementSessionUsage(
857
+ id: SessionId,
858
+ buckets: readonly SessionUsageBucket[],
859
+ ): Promise<void> {
860
+ if (buckets.length === 0) return;
861
+ const tSessionUsage = this.tables["t_session_usage"];
862
+ for (const bucket of buckets) {
863
+ await this.db.execute(sql`
864
+ INSERT INTO ${tSessionUsage} (
865
+ f_session_id, f_day, f_provider, f_model,
866
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
867
+ VALUES (${id}, ${bucket.day}, ${bucket.provider ?? ""}, ${bucket.model ?? ""},
868
+ ${bucket.inputTokens}, ${bucket.outputTokens}, ${bucket.cacheReadTokens},
869
+ ${bucket.reasoningTokens}, ${bucket.totalTokens})
870
+ ON CONFLICT (f_session_id, f_day, f_provider, f_model) DO UPDATE SET
871
+ f_input_tokens = ${tSessionUsage}.f_input_tokens + EXCLUDED.f_input_tokens,
872
+ f_output_tokens = ${tSessionUsage}.f_output_tokens + EXCLUDED.f_output_tokens,
873
+ f_cache_read_tokens = ${tSessionUsage}.f_cache_read_tokens + EXCLUDED.f_cache_read_tokens,
874
+ f_reasoning_tokens = ${tSessionUsage}.f_reasoning_tokens + EXCLUDED.f_reasoning_tokens,
875
+ f_total_tokens = ${tSessionUsage}.f_total_tokens + EXCLUDED.f_total_tokens
876
+ `);
877
+ }
824
878
  }
825
879
 
826
- /** 活动计数旁路累加:派生表,不在写事务里(失败可丢,表可销毁重建)。 */
827
- async incrementEventCounts(id: SessionId, buckets: readonly EventCountBucket[]): Promise<void> {
880
+ /** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
881
+ async incrementSessionCounts(
882
+ id: SessionId,
883
+ buckets: readonly SessionCountBucket[],
884
+ ): Promise<void> {
828
885
  if (buckets.length === 0) return;
829
- const table = this.qualifiedTable("t_event_counts");
886
+ const tSessionCounts = this.tables["t_session_counts"];
830
887
  for (const bucket of buckets) {
831
888
  await this.db.execute(sql`
832
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
833
- VALUES (${id}, ${bucket.day}, ${bucket.type}, ${bucket.count})
834
- ON CONFLICT (f_session_id, f_day, f_type)
835
- DO UPDATE SET f_count = ${sql.raw(`${table}.f_count`)} + EXCLUDED.f_count
889
+ INSERT INTO ${tSessionCounts} (
890
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
891
+ VALUES (${id}, ${bucket.day}, ${bucket.turns}, ${bucket.steps},
892
+ ${bucket.userInputs}, ${bucket.toolCalls})
893
+ ON CONFLICT (f_session_id, f_day) DO UPDATE SET
894
+ f_turns = ${tSessionCounts}.f_turns + EXCLUDED.f_turns,
895
+ f_steps = ${tSessionCounts}.f_steps + EXCLUDED.f_steps,
896
+ f_user_inputs = ${tSessionCounts}.f_user_inputs + EXCLUDED.f_user_inputs,
897
+ f_tool_calls = ${tSessionCounts}.f_tool_calls + EXCLUDED.f_tool_calls
836
898
  `);
837
899
  }
838
900
  }
839
901
 
840
- /** 按会话重算活动计数(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
841
- async rebuildEventCounts(id: SessionId): Promise<void> {
842
- const table = this.qualifiedTable("t_event_counts");
902
+ /** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
903
+ async rebuildSessionStats(id: SessionId): Promise<void> {
904
+ const tSessionUsage = this.tables["t_session_usage"];
905
+ const tSessionCounts = this.tables["t_session_counts"];
843
906
  const tSessionEvents = this.tables["t_session_events"];
907
+ const tEventUsage = this.tables["t_event_usage"];
844
908
  const tEvents = this.tables["t_events"];
845
- await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
909
+ await this.db.execute(sql`DELETE FROM ${tSessionUsage} WHERE f_session_id = ${id}`);
910
+ await this.db.execute(sql`
911
+ INSERT INTO ${tSessionUsage} (
912
+ f_session_id, f_day, f_provider, f_model,
913
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
914
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
915
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
916
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
917
+ FROM ${tSessionEvents} b
918
+ JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
919
+ WHERE b.f_session_id = ${id}
920
+ GROUP BY 1, 2, 3, 4
921
+ `);
922
+ await this.db.execute(sql`DELETE FROM ${tSessionCounts} WHERE f_session_id = ${id}`);
923
+ await this.db.execute(sql`
924
+ INSERT INTO ${tSessionCounts} (
925
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
926
+ SELECT b.f_session_id,
927
+ to_char(to_timestamp(e.f_created_at / 1000.0), 'YYYY-MM-DD'),
928
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
929
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
930
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
931
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
932
+ FROM ${tSessionEvents} b
933
+ JOIN ${tEvents} e ON e.f_event_id = b.f_event_id
934
+ WHERE b.f_session_id = ${id} AND e.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
935
+ GROUP BY 1, 2
936
+ `);
937
+ }
938
+
939
+ /** 全量重算用量行的引用标记(rewind / fork / 会话删除之后)。 */
940
+ async refreshEventUsageFlags(): Promise<void> {
941
+ const tEventUsage = this.tables["t_event_usage"];
942
+ const tSessionEvents = this.tables["t_session_events"];
943
+ const tSessions = this.tables["t_sessions"];
846
944
  await this.db.execute(sql`
847
- INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
848
- SELECT ${tSessionEvents}.f_session_id,
849
- to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
850
- ${tEvents}.f_type,
851
- count(*)
852
- FROM ${tSessionEvents}
853
- JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
854
- WHERE ${tSessionEvents}.f_session_id = ${id}
855
- AND ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
856
- GROUP BY 1, 2, 3
945
+ UPDATE ${tEventUsage} SET
946
+ f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
947
+ WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
948
+ f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
949
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
950
+ WHERE sb.f_event_id = ${tEventUsage}.f_event_id
951
+ AND ss.f_origin = 'subagent'))::int
857
952
  `);
858
953
  }
859
954
 
860
- async deleteEventCounts(id: SessionId): Promise<void> {
861
- const table = this.qualifiedTable("t_event_counts");
862
- await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
955
+ async deleteSessionStats(id: SessionId): Promise<void> {
956
+ await this.db.execute(
957
+ sql`DELETE FROM ${this.tables["t_session_usage"]} WHERE f_session_id = ${id}`,
958
+ );
959
+ await this.db.execute(
960
+ sql`DELETE FROM ${this.tables["t_session_counts"]} WHERE f_session_id = ${id}`,
961
+ );
863
962
  }
864
963
 
865
964
  private async getPrevBridge(
package/src/rows.ts CHANGED
@@ -92,7 +92,8 @@ export function registerSessionRows(ctx: Context, backend: Backend): void {
92
92
  return;
93
93
  }
94
94
  }
95
- const page = Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
95
+ const page =
96
+ Number.isSafeInteger(envelope.page) && (envelope.page ?? 0) > 0 ? envelope.page! : 1;
96
97
  const requested = envelope.pageSize;
97
98
  const pageSize =
98
99
  Number.isSafeInteger(requested) && (requested ?? 0) > 0
package/src/schema.ts CHANGED
@@ -32,6 +32,10 @@ export const tSessionProjcacheRows = sqliteTables["t_session_projcache_row"]!;
32
32
 
33
33
  export const tEventUsage = sqliteTables["t_event_usage"]!;
34
34
 
35
+ export const tSessionUsage = sqliteTables["t_session_usage"]!;
36
+
37
+ export const tSessionCounts = sqliteTables["t_session_counts"]!;
38
+
35
39
  export type { SessionRow } from "./backend.ts";
36
40
 
37
41
  export type { EventRow } from "./backend.ts";