@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/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
+ import type { Volatile } from "@deepseek-ai/cosmokit";
2
3
  import z from "@deepseek-ai/schemastery";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { Pool } from "pg";
@@ -41,11 +42,13 @@ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
41
42
  import {
42
43
  type Backend,
43
44
  type BackendTx,
44
- type EventCountBucket,
45
45
  type EventInsert,
46
+ type SessionCountBucket,
47
+ type SessionUsageBucket,
46
48
  } from "./backend.ts";
47
49
  import { WriteGuard } from "./write-guard.ts";
48
- import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
50
+ import { repairReadView, rowToMeta, scanRows, toJsonlArtifact, usageRowOf } from "./log.ts";
51
+ import type { EventUsageRow } from "./log.ts";
49
52
  import {
50
53
  DEFAULT_BUSY_TIMEOUT_MS,
51
54
  eventDimensions,
@@ -61,11 +64,16 @@ import { registerSessionDeletion } from "./deletion.ts";
61
64
  import { registerSessionExport } from "./export.ts";
62
65
  import { registerSessionGc } from "./gc.ts";
63
66
  import { registerSessionRows } from "./rows.ts";
64
- import { COUNTED_EVENT_TYPES, localDayKey, registerSessionUsage } from "./usage.ts";
67
+ import {
68
+ COUNTED_EVENT_TYPES,
69
+ addActivityCount,
70
+ localDayKey,
71
+ registerSessionUsage,
72
+ } from "./usage.ts";
65
73
  import type { UsageAggregate } from "./usage.ts";
66
74
  import { SessionQueryRdb } from "./session-query.ts";
67
75
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
68
- import { hasLegacyShape } from "./log.ts";
76
+ import { needsShapeAdoption, sealOwnEvents } from "./log.ts";
69
77
  import { installStorageTakeover } from "./storage-takeover/index.ts";
70
78
 
71
79
  /** 一批事件里的最大时间:写路径据此推进会话行的「最后活动时间」。 */
@@ -122,25 +130,21 @@ export interface ProjectionCacheOptions {
122
130
  export type Config =
123
131
  | {
124
132
  type: "sqlite";
125
-
126
133
  path: string;
127
-
128
- journalMode?: JournalMode;
129
-
130
- busyTimeout?: number;
131
-
132
- projectionCache?: ProjectionCacheOptions;
134
+ journalMode?: JournalMode | undefined;
135
+ busyTimeout?: number | undefined;
136
+ projectionCache?: ProjectionCacheOptions | undefined;
133
137
  }
134
138
  | {
135
139
  type: "postgres";
136
-
137
140
  connectionString: string;
138
-
139
- schema?: string;
140
-
141
- projectionCache?: ProjectionCacheOptions;
141
+ schema?: string | undefined;
142
+ projectionCache?: ProjectionCacheOptions | undefined;
142
143
  };
143
144
 
145
+ /** schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。 */
146
+ export type ResolvedConfig = Volatile<Config>;
147
+
144
148
  export type SessionDeletionErrorCode =
145
149
  | "SESSION_NOT_FOUND"
146
150
  | "SESSION_NOT_ARCHIVED"
@@ -484,40 +488,111 @@ class RdbSessionHandle implements SessionHandle {
484
488
  static readonly LIVE_WRITE_BATCH_MAX_DELAY_MS = 200;
485
489
  }
486
490
 
491
+ /**
492
+ * 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
493
+ * (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
494
+ */
495
+ const localized = (text: { zh: string; en: string }): string => text as unknown as string;
496
+
497
+ /** 投影 checkpoint 的写回节流:两个分支共用。 */
498
+ const projectionCacheSchema = z
499
+ .object({
500
+ writeEveryEvents: z
501
+ .natural()
502
+ .min(1)
503
+ .default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS)
504
+ .description(
505
+ localized({
506
+ zh: "每累计这么多事件写回一次投影 checkpoint。",
507
+ en: "Write the projection checkpoint back after this many events.",
508
+ }),
509
+ ),
510
+ writeIntervalMs: z
511
+ .natural()
512
+ .min(1)
513
+ .default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
514
+ .description(
515
+ localized({
516
+ zh: "距离上次写回超过这个间隔(毫秒)时也写一次。",
517
+ en: "Also write back when this many ms passed since the last one.",
518
+ }),
519
+ ),
520
+ })
521
+ .default({
522
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
523
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
524
+ });
525
+
487
526
  export class SessionPersistenceRdb extends SessionPersistence {
488
527
  static inject = ["sessions"];
489
528
 
490
- static Config: z<Config> = z.union([
491
- z.object({
492
- type: z.const("sqlite"),
493
- path: z.string().required(),
494
- journalMode: z.union(["wal", "delete", "truncate", "persist"] as const).default("wal"),
495
- busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
496
- projectionCache: z
497
- .object({
498
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
499
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
500
- })
501
- .default({
502
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
503
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
504
- }),
505
- }),
506
- z.object({
507
- type: z.const("postgres"),
508
- connectionString: z.string().required(),
509
- schema: z.string().default("public"),
510
- projectionCache: z
511
- .object({
512
- writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
513
- writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS),
514
- })
515
- .default({
516
- writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
517
- writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS,
518
- }),
519
- }),
520
- ]);
529
+ // 显式声明「输入 → 解析后」的形状:整段是 volatile,读它要过 `.get()`。union + volatile 的推断形状与手写类型
530
+ // 在可选性 / 索引签名上不完全对齐,所以这里断言一次,类型面由此固定。
531
+ static Config = z
532
+ .union([
533
+ z.object({
534
+ type: z.const("sqlite"),
535
+ path: z
536
+ .string()
537
+ .required()
538
+ .description(
539
+ localized({
540
+ zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
541
+ en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process.",
542
+ }),
543
+ ),
544
+ journalMode: z
545
+ .union(["wal", "delete", "truncate", "persist"] as const)
546
+ .default("wal")
547
+ .description(
548
+ localized({
549
+ zh: "SQLite 日志模式;默认 wal(并发读最好)。",
550
+ en: "SQLite journal mode; `wal` by default (best concurrent reads).",
551
+ }),
552
+ ),
553
+ busyTimeout: z
554
+ .number()
555
+ .step(1)
556
+ .min(0)
557
+ .default(DEFAULT_BUSY_TIMEOUT_MS)
558
+ .description(
559
+ localized({
560
+ zh: "写锁冲突时的忙等超时(毫秒)。",
561
+ en: "Busy timeout in ms while a write lock is contended.",
562
+ }),
563
+ ),
564
+ projectionCache: projectionCacheSchema,
565
+ }),
566
+ z.object({
567
+ type: z.const("postgres"),
568
+ connectionString: z
569
+ .string()
570
+ .required()
571
+ .description(
572
+ localized({
573
+ zh: "PostgreSQL 连接串(含库名与凭证)。",
574
+ en: "PostgreSQL connection string (database and credentials).",
575
+ }),
576
+ ),
577
+ schema: z
578
+ .string()
579
+ .default("public")
580
+ .description(
581
+ localized({
582
+ zh: "库内的 schema 名;默认 public。",
583
+ en: "Schema name inside the database; `public` by default.",
584
+ }),
585
+ ),
586
+ projectionCache: projectionCacheSchema,
587
+ }),
588
+ ])
589
+ .description(
590
+ localized({
591
+ zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
592
+ en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows).",
593
+ }),
594
+ )
595
+ .volatile() as unknown as z<Config, ResolvedConfig>;
521
596
 
