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

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 (45) hide show
  1. package/README.md +31 -12
  2. package/dist/artifact.d.mts +2 -101
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{branch-BjUR6wfe.mjs → branch-D4nVzQQM.mjs} +3 -3
  5. package/dist/deletion.d.mts +1 -1
  6. package/dist/deletion.mjs +1 -1
  7. package/dist/{dist-vIVO6bA-.mjs → dist-DHer1cql.mjs} +14 -7
  8. package/dist/export.d.mts +8 -0
  9. package/dist/export.mjs +76 -0
  10. package/dist/gc.d.mts +8 -0
  11. package/dist/gc.mjs +51 -0
  12. package/dist/import.d.mts +1 -1
  13. package/dist/import.mjs +1 -1
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +3 -3
  16. package/dist/{log-qCNkDjh5.mjs → log-BWVFYAh5.mjs} +39 -12
  17. package/dist/{backend-E0bmTXpz.d.mts → log-CUjKQbOR.d.mts} +222 -39
  18. package/dist/{sqlite-zcXVyMxr.mjs → sqlite-SCiSnwaz.mjs} +321 -259
  19. package/dist/{src-B9X8BKyh.mjs → src-BZ1tffd5.mjs} +342 -310
  20. package/dist/storage.d.mts +20 -7
  21. package/dist/storage.mjs +2 -2
  22. package/dist/testing.d.mts +1 -1
  23. package/dist/testing.mjs +48 -29
  24. package/dist/usage.d.mts +2 -0
  25. package/dist/usage.mjs +152 -0
  26. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +48 -0
  27. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +48 -0
  28. package/package.json +25 -30
  29. package/src/backend.ts +41 -17
  30. package/src/branch.ts +4 -4
  31. package/src/drizzle/postgres-v3.ts +2 -0
  32. package/src/drizzle/sqlite-v3.ts +2 -0
  33. package/src/entities/v3/event-usage.ts +8 -1
  34. package/src/entities/v3/index.ts +8 -0
  35. package/src/entities/v3/session-counts.ts +23 -0
  36. package/src/entities/v3/session-usage.ts +29 -0
  37. package/src/index.ts +230 -88
  38. package/src/log.ts +58 -17
  39. package/src/postgres.ts +248 -149
  40. package/src/rows.ts +2 -1
  41. package/src/schema.ts +4 -0
  42. package/src/sqlite.ts +263 -124
  43. package/src/storage-takeover/repository.ts +1 -4
  44. package/src/usage.ts +10 -6
  45. package/cordis.patch.yml +0 -13
package/src/sqlite.ts CHANGED
@@ -11,18 +11,20 @@ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistenc
11
11
  import {
12
12
  type Backend,
13
13
  type BackendTx,
14
- type EventCountBucket,
15
14
  type EventInsert,
16
15
  type EventRow,
16
+ type SessionCountBucket,
17
17
  type SessionListRowRecord,
18
18
  type SessionListRowsPage,
19
19
  type SessionListRowsQuery,
20
20
  type SessionRow,
21
+ type SessionUsageBucket,
21
22
  } from "./backend.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 {
24
26
  COUNTED_EVENT_TYPES,
25
- addActivityCount,
27
+ addActivityTotals,
26
28
  addTokenTotals,
27
29
  addTotals,
28
30
  emptyTotals,
@@ -63,12 +65,6 @@ interface RawBucketRow {
63
65
  total_tokens: number | null;
64
66
  }
65
67
 
66
- /** 活动计数行(派生表按事件类型存,读的时候折成四项)。 */
67
- interface RawCountRow {
68
- f_type: string;
69
- n: number | null;
70
- }
71
-
72
68
  /** 「是否子代理」两组的 token 合计行。 */
73
69
  interface RawScopeRow extends RawBucketRow {}
74
70
 
@@ -79,53 +75,69 @@ interface RawSessionTokenRow extends RawBucketRow {
79
75
  archived: number;
80
76
  }
81
77
 
78
+ /** `t_session_counts` 的原始行(四项计数,列名是 SQL 别名)。 */
79
+ interface RawCountRow {
80
+ turns: number | null;
81
+ steps: number | null;
82
+ user_inputs: number | null;
83
+ tool_calls: number | null;
84
+ }
85
+
86
+ /** 按会话的活动计数行。 */
82
87
  interface RawSessionCountRow extends RawCountRow {
83
88
  session_id: string;
84
89
  }
85
90
 