522
597
  override readonly name = "session-rdb";
523
598
 
@@ -534,9 +609,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
534
609
  private readonly liveBuffers = new Map<SessionId, SessionEvent[]>();
535
610
  private readonly liveReady = new Map<SessionId, Promise<void>>();
536
611
 
612
+ /** 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。 */
613
+ readonly config: Config;
614
+
537
615
  constructor(
538
616
  ctx: Context,
539
- public config: Config,
617
+ config: ResolvedConfig,
540
618
 
541
619
  injectedBackend?: Backend,
542
620
  ) {
@@ -544,7 +622,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
544
622
 
545
623
  // 配置就是这一行的 config(cordis.patch.yml / profile patch,或设置页改它);上游 0.1.7 的 settings
546
624
  // 不再提供 namespace section 覆盖,旧 `settings.yaml` 的 `session-rdb` 段由上游一次性导进同 id 的行。
547
- this.backend = injectedBackend ?? createBackend(config);
625
+ //
626
+ // 整段 Config 标了 volatile(页面可编辑),而生效靠 **Loader 重挂这一行**(settings 写完经
627
+ // `reconcileProfilePatches` 让 Loader 重装受影响的 entry):重挂会构造新实例、拿到新快照,所以这里取一次
628
+ // 就够,运行期不必反复读引用。
629
+ this.config = config.get();
630
+ this.backend = injectedBackend ?? createBackend(this.config);
548
631
  this.ready = this.init();
549
632
  this.installLiveRouting(ctx);
550
633
 
@@ -754,7 +837,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
754
837
  this.liveFailures.delete(id);
755
838
  this.liveDropWarned.delete(id);
756
839
  this.reuseEventIds.delete(id);
757
- await this.deleteEventCounts(id);
840
+ await this.dropSessionStats(id);
758
841
  }
759
842
 
760
843
  /** GC 通道:回收已无桥接行引用的事件行(孤儿),返回删除行数。 */
@@ -778,7 +861,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
778
861
  this.liveFailures.delete(id);
779
862
  this.liveDropWarned.delete(id);
780
863
  this.reuseEventIds.delete(id);
781
- await this.deleteEventCounts(id);
864
+ await this.dropSessionStats(id);
782
865
  }
783
866
  return deleted;
784
867
  }
@@ -854,10 +937,14 @@ export class SessionPersistenceRdb extends SessionPersistence {
854
937
  await this.ready;
855
938
  if (events.length === 0) return false;
856
939
 
940
+ // 本仓库自造的事件类型落库前补上 `ignorable` 信封:不补的话上游那道校验连写入都拒。
941
+ sealOwnEvents(events);
857
942
  validateStoredEvents(meta, [...events]);
858
943
 
859
944
  const reuse = this.reuseEventIds.get(meta.id);
860
945
  let confirmedHead = -1;
946
+ // 本批用量行由 `appendEventTail` 折好;事务成功后旁路据此累加 token 汇总表。
947
+ let usageRows: EventUsageRow[] = [];
861
948
  await this.backend.transaction(async (tx) => {
862
949
  if (tornTruncateTo !== undefined) {
863
950
  await tx.deleteBridgeTail(meta.id, tornTruncateTo);
@@ -872,16 +959,17 @@ export class SessionPersistenceRdb extends SessionPersistence {
872
959
  const head = await tx.getHead(meta.id);
873
960
 
874
961
  this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
875
- const { headEventId, headSequence } = await appendEventTail(
962
+ const appended = await appendEventTail(
876
963
  tx,
877
964
  meta,
878
965
  events,
879
966
  { parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
880
967
  reuse,
881
968
  );
882
- await tx.updateHead(meta.id, headEventId, headSequence);
969
+ usageRows = appended.usageRows;
970
+ await tx.updateHead(meta.id, appended.headEventId, appended.headSequence);
883
971
  await tx.bumpRevision(meta.id, maxEventTime(events));
884
- confirmedHead = headSequence;
972
+ confirmedHead = appended.headSequence;
885
973
  });
886
974
  // 事务**成功之后**才丢掉复用映射:失败(并发写者校验、唯一键冲突等)时这份映射还没被消费,
887
975
  // 重试同一个 append 还要靠它复用父会话的事件行——提前删掉会让前缀事件行被重新插入一遍。
@@ -889,7 +977,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
889
977
 
890
978
  this.writeGuard.confirmHead(meta.id, confirmedHead);
891
979
  this.tracker.materialized(meta.id);
892
- await this.recordEventCounts(meta.id, events);
980
+ await this.recordSessionStats(meta.id, events, usageRows);
893
981
  return true;
894
982
  }
895
983
 
@@ -959,7 +1047,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
959
1047
  const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0);
960
1048
  // 版本号不完全可信:写路径曾把回退视图的结果以当前版本号落库,于是库里存在「v4 标记 + 旧代形状」
961
1049
  // 的行——在扫干净的边界之内再按内容判一次(撕裂尾部与坏行已经被 `scanRows` 丢掉了)。
962
- if (hasLegacyShape(preserved)) {
1050
+ if (needsShapeAdoption(preserved)) {
963
1051
  const adopted = adoptLegacyRows(row, eventRows);
964
1052
  return {
965
1053
  meta: adopted.meta,
@@ -985,39 +1073,60 @@ export class SessionPersistenceRdb extends SessionPersistence {
985
1073
  }
986
1074
 
987
1075
  /**
988
- * 活动计数**旁路累加**:不在写事务里、失败只 warn——`t_event_counts` 是可销毁重建的派生表,
989
- * 丢几次累加不影响可用性(需要时用 `rebuildEventCounts` 重算)。
1076
+ * 统计衍生表的**旁路累加**:不在写事务里、失败只 warn——两张会话汇总表都是可销毁重建的
1077
+ * 派生表,丢几次累加不影响可用性(需要时用 `rebuildSessionStats` 重算)。
1078
+ * token 走写路径已折好的用量行,活动计数走本批事件类型。
990
1079
  */
991
- private async recordEventCounts(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
992
- const buckets = eventCountBuckets(events);
993
- if (buckets.length === 0) return;
994
- try {
995
- await this.backend.incrementEventCounts(id, buckets);
996
- } catch (error: unknown) {
997
- this.ctx.logger.warn(
998
- `session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
999
- );
1080
+ private async recordSessionStats(
1081
+ id: SessionId,
1082
+ events: readonly SessionEvent[],
1083
+ usageRows: readonly EventUsageRow[],
1084
+ ): Promise<void> {
1085
+ const usageBuckets = sessionUsageBuckets(usageRows);
1086
+ if (usageBuckets.length > 0) {
1087
+ try {
1088
+ await this.backend.incrementSessionUsage(id, usageBuckets);
1089
+ } catch (error: unknown) {
1090
+ this.ctx.logger.warn(
1091
+ `session-rdb: token usage for "${id}" not updated (${describeError(error)})`,
1092
+ );
1093
+ }
1094
+ }
1095
+ const countBuckets = sessionCountBuckets(events);
1096
+ if (countBuckets.length > 0) {
1097
+ try {
1098
+ await this.backend.incrementSessionCounts(id, countBuckets);
1099
+ } catch (error: unknown) {
1100
+ this.ctx.logger.warn(
1101
+ `session-rdb: activity counts for "${id}" not updated (${describeError(error)})`,
1102
+ );
1103
+ }
1000
1104
  }
1001
1105
  }
1002
1106
 
1003
- /** 重算一个会话的活动计数(rewind / fork 之后;best-effort,同 `recordEventCounts`)。 */
1004
- async rebuildEventCounts(id: SessionId): Promise<void> {
1107
+ /**
1108
+ * 重算一个会话的统计(rewind / fork 之后;best-effort,同 `recordSessionStats`):
1109
+ * 两张汇总表按会话重算,用量行的引用标记全量重算(引用可能跨会话消失)。
1110
+ */
1111
+ async rebuildSessionStats(id: SessionId): Promise<void> {
1005
1112
  try {
1006
- await this.backend.rebuildEventCounts(id);
1113
+ await this.backend.rebuildSessionStats(id);
1114
+ await this.backend.refreshEventUsageFlags();
1007
1115
  } catch (error: unknown) {
1008
1116
  this.ctx.logger.warn(
1009
- `session-rdb: activity counts for "${id}" not rebuilt (${describeError(error)})`,
1117
+ `session-rdb: session stats for "${id}" not rebuilt (${describeError(error)})`,
1010
1118
  );
1011
1119
  }
1012
1120
  }
1013
1121
 
1014
- /** 会话删除时清掉它的活动计数行(best-effort)。 */
1015
- private async deleteEventCounts(id: SessionId): Promise<void> {
1122
+ /** 会话删除时清掉它的汇总行,并重算用量行的引用标记(被删会话引用的行要立刻退出统计)。 */
1123
+ private async dropSessionStats(id: SessionId): Promise<void> {
1016
1124
  try {
1017
- await this.backend.deleteEventCounts(id);
1125
+ await this.backend.deleteSessionStats(id);
1126
+ await this.backend.refreshEventUsageFlags();
1018
1127
  } catch (error: unknown) {
1019
1128
  this.ctx.logger.warn(
1020
- `session-rdb: activity counts for "${id}" not deleted (${describeError(error)})`,
1129
+ `session-rdb: session stats for "${id}" not deleted (${describeError(error)})`,
1021
1130
  );
1022
1131
  }
1023
1132
  }
@@ -1039,13 +1148,15 @@ export class SessionPersistenceRdb extends SessionPersistence {
1039
1148
  { meta: log.meta, inheritedEventCount: SessionLogOffset(log.inheritedEventCount) },
1040
1149
  randomUUID(),
1041
1150
  );
1042
- const { headEventId, headSequence } = await appendEventTail(tx, log.meta, log.events, {
1151
+ const appended = await appendEventTail(tx, log.meta, log.events, {
1043
1152
  parentId: "",
1044
1153
  nextSeq: 0,
1045
1154
  });
1046
- await tx.updateHead(id, headEventId, headSequence);
1155
+ await tx.updateHead(id, appended.headEventId, appended.headSequence);
1047
1156
  await tx.bumpRevision(id, maxEventTime(log.events));
1048
1157
  });
1158
+ // 重写换了事件行:被删掉的那批成了孤儿(引用标记要落回 0),本会话汇总按新事件重算。
1159
+ await this.rebuildSessionStats(id);
1049
1160
  }
1050
1161
 
1051
1162
  async listSnapshots(
@@ -1374,15 +1485,41 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
1374
1485
  );
1375
1486
  }
1376
1487
 
1377
- /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合。 */
1378
- function eventCountBuckets(events: readonly SessionEvent[]): EventCountBucket[] {
1379
- const counted = new Map<string, EventCountBucket>();
1488
+ /** 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。 */
1489
+ function sessionCountBuckets(events: readonly SessionEvent[]): SessionCountBucket[] {
1490
+ const counted = new Map<string, SessionCountBucket>();
1380
1491
  for (const event of events) {
1381
1492
  if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
1382
1493
  const day = localDayKey(event.time);
1383
- const key = `${day}#${event.type}`;
1384
- const bucket = counted.get(key) ?? { day, type: event.type, count: 0 };
1385
- bucket.count += 1;
1494
+ const bucket = counted.get(day) ?? { day, turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
1495
+ addActivityCount(bucket, event.type, 1);
1496
+ counted.set(day, bucket);
1497
+ }
1498
+ return [...counted.values()];
1499
+ }
1500
+
1501
+ /** 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。 */
1502
+ function sessionUsageBuckets(usageRows: readonly EventUsageRow[]): SessionUsageBucket[] {
1503
+ const counted = new Map<string, SessionUsageBucket>();
1504
+ for (const row of usageRows) {
1505
+ const provider = row.fProvider ?? "";
1506
+ const model = row.fModel ?? "";
1507
+ const key = `${row.fDay}#${provider}#${model}`;
1508
+ const bucket = counted.get(key) ?? {
1509
+ day: row.fDay,
1510
+ provider,
1511
+ model,
1512
+ inputTokens: 0,
1513
+ outputTokens: 0,
1514
+ cacheReadTokens: 0,
1515
+ reasoningTokens: 0,
1516
+ totalTokens: 0,
1517
+ };
1518
+ bucket.inputTokens += row.fInputTokens;
1519
+ bucket.outputTokens += row.fOutputTokens;
1520
+ bucket.cacheReadTokens += row.fCacheReadTokens;
1521
+ bucket.reasoningTokens += row.fReasoningTokens;
1522
+ bucket.totalTokens += row.fTotalTokens;
1386
1523
  counted.set(key, bucket);
1387
1524
  }
1388
1525
  return [...counted.values()];
@@ -1424,11 +1561,12 @@ async function appendEventTail(
1424
1561
  events: readonly SessionEvent[],
1425
1562
  anchor: { parentId: string; nextSeq: number },
1426
1563
  reuse?: ReadonlyMap<number, string>,
1427
- ): Promise<{ headEventId: string; headSequence: number }> {
1564
+ ): Promise<{ headEventId: string; headSequence: number; usageRows: EventUsageRow[] }> {
1428
1565
  let parentId = anchor.parentId;
1429
1566
  let nextSeq = anchor.nextSeq;
1430
1567
 
1431
1568
  const eventRows: EventInsert[] = [];
1569
+ const usageRows: EventUsageRow[] = [];
1432
1570
  const bridgeRows: Array<{
1433
1571
  fSessionId: SessionId;
1434
1572
  fEventId: string;
@@ -1447,7 +1585,7 @@ async function appendEventTail(
1447
1585
  sourceEventSeqs?: unknown;
1448
1586
  };
1449
1587
  const { data, surfaceOp: _surfaceOp, sourceEventSeqs: _sourceEventSeqs, ...envelope } = raw;
1450
- eventRows.push({
1588
+ const row: EventInsert = {
1451
1589
  fEventId: eventId,
1452
1590
  fParentId: parentId,
1453
1591
  fType: event.type,
@@ -1458,7 +1596,11 @@ async function appendEventTail(
1458
1596
  fEncoding: EVENT_ENCODING,
1459
1597
  fData: JSON.stringify({ ...envelope, data }),
1460
1598
  fCreatedAt: event.time,
1461
- });
1599
+ };
1600
+ eventRows.push(row);
1601
+ // 用量行在这里一次折好(读侧维度一起物化),后端只负责插入,事务后旁路再据此累加汇总表。
1602
+ const usageRow = usageRowOf(row, meta.origin === "subagent");
1603
+ if (usageRow !== undefined) usageRows.push(usageRow);
1462
1604
  }
1463
1605
  const surfaceOp =
1464
1606
  (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined
@@ -1473,13 +1615,13 @@ async function appendEventTail(
1473
1615
  parentId = eventId;
1474
1616
  nextSeq++;
1475
1617
  }
1476
- if (eventRows.length > 0) await tx.insertEvents(eventRows);
1618
+ if (eventRows.length > 0) await tx.insertEvents(eventRows, usageRows);
1477
1619
  await tx.insertBridges(bridgeRows);
1478
1620
 
1479
1621
  if (events.some((event) => (event.type as string) === "session/title")) {
1480
1622
  await tx.refreshTitle(meta.id);
1481
1623
  }
1482
- return { headEventId: parentId, headSequence: nextSeq - 1 };
1624
+ return { headEventId: parentId, headSequence: nextSeq - 1, usageRows };
1483
1625
  }
1484
1626
 
1485
1627
  export default SessionPersistenceRdb;
package/src/log.ts CHANGED
@@ -4,6 +4,7 @@ import type { SessionFormatEvent, SessionFormatHeader } from "@deepseek-ai/dsh-s
4
4
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
5
5
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
6
6
  import type { EventRow, SessionRow } from "./backend.ts";
7
+ import { localDayKey } from "./usage.ts";
7
8
 
8
9
  function normalizeSurfaceOp(value: unknown): SurfaceOp {
9
10
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -469,15 +470,15 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
469
470
  * (v4 起 system 消息只认这个 kind);
470
471
  * - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
471
472
  * `toolCallId` + `content` 就是结果块;
472
- * - 本仓库自己写过、上游已删除的事件类型(见 `LEGACY_OWN_EVENT_TYPES`)标 `ignorable: true`——v4 的
473
- * 校验对未知类型只接受可忽略信封。
473
+ * - **本仓库自造、上游不认识的事件类型**(见 `OWN_EVENT_TYPES`:历史遗留的与我们当前在写的)标
474
+ * `ignorable: true`——v4 的校验对未知类型只接受可忽略信封。
474
475
  *
475
476
  * 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
476
477
  */
477
478
  export function normalizeToCurrentShape(events: SessionEvent[]): void {
478
479
  for (const event of events) {
479
480
  const type = (event as { type: string }).type;
480
- if (LEGACY_OWN_EVENT_TYPES.has(type)) {
481
+ if (OWN_EVENT_TYPES.has(type)) {
481
482
  (event as { ignorable?: true }).ignorable = true;
482
483
  continue;
483
484
  }
@@ -520,17 +521,45 @@ function liftToolResultMessage(message: unknown): void {
520
521
  }
521
522
 
522
523
  /**
523
- * 本仓库写过、后来连同机制一起删除的事件类型:读的时候标 `ignorable: true` 让校验跳过它们。
524
- * 新增历史类型时登记到这里——**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件)。
524
+ * 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封。
525
+ *
526
+ * 上游的持久化校验(`validateStoredEvents`)只放行「已知类型」或「带可忽略信封的未知类型」,我们的类型
527
+ * 按构造就在已知表之外——不补的话连**写入**都会被拒(`appendBatch` 里那道校验)。上游 `Session.append`
528
+ * 没有承载这个信封的口子(第三个参数只服务 surface 事件),所以这一笔由持久化层补:canonical log 里的
529
+ * 形状不变,落库的那一份带上信封。
530
+ * @param events - 即将落库的批次(`materializeAppendBatch` 的 JSON 快照副本,可写)。
531
+ */
532
+ export function sealOwnEvents(events: readonly SessionEvent[]): void {
533
+ for (const event of events) {
534
+ if (OWN_EVENT_TYPES.has((event as { type: string }).type)) {
535
+ (event as { ignorable?: true }).ignorable = true;
536
+ }
537
+ }
538
+ }
539
+
540
+ /**
541
+ * 本仓库自造、上游不认识的事件类型:读的时候标 `ignorable: true`,v4 的持久化校验才肯跳过它们
542
+ * (`validateStoredEvents` 只放行「已知类型」或「带可忽略信封的未知类型」)。
543
+ *
544
+ * 两类都在这张表里:
545
+ * - **历史遗留**:写过、后来连同机制一起删除的(`session-branch/version`);
546
+ * - **当前在写**:本仓库的插件自己声明的事件类型(`session-mode/selected`)——上游的
547
+ * `KNOWN_SESSION_EVENT_TYPES` 由上游仓库的声明生成,外部插件的事件按构造就在它之外。
548
+ *
549
+ * 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud(真回归:`session-mode/selected` 漏登记,
550
+ * 会话打不开)。**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件——那种必须拒读)。
525
551
  */
526
- export const LEGACY_OWN_EVENT_TYPES: ReadonlySet<string> = new Set(["session-branch/version"]);
552
+ export const OWN_EVENT_TYPES: ReadonlySet<string> = new Set([
553
+ "session-branch/version",
554
+ "session-mode/selected",
555
+ ]);
527
556
 
528
- /** 行里是否还带着旧代消息形状(`session-branch/version` 这类自造事件也算)——写路径曾把回退视图的结果
529
- * 以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
530
- export function hasLegacyShape(events: readonly SessionEvent[]): boolean {
557
+ /** 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——写路径曾把回退
558
+ * 视图的结果以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
559
+ export function needsShapeAdoption(events: readonly SessionEvent[]): boolean {
531
560
  for (const event of events) {
532
561
  const type = (event as { type: string }).type;
533
- if (LEGACY_OWN_EVENT_TYPES.has(type)) return true;
562
+ if (OWN_EVENT_TYPES.has(type)) return true;
534
563
  if (type === "system/message") {
535
564
  const message = (event.data as unknown as Record<string, unknown>)["message"] as
536
565
  | Record<string, unknown>
@@ -716,12 +745,15 @@ export function titleOfEventData(data: string): string | undefined {
716
745
  }
717
746
  }
718
747
 
719
- /** 用量行的列(`t_event_usage`)。 */
748
+ /** 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。 */
720
749
  export interface EventUsageRow {
721
750
  fEventId: string;
722
751
  fCreatedAt: number;
752
+ fDay: string;
723
753
  fProvider: string | null;
724
754
  fModel: string | null;
755
+ fReferenced: number;
756
+ fSubagent: number;
725
757
  fInputTokens: number;
726
758
  fOutputTokens: number;
727
759
  fCacheReadTokens: number;
@@ -740,15 +772,21 @@ function textField(value: unknown): string | null {
740
772
  /**
741
773
  * 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有):写路径据此记录
742
774
  * `t_event_usage`,统计因此不必逐行解析 JSON。两种 `f_data` 结构(带信封 / 老格式)都认。
775
+ * 读侧维度一起物化:本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务,
776
+ * 提交即被引用),`f_subagent` 取写入它的会话是否子代理。
743
777
  * @param event - 待写入的事件行。
778
+ * @param subagent - 写入它的会话是否 `origin === 'subagent'`。
744
779
  * @returns 用量行,或该事件没有用量时的 undefined。
745
780
  */
746
- export function usageRowOf(event: {
747
- fEventId: string;
748
- fCreatedAt: number;
749
- fType: string;
750
- fData: string;
751
- }): EventUsageRow | undefined {
781
+ export function usageRowOf(
782
+ event: {
783
+ fEventId: string;
784
+ fCreatedAt: number;
785
+ fType: string;
786
+ fData: string;
787
+ },
788
+ subagent: boolean,
789
+ ): EventUsageRow | undefined {
752
790
  if (event.fType !== "assistant/message") return undefined;
753
791
  let parsed: Record<string, unknown>;
754
792
  try {
@@ -763,8 +801,11 @@ export function usageRowOf(event: {
763
801
  const row: EventUsageRow = {
764
802
  fEventId: event.fEventId,
765
803
  fCreatedAt: event.fCreatedAt,
804
+ fDay: localDayKey(event.fCreatedAt),
766
805
  fProvider: textField(source?.["provider"]),
767
806
  fModel: textField(source?.["model"]),
807
+ fReferenced: 1,
808
+ fSubagent: subagent ? 1 : 0,
768
809
  fInputTokens: numericField((usage as Record<string, unknown>)["inputTokens"]),
769
810
  fOutputTokens: numericField((usage as Record<string, unknown>)["outputTokens"]),
770
811
  fCacheReadTokens: numericField((usage as Record<string, unknown>)["cacheReadTokens"]),