86
- /** 活动计数的派生表:可销毁重建(不在 drizzle schema / 迁移里,建表幂等)。 */
87
- function ensureEventCountsTable(db: DatabaseSync): void {
88
- db.exec(`CREATE TABLE IF NOT EXISTS t_event_counts (
89
- f_id INTEGER PRIMARY KEY AUTOINCREMENT,
90
- f_session_id TEXT NOT NULL,
91
- f_day TEXT NOT NULL,
92
- f_type TEXT NOT NULL,
93
- f_count INTEGER NOT NULL DEFAULT 0
94
- );
95
- CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type
96
- ON t_event_counts(f_session_id, f_day, f_type);
97
- CREATE INDEX IF NOT EXISTS idx_event_counts_day ON t_event_counts(f_day);`);
91
+ /** 活动总览行:是否子代理 + 四项计数。 */
92
+ interface RawActivityScopeRow extends RawCountRow {
93
+ subagent: number | null;
98
94
  }
99
95
 
100
- /** 一次性回填活动计数(表为空时从事件表重算;幂等)。 */
101
- function backfillEventCounts(db: DatabaseSync): void {
102
- const pending = db.prepare("SELECT count(*) AS n FROM t_event_counts").get() as { n: number };
103
- if (pending.n > 0) return;
104
- db.exec(`INSERT OR IGNORE INTO t_event_counts (f_session_id, f_day, f_type, f_count)
105
- SELECT b.f_session_id,
106
- date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
107
- e.f_type,
108
- count(*)
109
- FROM t_session_events b
110
- JOIN t_events e ON e.f_event_id = b.f_event_id
111
- WHERE e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
112
- GROUP BY 1, 2, 3`);
96
+ /** 用量行的「被引用」物化表达式(`%EVENT%` 换成事件 id 列)。 */
97
+ const EVENT_REFERENCED_SQL = `EXISTS (SELECT 1 FROM t_session_events rb
98
+ WHERE rb.f_event_id = %EVENT%)`;
99
+
100
+ /** 用量行的「被 subagent 会话引用」物化表达式(`%EVENT%` 换成事件 id 列)。 */
101
+ const EVENT_SUBAGENT_SQL = `EXISTS (SELECT 1 FROM t_session_events sb
102
+ JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
103
+ WHERE sb.f_event_id = %EVENT% AND ss.f_origin = 'subagent')`;
104
+
105
+ /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
106
+ const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
107
+
108
+ function withEvent(expression: string, column: string): string {
109
+ return expression.replace("%EVENT%", column);
113
110
  }
114
111
 
115
112
  /**
116
- * 一次性回填:旧库的用量从未写过 `t_event_usage`,建表后按事件行补一次(幂等:表非空即跳过)。
113
+ * 派生统计表的回填:任一表为空即按事件表全量重算(幂等;迁移删表重建后、手动清空后都靠它自愈)。
114
+ * 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
117
115
  */
116
+ function backfillUsageTables(db: DatabaseSync): void {
117
+ backfillEventUsage(db);
118
+ backfillSessionUsage(db);
119
+ backfillSessionCounts(db);
120
+ }
121
+
122
+ function tableIsEmpty(db: DatabaseSync, table: string): boolean {
123
+ const row = db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number };
124
+ return row.n === 0;
125
+ }
126
+
127
+ /** 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。 */
118
128
  function backfillEventUsage(db: DatabaseSync): void {
119
- const pending = db.prepare("SELECT count(*) AS n FROM t_event_usage").get() as { n: number };
120
- if (pending.n > 0) return;
129
+ if (!tableIsEmpty(db, "t_event_usage")) return;
121
130
  db.exec(`INSERT OR IGNORE INTO t_event_usage (
122
- f_event_id, f_created_at, f_provider, f_model,
131
+ f_event_id, f_created_at, f_day, f_provider, f_model, f_referenced, f_subagent,
123
132
  f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
124
133
  SELECT e.f_event_id, e.f_created_at,
134
+ date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
125
135
  coalesce(json_extract(e.f_data, '$.data.message.source.provider'),
126
136
  json_extract(e.f_data, '$.message.source.provider')),
127
137
  coalesce(json_extract(e.f_data, '$.data.message.source.model'),
128
138
  json_extract(e.f_data, '$.message.source.model')),
139
+ ${withEvent(EVENT_REFERENCED_SQL, "e.f_event_id")},
140
+ ${withEvent(EVENT_SUBAGENT_SQL, "e.f_event_id")},
129
141
  coalesce(json_extract(e.f_data, '$.data.usage.inputTokens'),
130
142
  json_extract(e.f_data, '$.usage.inputTokens'), 0),
131
143
  coalesce(json_extract(e.f_data, '$.data.usage.outputTokens'),
@@ -142,9 +154,41 @@ function backfillEventUsage(db: DatabaseSync): void {
142
154
  OR json_extract(e.f_data, '$.usage') IS NOT NULL)`);
143
155
  }
144
156
 
157
+ /** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
158
+ function backfillSessionUsage(db: DatabaseSync): void {
159
+ if (!tableIsEmpty(db, "t_session_usage")) return;
160
+ db.exec(`INSERT OR IGNORE INTO t_session_usage (
161
+ f_session_id, f_day, f_provider, f_model,
162
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
163
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
164
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
165
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
166
+ FROM t_session_events b
167
+ JOIN t_event_usage u ON u.f_event_id = b.f_event_id
168
+ GROUP BY b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, '')`);
169
+ }
170
+
171
+ /** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
172
+ function backfillSessionCounts(db: DatabaseSync): void {
173
+ if (!tableIsEmpty(db, "t_session_counts")) return;
174
+ db.exec(`INSERT OR IGNORE INTO t_session_counts (
175
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
176
+ SELECT b.f_session_id,
177
+ date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
178
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
179
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
180
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
181
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
182
+ FROM t_session_events b
183
+ JOIN t_events e ON e.f_event_id = b.f_event_id
184
+ WHERE e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
185
+ GROUP BY b.f_session_id, 2`);
186
+ }
187
+
145
188
  function sessionListRowOf(row: Record<string, unknown>): SessionListRowRecord {
146
189
  return {
147
- sessionId: typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
190
+ sessionId:
191
+ typeof row["session_id"] === "string" ? row["session_id"] : String(row["session_id"]),
148
192
  title: typeof row["title"] === "string" ? row["title"] : null,
149
193
  origin: typeof row["origin"] === "string" ? row["origin"] : null,
150
194
  cwd: typeof row["cwd"] === "string" ? row["cwd"] : null,
@@ -173,25 +217,31 @@ function tokenTotalsOf(row: {
173
217
  };
174
218
  }
175
219
 
220
+ /** `t_session_counts` 的原始行(列名是 SQL 别名)折成四项活动计数。 */
221
+ function activityTotalsOf(row: {
222
+ turns: number | null;
223
+ steps: number | null;
224
+ user_inputs: number | null;
225
+ tool_calls: number | null;
226
+ }): UsageActivityTotals {
227
+ return {
228
+ turns: row.turns ?? 0,
229
+ steps: row.steps ?? 0,
230
+ userInputs: row.user_inputs ?? 0,
231
+ toolCalls: row.tool_calls ?? 0,
232
+ };
233
+ }
234
+
176
235
  /** 一行的整套指标:token 用量 + 活动计数(轮次 / 步骤 / 用户输入 / 工具调用)。 */
177
236
  function emptyActivity(): UsageActivityTotals {
178
237
  return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
179
238
  }
180
239
 
181
- /** 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致。 */
240
+ /** 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致(读汇总表,几百行)。 */
182
241
  function hasUsageSession(column: string): string {
183
- return `EXISTS (SELECT 1 FROM t_session_events ub
184
- JOIN t_event_usage uu ON uu.f_event_id = ub.f_event_id
185
- WHERE ub.f_session_id = ${column})`;
242
+ return `EXISTS (SELECT 1 FROM t_session_usage ub WHERE ub.f_session_id = ${column})`;
186
243
  }
187
244
 
188
- const SUBAGENT_OF_EVENT = `EXISTS (SELECT 1 FROM t_session_events sb
189
- JOIN t_sessions ss ON ss.f_session_id = sb.f_session_id
190
- WHERE sb.f_event_id = %EVENT% AND ss.f_origin = 'subagent')`;
191
-
192
- /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
193
- const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
194
-
195
245
  const sqliteMigrationsDir = fileURLToPath(new URL("../drizzle/sqlite/", import.meta.url));
196
246
 
197
247
  const sqliteTxQueues = new Map<string, Promise<void>>();
@@ -236,9 +286,7 @@ export async function openDatabase(
236
286
  await baselineV2(db);
237
287
  }
238
288
  migrate(dbx, { migrationsFolder: sqliteMigrationsDir });
239
- ensureEventCountsTable(db);
240
- backfillEventCounts(db);
241
- backfillEventUsage(db);
289
+ backfillUsageTables(db);
242
290
 
243
291
  dbx
244
292
  .insert(tPersistenceState)
@@ -509,7 +557,7 @@ export class SqliteBackend implements Backend {
509
557
  getHead: (id) => this.getHead(id),
510
558
  getSeedLength: (id) => this.getSeedLength(id),
511
559
  updateSeedLength: (id, seedLength) => this.updateSeedLength(id, seedLength),
512
- insertEvents: (events) => this.insertEvents(events),
560
+ insertEvents: (events, usageRows) => this.insertEvents(events, usageRows),
513
561
  insertBridges: (rows) => this.insertBridges(rows),
514
562
  updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
515
563
  bumpRevision: (id, lastEventAt) => this.bumpRevision(id, lastEventAt),
@@ -565,7 +613,10 @@ export class SqliteBackend implements Backend {
565
613
 
566
614
  private static readonly INSERT_BATCH_ROWS = 1000;
567
615
 
568
- private async insertEvents(events: EventInsert[]): Promise<void> {
616
+ private async insertEvents(
617
+ events: EventInsert[],
618
+ usageRows: readonly EventUsageRow[],
619
+ ): Promise<void> {
569
620
  if (events.length === 0) return;
570
621
  for (let i = 0; i < events.length; i += SqliteBackend.INSERT_BATCH_ROWS) {
571
622
  this.db
@@ -573,13 +624,14 @@ export class SqliteBackend implements Backend {
573
624
  .values(events.slice(i, i + SqliteBackend.INSERT_BATCH_ROWS).map((event) => ({ ...event })))
574
625
  .run();
575
626
  }
576
- // 用量顺带落 t_event_usage:统计不再逐行解析事件 JSON(重复事件行忽略,fork 复用不重复记)。
577
- const usage = events.flatMap((event) => {
578
- const row = usageRowOf(event);
579
- return row === undefined ? [] : [row];
580
- });
581
- if (usage.length > 0) {
582
- this.db.insert(tEventUsage).values(usage).onConflictDoNothing().run();
627
+ // 用量行顺带落 t_event_usage(本地日与引用标记已由写路径物化):统计不再解析事件 JSON、
628
+ // 也不回连事件表(重复事件行忽略,fork 复用不重复记)。
629
+ if (usageRows.length > 0) {
630
+ this.db
631
+ .insert(tEventUsage)
632
+ .values(usageRows.map((row) => ({ ...row })))
633
+ .onConflictDoNothing()
634
+ .run();
583
635
  }
584
636
  }
585
637
 
@@ -760,57 +812,64 @@ export class SqliteBackend implements Backend {
760
812
  };
761
813
  }
762
814
 
815
+ /**
816
+ * 用量聚合:只读三张派生统计表——事件级用量行给去重口径的总量与「天 × 模型」桶,
817
+ * `t_session_usage` / `t_session_counts` 给按会话的行(含 fork 继承前缀)。
818
+ * 归属、本地日与「是否被引用」都是物化列,这里不再现算、也不回连事件表。
819
+ * 事件级按毫秒时间戳过滤、汇总表按本地日过滤——`sinceMs` 对齐到本地零点时两者等价。
820
+ */
763
821
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
764
- const usageSince = sinceMs === undefined ? "" : " AND u.f_created_at >= ?";
822
+ const usageSince = sinceMs === undefined ? "" : " AND f_created_at >= ?";
765
823
  const params = sinceMs === undefined ? [] : [sinceMs];
766
- // 活动计数走派生表的「本地日」列(与桶的 localtime 口径一致)。
767
- const daySince = sinceMs === undefined ? "" : " AND c.f_day >= ?";
824
+ const daySince = sinceMs === undefined ? "" : " AND %COL% >= ?";
768
825
  const dayParams = sinceMs === undefined ? [] : [localDayKey(sinceMs)];
769
- const subagentOfUsage = SUBAGENT_OF_EVENT.replace("%EVENT%", "u.f_event_id");
826
+ const dayFilter = (column: string): string => daySince.replace("%COL%", column);
770
827
 
771
828
  const buckets = this.db.$client
772
829
  .prepare(
773
- `SELECT date(u.f_created_at / 1000, 'unixepoch', 'localtime') AS day,
774
- u.f_provider AS provider,
775
- u.f_model AS model,
776
- ${subagentOfUsage} AS subagent,
777
- sum(u.f_input_tokens) AS input_tokens,
778
- sum(u.f_output_tokens) AS output_tokens,
779
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
780
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
781
- sum(u.f_total_tokens) AS total_tokens
782
- FROM t_event_usage u
783
- WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
784
- GROUP BY day, provider, model, subagent`,
830
+ `SELECT f_day AS day,
831
+ f_provider AS provider,
832
+ f_model AS model,
833
+ f_subagent AS subagent,
834
+ sum(f_input_tokens) AS input_tokens,
835
+ sum(f_output_tokens) AS output_tokens,
836
+ sum(f_cache_read_tokens) AS cache_read_tokens,
837
+ sum(f_reasoning_tokens) AS reasoning_tokens,
838
+ sum(f_total_tokens) AS total_tokens
839
+ FROM t_event_usage
840
+ WHERE f_referenced = 1${usageSince}
841
+ GROUP BY f_day, f_provider, f_model, f_subagent`,
785
842
  )
786
843
  .all(...params) as unknown as RawBucketRow[];
787
844
  const tokenScope = this.db.$client
788
845
  .prepare(
789
- `SELECT ${subagentOfUsage} AS subagent,
790
- sum(u.f_input_tokens) AS input_tokens,
791
- sum(u.f_output_tokens) AS output_tokens,
792
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
793
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
794
- sum(u.f_total_tokens) AS total_tokens
795
- FROM t_event_usage u
796
- WHERE EXISTS (SELECT 1 FROM t_session_events rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
797
- GROUP BY subagent`,
846
+ `SELECT f_subagent AS subagent,
847
+ sum(f_input_tokens) AS input_tokens,
848
+ sum(f_output_tokens) AS output_tokens,
849
+ sum(f_cache_read_tokens) AS cache_read_tokens,
850
+ sum(f_reasoning_tokens) AS reasoning_tokens,
851
+ sum(f_total_tokens) AS total_tokens
852
+ FROM t_event_usage
853
+ WHERE f_referenced = 1${usageSince}
854
+ GROUP BY f_subagent`,
798
855
  )
799
856
  .all(...params) as unknown as RawScopeRow[];
800
857
  const activityScope = this.db.$client
801
858
  .prepare(
802
859
  `SELECT (s.f_origin = 'subagent') AS subagent,
803
- c.f_type AS f_type,
804
- sum(c.f_count) AS n
805
- FROM t_event_counts c
860
+ sum(c.f_turns) AS turns,
861
+ sum(c.f_steps) AS steps,
862
+ sum(c.f_user_inputs) AS user_inputs,
863
+ sum(c.f_tool_calls) AS tool_calls
864
+ FROM t_session_counts c
806
865
  JOIN t_sessions s ON s.f_session_id = c.f_session_id
807
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
808
- GROUP BY subagent, c.f_type`,
866
+ WHERE ${hasUsageSession("c.f_session_id")}${dayFilter("c.f_day")}
867
+ GROUP BY subagent`,
809
868
  )
810
- .all(...dayParams) as unknown as Array<RawCountRow & { subagent: number }>;
869
+ .all(...dayParams) as unknown as RawActivityScopeRow[];
811
870
  const tokenSessions = this.db.$client
812
871
  .prepare(
813
- `SELECT b.f_session_id AS session_id,
872
+ `SELECT u.f_session_id AS session_id,
814
873
  s.f_title AS title,
815
874
  (s.f_origin = 'subagent') AS subagent,
816
875
  (s.f_archived_at IS NOT NULL) AS archived,
@@ -819,21 +878,22 @@ export class SqliteBackend implements Backend {
819
878
  sum(u.f_cache_read_tokens) AS cache_read_tokens,
820
879
  sum(u.f_reasoning_tokens) AS reasoning_tokens,
821
880
  sum(u.f_total_tokens) AS total_tokens
822
- FROM t_session_events b
823
- JOIN t_event_usage u ON u.f_event_id = b.f_event_id
824
- JOIN t_sessions s ON s.f_session_id = b.f_session_id
825
- WHERE 1 = 1${usageSince}
826
- GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
881
+ FROM t_session_usage u
882
+ JOIN t_sessions s ON s.f_session_id = u.f_session_id
883
+ WHERE 1 = 1${dayFilter("u.f_day")}
884
+ GROUP BY u.f_session_id, s.f_title, s.f_origin, s.f_archived_at`,
827
885
  )
828
- .all(...params) as unknown as RawSessionTokenRow[];
886
+ .all(...dayParams) as unknown as RawSessionTokenRow[];
829
887
  const activitySessions = this.db.$client
830
888
  .prepare(
831
- `SELECT c.f_session_id AS session_id,
832
- c.f_type AS f_type,
833
- sum(c.f_count) AS n
834
- FROM t_event_counts c
835
- WHERE ${hasUsageSession("c.f_session_id")}${daySince}
836
- GROUP BY c.f_session_id, c.f_type`,
889
+ `SELECT f_session_id AS session_id,
890
+ sum(f_turns) AS turns,
891
+ sum(f_steps) AS steps,
892
+ sum(f_user_inputs) AS user_inputs,
893
+ sum(f_tool_calls) AS tool_calls
894
+ FROM t_session_counts
895
+ WHERE 1 = 1${dayFilter("f_day")}
896
+ GROUP BY f_session_id`,
837
897
  )
838
898
  .all(...dayParams) as unknown as RawSessionCountRow[];
839
899
  const human = emptyTotals();
@@ -842,15 +902,13 @@ export class SqliteBackend implements Backend {
842
902
  addTokenTotals(row.subagent === 1 ? subagent : human, tokenTotalsOf(row));
843
903
  }
844
904
  for (const row of activityScope) {
845
- addActivityCount(row.subagent === 1 ? subagent : human, row.f_type, row.n ?? 0);
905
+ addActivityTotals(row.subagent === 1 ? subagent : human, activityTotalsOf(row));
846
906
  }
847
907
  const totals = addTotals(addTotals(emptyTotals(), human), subagent);
848
908
 
849
909
  const activityBySession = new Map<string, UsageActivityTotals>();
850
910
  for (const row of activitySessions) {
851
- const target = activityBySession.get(row.session_id) ?? emptyActivity();
852
- addActivityCount(target, row.f_type, row.n ?? 0);
853
- activityBySession.set(row.session_id, target);
911
+ activityBySession.set(row.session_id, activityTotalsOf(row));
854
912
  }
855
913
  return {
856
914
  totals,
@@ -874,36 +932,117 @@ export class SqliteBackend implements Backend {
874
932
  };
875
933
  }
876
934
 
877
- /** 活动计数旁路累加:派生表 `t_event_counts`,不在写事务里(失败可丢,表可销毁重建)。 */
878
- async incrementEventCounts(id: SessionId, buckets: readonly EventCountBucket[]): Promise<void> {
935
+ /** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
936
+ async incrementSessionUsage(
937
+ id: SessionId,
938
+ buckets: readonly SessionUsageBucket[],
939
+ ): Promise<void> {
879
940
  if (buckets.length === 0) return;
880
941
  const statement = this.db.$client.prepare(
881
- `INSERT INTO t_event_counts (f_session_id, f_day, f_type, f_count) VALUES (?, ?, ?, ?)
882
- ON CONFLICT(f_session_id, f_day, f_type) DO UPDATE SET f_count = f_count + excluded.f_count`,
942
+ `INSERT INTO t_session_usage (f_session_id, f_day, f_provider, f_model,
943
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
944
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
945
+ ON CONFLICT(f_session_id, f_day, f_provider, f_model) DO UPDATE SET
946
+ f_input_tokens = f_input_tokens + excluded.f_input_tokens,
947
+ f_output_tokens = f_output_tokens + excluded.f_output_tokens,
948
+ f_cache_read_tokens = f_cache_read_tokens + excluded.f_cache_read_tokens,
949
+ f_reasoning_tokens = f_reasoning_tokens + excluded.f_reasoning_tokens,
950
+ f_total_tokens = f_total_tokens + excluded.f_total_tokens`,
883
951
  );
884
- for (const bucket of buckets) statement.run(id, bucket.day, bucket.type, bucket.count);
952
+ for (const bucket of buckets) {
953
+ statement.run(
954
+ id,
955
+ bucket.day,
956
+ bucket.provider ?? "",
957
+ bucket.model ?? "",
958
+ bucket.inputTokens,
959
+ bucket.outputTokens,
960
+ bucket.cacheReadTokens,
961
+ bucket.reasoningTokens,
962
+ bucket.totalTokens,
963
+ );
964
+ }
885
965
  }
886
966
 
887
- /** 按会话重算活动计数(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
888
- async rebuildEventCounts(id: SessionId): Promise<void> {
889
- this.db.$client.prepare("DELETE FROM t_event_counts WHERE f_session_id = ?").run(id);
967
+ /** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
968
+ async incrementSessionCounts(
969
+ id: SessionId,
970
+ buckets: readonly SessionCountBucket[],
971
+ ): Promise<void> {
972
+ if (buckets.length === 0) return;
973
+ const statement = this.db.$client.prepare(
974
+ `INSERT INTO t_session_counts (f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
975
+ VALUES (?, ?, ?, ?, ?, ?)
976
+ ON CONFLICT(f_session_id, f_day) DO UPDATE SET
977
+ f_turns = f_turns + excluded.f_turns,
978
+ f_steps = f_steps + excluded.f_steps,
979
+ f_user_inputs = f_user_inputs + excluded.f_user_inputs,
980
+ f_tool_calls = f_tool_calls + excluded.f_tool_calls`,
981
+ );
982
+ for (const bucket of buckets) {
983
+ statement.run(
984
+ id,
985
+ bucket.day,
986
+ bucket.turns,
987
+ bucket.steps,
988
+ bucket.userInputs,
989
+ bucket.toolCalls,
990
+ );
991
+ }
992
+ }
993
+
994
+ /** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
995
+ async rebuildSessionStats(id: SessionId): Promise<void> {
996
+ this.db.$client.prepare("DELETE FROM t_session_usage WHERE f_session_id = ?").run(id);
890
997
  this.db.$client
891
998
  .prepare(
892
- `INSERT INTO t_event_counts (f_session_id, f_day, f_type, f_count)
999
+ `INSERT INTO t_session_usage (
1000
+ f_session_id, f_day, f_provider, f_model,
1001
+ f_input_tokens, f_output_tokens, f_cache_read_tokens, f_reasoning_tokens, f_total_tokens)
1002
+ SELECT b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, ''),
1003
+ sum(u.f_input_tokens), sum(u.f_output_tokens), sum(u.f_cache_read_tokens),
1004
+ sum(u.f_reasoning_tokens), sum(u.f_total_tokens)
1005
+ FROM t_session_events b
1006
+ JOIN t_event_usage u ON u.f_event_id = b.f_event_id
1007
+ WHERE b.f_session_id = ?
1008
+ GROUP BY b.f_session_id, u.f_day, coalesce(u.f_provider, ''), coalesce(u.f_model, '')`,
1009
+ )
1010
+ .run(id);
1011
+ this.db.$client.prepare("DELETE FROM t_session_counts WHERE f_session_id = ?").run(id);
1012
+ this.db.$client
1013
+ .prepare(
1014
+ `INSERT INTO t_session_counts (
1015
+ f_session_id, f_day, f_turns, f_steps, f_user_inputs, f_tool_calls)
893
1016
  SELECT b.f_session_id,
894
1017
  date(e.f_created_at / 1000, 'unixepoch', 'localtime'),
895
- e.f_type,
896
- count(*)
1018
+ sum(CASE WHEN e.f_type = 'turn/start' THEN 1 ELSE 0 END),
1019
+ sum(CASE WHEN e.f_type = 'step/start' THEN 1 ELSE 0 END),
1020
+ sum(CASE WHEN e.f_type = 'user/message' THEN 1 ELSE 0 END),
1021
+ sum(CASE WHEN e.f_type = 'tool/call' THEN 1 ELSE 0 END)
897
1022
  FROM t_session_events b
898
1023
  JOIN t_events e ON e.f_event_id = b.f_event_id
899
1024
  WHERE b.f_session_id = ? AND e.f_type IN (${COUNTED_EVENT_TYPE_SQL})
900
- GROUP BY 1, 2, 3`,
1025
+ GROUP BY b.f_session_id, 2`,
901
1026
  )
902
1027
  .run(id);
903
1028
  }
904
1029
 
905
- async deleteEventCounts(id: SessionId): Promise<void> {
906
- this.db.$client.prepare("DELETE FROM t_event_counts WHERE f_session_id = ?").run(id);
1030
+ /**
1031
+ * 全量重算用量行的引用标记:rewind 截断 / fork 复用 / 会话删除都会让「引用」跨会话变化,
1032
+ * 只按本会话判定不够。低频路径,一次全表 UPDATE(3 万行量级)。
1033
+ */
1034
+ async refreshEventUsageFlags(): Promise<void> {
1035
+ const eventId = "t_event_usage.f_event_id";
1036
+ this.db.$client.exec(
1037
+ `UPDATE t_event_usage SET
1038
+ f_referenced = ${withEvent(EVENT_REFERENCED_SQL, eventId)},
1039
+ f_subagent = ${withEvent(EVENT_SUBAGENT_SQL, eventId)}`,
1040
+ );
1041
+ }
1042
+
1043
+ async deleteSessionStats(id: SessionId): Promise<void> {
1044
+ this.db.$client.prepare("DELETE FROM t_session_usage WHERE f_session_id = ?").run(id);
1045
+ this.db.$client.prepare("DELETE FROM t_session_counts WHERE f_session_id = ?").run(id);
907
1046
  }
908
1047
 
909
1048
  async vacuum(): Promise<void> {
@@ -277,10 +277,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
277
277
  const pinned = state.pinnedSessionIds ?? [];
278
278
  await runQuery(
279
279
  pinned.length === 0
280
- ? db
281
- .update(tSessions)
282
- .set({ fPinnedSeq: null })
283
- .where(isNotNull(tSessions.fPinnedSeq))
280
+ ? db.update(tSessions).set({ fPinnedSeq: null }).where(isNotNull(tSessions.fPinnedSeq))
284
281
  : db
285
282
  .update(tSessions)
286
283
  .set({ fPinnedSeq: null })
package/src/usage.ts CHANGED
@@ -3,11 +3,9 @@ import type { SessionPersistenceRdb } from "./index.ts";
3
3
 
4
4
  export const SESSION_USAGE_PATH = "/api/session.usage";
5
5
 
6
- const DAY_MS = 24 * 60 * 60 * 1000;
7
-
8
6
  /**
9
7
  * 时间范围的语义键:`all` 不限;`day` / `week` 是**本地自然日 / 自然周**(周一起算);
10
- * `7d` / `30d` / `90d` 是最近 N 天(滚动窗口)。
8
+ * `7d` / `30d` / `90d` 是**最近 N 个自然日**(含今天)。
11
9
  */
12
10
  export type UsageRangeKey = "all" | "day" | "week" | "7d" | "30d" | "90d";
13
11
 
@@ -20,7 +18,9 @@ function localDayStart(now: number): Date {
20
18
  }
21
19
 
22
20
  /**
23
- * 范围起点(含),不限时为 undefined。自然日 / 自然周按 host 的本地时区算,周边界是周一 00:00。
21
+ * 范围起点(含),不限时为 undefined。**所有起点都对齐到 host 本地时区的零点**——`day` / `week` 是
22
+ * 今天 / 本周一的零点,`7d` / `30d` / `90d` 是「今天零点往前 N-1 天」(含今天共 N 个自然日)。
23
+ * 对齐的意义:事件级表按毫秒过滤、会话汇总表按本地日过滤,两者因此严格等价。
24
24
  * @param range - 语义键。
25
25
  * @param now - 当前时刻。
26
26
  * @returns 起点毫秒时间戳,或 undefined。
@@ -37,8 +37,12 @@ export function resolveUsageSince(range: UsageRangeKey, now: number): number | u
37
37
  start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
38
38
  return start.getTime();
39
39
  }
40
- default:
41
- return now - ROLLING_DAYS[range] * DAY_MS;
40
+ default: {
41
+ // 用日历日期减(不是减 N × 24h):跨夏令时切换时「N-1 天前的零点」才是本地零点。
42
+ const start = localDayStart(now);
43
+ start.setDate(start.getDate() - (ROLLING_DAYS[range] - 1));
44
+ return start.getTime();
45
+ }
42
46
  }
43
47
  }
44
48
 
package/cordis.patch.yml DELETED
@@ -1,13 +0,0 @@
1
- # @morlay/session-rdb bundle patch.
2
- #
3
- # 在 profile 的 dsh.profile.bundles 中列出本包后,本 patch 在 base 层之后
4
- # 生效,插入 RDB 持久化后端插件行(默认 SQLite,库文件放
5
- # $DSH_HOME/sessions/sessions.sqlite)。用户可在 profile 的 cordis.patch.yml 中
6
- # 按 id 覆盖 config(例如切到 PostgreSQL)。
7
-
8
- - insert:
9
- - id: session-rdb
10
- name: "@morlay/session-rdb"
11
- config:
12
- type: sqlite
13
- path: !!js dshHomePath('sessions', 'sessions.sqlite')