@morlay/session-rdb 0.0.21-alpha.3 → 0.0.21-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/postgres.ts CHANGED
@@ -10,6 +10,7 @@ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistenc
10
10
  import {
11
11
  type Backend,
12
12
  type BackendTx,
13
+ type EventCountBucket,
13
14
  type EventInsert,
14
15
  type EventRow,
15
16
  type SessionRow,
@@ -19,10 +20,34 @@ import { postgresTableDefs } from "./entities/index.ts";
19
20
  import { sessionConflictRow, sessionInsertRow, titleOfEventData, usageRowOf } from "./log.ts";
20
21
  import { createStorageRepository } from "./storage-takeover/repository.ts";
21
22
  import type { StorageRepository } from "./storage-takeover/types.ts";
22
- import type { UsageAggregate, UsageTotals } from "./usage.ts";
23
+ import {
24
+ COUNTED_EVENT_TYPES,
25
+ addActivityCount,
26
+ addTokenTotals,
27
+ addTotals,
28
+ emptyTotals,
29
+ localDayKey,
30
+ } from "./usage.ts";
31
+ import type { UsageActivityTotals, UsageAggregate, UsageTokenTotals } from "./usage.ts";
23
32
 
24
33
  const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
25
34
 
35
+ const pgWriteQueues = new Map<string, Promise<void>>();
36
+
37
+ /** 按介质(连接串 + schema)串行化写事务,与 SQLite 侧的 `enqueueSqliteTx` 同形。 */
38
+ function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
39
+ const tail = pgWriteQueues.get(key) ?? Promise.resolve();
40
+ const run = tail.then(fn);
41
+ pgWriteQueues.set(
42
+ key,
43
+ run.then(
44
+ () => undefined,
45
+ () => undefined,
46
+ ),
47
+ );
48
+ return run;
49
+ }
50
+
26
51
  /** pg 的 text 列窄化:非字符串(含 null)都不当作文本。 */
27
52
  function text(value: unknown): string | null {
28
53
  return typeof value === "string" ? value : null;
@@ -35,9 +60,8 @@ function numeric(value: unknown): number {
35
60
  }
36
61
 
37
62
  /** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
38
- function totalsOfRow(row: Record<string, unknown>): UsageTotals {
63
+ function tokenTotalsOf(row: Record<string, unknown>): UsageTokenTotals {
39
64
  return {
40
- events: numeric(row["events"]),
41
65
  inputTokens: numeric(row["input_tokens"]),
42
66
  outputTokens: numeric(row["output_tokens"]),
43
67
  cacheReadTokens: numeric(row["cache_read_tokens"]),
@@ -46,6 +70,13 @@ function totalsOfRow(row: Record<string, unknown>): UsageTotals {
46
70
  };
47
71
  }
48
72
 
73
+ function emptyActivity(): UsageActivityTotals {
74
+ return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
75
+ }
76
+
77
+ /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
78
+ const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
79
+
49
80
  export interface PostgresBackendOptions {
50
81
  identityBase: string;
51
82
 
@@ -79,21 +110,28 @@ export class PostgresBackend implements Backend {
79
110
  });
80
111
 
81
112
  this.opened.catch(() => {});
113
+ // 写事务按介质串行(与 SQLite 侧的 enqueueSqliteTx 同形):并发 `writeAtomically`
114
+ // 会互相覆盖实例级的 `txOverride`,让先开始的事务的语句落到别人的事务(一次失败
115
+ // 会把另一个成功事务一起拖垮),或退化成 autocommit。介质身份用 `identityBase`
116
+ // (宿主 + 库 + schema)——同一进程内多实例连同一库时也共用同一条队列。
117
+ const writeQueueKey = options.identityBase;
82
118
  this.storage = createStorageRepository({
83
119
  db: () => this.opened.then(() => this.txOverride ?? this.db),
84
120
 
85
121
  writeAtomically: (fn) =>
86
- this.db.transaction(
87
- async (tx) => {
88
- const previous = this.txOverride;
89
- this.txOverride = tx;
90
- try {
91
- return await fn();
92
- } finally {
93
- this.txOverride = previous;
94
- }
95
- },
96
- { isolationLevel: "serializable" },
122
+ enqueuePgWrite(writeQueueKey, () =>
123
+ this.db.transaction(
124
+ async (tx) => {
125
+ const previous = this.txOverride;
126
+ this.txOverride = tx;
127
+ try {
128
+ return await fn();
129
+ } finally {
130
+ this.txOverride = previous;
131
+ }
132
+ },
133
+ { isolationLevel: "serializable" },
134
+ ),
97
135
  ),
98
136
  tables: this.tables,
99
137
  });
@@ -124,6 +162,8 @@ export class PostgresBackend implements Backend {
124
162
  }
125
163
  }
126
164
  await migrate(this.db, { migrationsFolder: postgresMigrationsDir });
165
+ await this.ensureEventCountsTable();
166
+ await this.backfillEventCounts();
127
167
  await this.backfillEventUsage();
128
168
  const storeId = await this.db.transaction(async (tx) => {
129
169
  await tx
@@ -194,9 +234,13 @@ export class PostgresBackend implements Backend {
194
234
  .execute() as unknown as EventRow[];
195
235
  }
196
236
 
237
+ // 只取类型:rewind 的边界 / 窗口探测不该把整个事件 JSON(f_data)拖回来。
197
238
  async getEventTypeAt(id: SessionId, sequence: number): Promise<string | undefined> {
198
239
  const table = this.tables["t_session_events"];
199
- const rows = (await this.eventRows(this.db)
240
+ const rows = (await this.db
241
+ .select({ fType: this.tables["t_events"].fType })
242
+ .from(table)
243
+ .innerJoin(this.tables["t_events"], eq(table.fEventId, this.tables["t_events"].fEventId))
200
244
  .where(and(eq(table.fSessionId, id), eq(table.fSequence, sequence)))
201
245
  .limit(1)
202
246
  .execute()) as Array<{ fType?: string }>;
@@ -209,7 +253,10 @@ export class PostgresBackend implements Backend {
209
253
  limit: number,
210
254
  ): Promise<Array<Pick<EventRow, "fSequence" | "fType">>> {
211
255
  const table = this.tables["t_session_events"];
212
- return (await this.eventRows(this.db)
256
+ return (await this.db
257
+ .select({ fSequence: table.fSequence, fType: this.tables["t_events"].fType })
258
+ .from(table)
259
+ .innerJoin(this.tables["t_events"], eq(table.fEventId, this.tables["t_events"].fEventId))
213
260
  .where(and(eq(table.fSessionId, id), lt(table.fSequence, beforeSequence)))
214
261
  .orderBy(desc(table.fSequence))
215
262
  .limit(limit)
@@ -552,62 +599,209 @@ export class PostgresBackend implements Backend {
552
599
 
553
600
  /** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
554
601
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
555
- const sinceClause = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
602
+ const usageSince = sinceMs === undefined ? sql`` : sql` AND u.f_created_at >= ${sinceMs}`;
603
+ // 活动计数走派生表的「本地日」列(与桶的本地日口径一致)。
604
+ const daySince = sinceMs === undefined ? sql`` : sql` AND c.f_day >= ${localDayKey(sinceMs)}`;
556
605
  const tEventUsage = this.tables["t_event_usage"];
557
606
  const tSessionEvents = this.tables["t_session_events"];
558
607
  const tSessions = this.tables["t_sessions"];
608
+ const eventCountsTable = this.qualifiedTable("t_event_counts");
609
+ const subagentOfUsage = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
610
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
611
+ WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent')`;
612
+ const subagentOfCounts = sql`EXISTS (SELECT 1 FROM ${tSessionEvents} sb
613
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
614
+ WHERE sb.f_session_id = c.f_session_id AND ss.f_origin = 'subagent')`;
615
+ // 有 token 用量的会话:活动计数与列表都限定在这一批会话里,口径一致。
616
+ const hasUsageSession = (
617
+ column: string,
618
+ ): ReturnType<typeof sql> => sql`EXISTS (SELECT 1 FROM ${tSessionEvents} ub
619
+ JOIN ${tEventUsage} uu ON uu.f_event_id = ub.f_event_id
620
+ WHERE ub.f_session_id = ${sql.raw(column)})`;
621
+ const tokenColumns = sql.raw(`sum(u.f_input_tokens) AS input_tokens,
622
+ sum(u.f_output_tokens) AS output_tokens,
623
+ sum(u.f_cache_read_tokens) AS cache_read_tokens,
624
+ sum(u.f_reasoning_tokens) AS reasoning_tokens,
625
+ sum(u.f_total_tokens) AS total_tokens`);
626
+
559
627
  const buckets = (await this.db.execute(sql`
560
628
  SELECT to_char(to_timestamp(u.f_created_at / 1000.0), 'YYYY-MM-DD') AS day,
561
629
  u.f_provider AS provider,
562
630
  u.f_model AS model,
563
- EXISTS (SELECT 1 FROM ${tSessionEvents} sb
564
- JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
565
- WHERE sb.f_event_id = u.f_event_id AND ss.f_origin = 'subagent') AS subagent,
566
- count(*) AS events,
567
- sum(u.f_input_tokens) AS input_tokens,
568
- sum(u.f_output_tokens) AS output_tokens,
569
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
570
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
571
- sum(u.f_total_tokens) AS total_tokens
631
+ ${subagentOfUsage} AS subagent,
632
+ ${tokenColumns}
572
633
  FROM ${tEventUsage} u
573
- WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${sinceClause}
634
+ WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
574
635
  GROUP BY 1, 2, 3, 4
575
636
  `)) as unknown as { rows: Array<Record<string, unknown>> };
576
- const sessions = (await this.db.execute(sql`
637
+ const tokenScope = (await this.db.execute(sql`
638
+ SELECT ${subagentOfUsage} AS subagent,
639
+ ${tokenColumns}
640
+ FROM ${tEventUsage} u
641
+ WHERE EXISTS (SELECT 1 FROM ${tSessionEvents} rb WHERE rb.f_event_id = u.f_event_id)${usageSince}
642
+ GROUP BY 1
643
+ `)) as unknown as { rows: Array<Record<string, unknown>> };
644
+ const activityScope = (await this.db.execute(sql`
645
+ SELECT ${subagentOfCounts} AS subagent,
646
+ c.f_type AS f_type,
647
+ sum(c.f_count) AS n
648
+ FROM ${sql.raw(eventCountsTable)} c
649
+ JOIN ${tSessions} s ON s.f_session_id = c.f_session_id
650
+ WHERE ${hasUsageSession("c.f_session_id")}${daySince}
651
+ GROUP BY 1, 2
652
+ `)) as unknown as { rows: Array<Record<string, unknown>> };
653
+ const tokenSessions = (await this.db.execute(sql`
577
654
  SELECT b.f_session_id AS session_id,
578
655
  s.f_title AS title,
579
656
  (s.f_origin = 'subagent') AS subagent,
580
657
  (s.f_archived_at IS NOT NULL) AS archived,
581
- count(*) AS events,
582
- sum(u.f_input_tokens) AS input_tokens,
583
- sum(u.f_output_tokens) AS output_tokens,
584
- sum(u.f_cache_read_tokens) AS cache_read_tokens,
585
- sum(u.f_reasoning_tokens) AS reasoning_tokens,
586
- sum(u.f_total_tokens) AS total_tokens
658
+ ${tokenColumns}
587
659
  FROM ${tSessionEvents} b
588
660
  JOIN ${tEventUsage} u ON u.f_event_id = b.f_event_id
589
661
  JOIN ${tSessions} s ON s.f_session_id = b.f_session_id
590
- WHERE 1 = 1${sinceClause}
662
+ WHERE 1 = 1${usageSince}
591
663
  GROUP BY b.f_session_id, s.f_title, s.f_origin, s.f_archived_at
592
664
  `)) as unknown as { rows: Array<Record<string, unknown>> };
665
+ const activitySessions = (await this.db.execute(sql`
666
+ SELECT c.f_session_id AS session_id,
667
+ c.f_type AS f_type,
668
+ sum(c.f_count) AS n
669
+ FROM ${sql.raw(eventCountsTable)} c
670
+ WHERE ${hasUsageSession("c.f_session_id")}${daySince}
671
+ GROUP BY c.f_session_id, c.f_type
672
+ `)) as unknown as { rows: Array<Record<string, unknown>> };
673
+ const human = emptyTotals();
674
+ const subagent = emptyTotals();
675
+ for (const row of tokenScope.rows) {
676
+ addTokenTotals(row["subagent"] === true ? subagent : human, tokenTotalsOf(row));
677
+ }
678
+ for (const row of activityScope.rows) {
679
+ addActivityCount(
680
+ row["subagent"] === true ? subagent : human,
681
+ String(row["f_type"]),
682
+ numeric(row["n"]),
683
+ );
684
+ }
685
+ const totals = addTotals(addTotals(emptyTotals(), human), subagent);
686
+
687
+ const activityBySession = new Map<string, UsageActivityTotals>();
688
+ for (const row of activitySessions.rows) {
689
+ const key = String(row["session_id"]);
690
+ const target = activityBySession.get(key) ?? emptyActivity();
691
+ addActivityCount(target, String(row["f_type"]), numeric(row["n"]));
692
+ activityBySession.set(key, target);
693
+ }
593
694
  return {
695
+ totals,
696
+ subagent,
697
+ human,
594
698
  buckets: buckets.rows.map((row) => ({
595
699
  day: String(row["day"]),
596
700
  provider: text(row["provider"]),
597
701
  model: text(row["model"]),
598
702
  subagent: row["subagent"] === true,
599
- ...totalsOfRow(row),
703
+ ...tokenTotalsOf(row),
600
704
  })),
601
- sessions: sessions.rows.map((row) => ({
705
+ sessions: tokenSessions.rows.map((row) => ({
602
706
  sessionId: String(row["session_id"]),
603
707
  title: text(row["title"]),
604
708
  subagent: row["subagent"] === true,
605
709
  archived: row["archived"] === true,
606
- ...totalsOfRow(row),
710
+ ...tokenTotalsOf(row),
711
+ ...(activityBySession.get(String(row["session_id"])) ?? emptyActivity()),
607
712
  })),
608
713
  };
609
714
  }
610
715
 
716
+ /** 表名限定到配置的 schema(派生表不进 drizzle schema / 迁移,建表幂等)。 */
717
+ private qualifiedTable(name: string): string {
718
+ const schema = this.options.schema ?? "public";
719
+ return schema === "public" ? name : `"${schema}".${name}`;
720
+ }
721
+
722
+ private async ensureEventCountsTable(): Promise<void> {
723
+ const table = this.qualifiedTable("t_event_counts");
724
+ await this.db.execute(
725
+ sql.raw(`CREATE TABLE IF NOT EXISTS ${table} (
726
+ f_id serial PRIMARY KEY,
727
+ f_session_id text NOT NULL,
728
+ f_day text NOT NULL,
729
+ f_type text NOT NULL,
730
+ f_count integer NOT NULL DEFAULT 0
731
+ )`),
732
+ );
733
+ await this.db.execute(
734
+ sql.raw(
735
+ `CREATE UNIQUE INDEX IF NOT EXISTS uq_event_counts_session_day_type ON ${table}(f_session_id, f_day, f_type)`,
736
+ ),
737
+ );
738
+ await this.db.execute(
739
+ sql.raw(`CREATE INDEX IF NOT EXISTS idx_event_counts_day ON ${table}(f_day)`),
740
+ );
741
+ }
742
+
743
+ /** 一次性回填活动计数(表为空时从事件表重算;幂等)。 */
744
+ private async backfillEventCounts(): Promise<void> {
745
+ const table = this.qualifiedTable("t_event_counts");
746
+ const existing = (await this.db.execute(
747
+ sql.raw(`SELECT count(*) AS n FROM ${table}`),
748
+ )) as unknown as { rows: Array<Record<string, unknown>> };
749
+ if (Number(existing.rows[0]?.["n"] ?? 0) > 0) return;
750
+ const tSessionEvents = this.tables["t_session_events"];
751
+ const tEvents = this.tables["t_events"];
752
+ await this.db.execute(sql`
753
+ INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
754
+ SELECT ${tSessionEvents}.f_session_id,
755
+ to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
756
+ ${tEvents}.f_type,
757
+ count(*)
758
+ FROM ${tSessionEvents}
759
+ JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
760
+ WHERE ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
761
+ GROUP BY 1, 2, 3
762
+ ON CONFLICT DO NOTHING
763
+ `);
764
+ }
765
+
766
+ /** 活动计数旁路累加:派生表,不在写事务里(失败可丢,表可销毁重建)。 */
767
+ async incrementEventCounts(id: SessionId, buckets: readonly EventCountBucket[]): Promise<void> {
768
+ if (buckets.length === 0) return;
769
+ const table = this.qualifiedTable("t_event_counts");
770
+ for (const bucket of buckets) {
771
+ await this.db.execute(sql`
772
+ INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
773
+ VALUES (${id}, ${bucket.day}, ${bucket.type}, ${bucket.count})
774
+ ON CONFLICT (f_session_id, f_day, f_type)
775
+ DO UPDATE SET f_count = ${sql.raw(`${table}.f_count`)} + EXCLUDED.f_count
776
+ `);
777
+ }
778
+ }
779
+
780
+ /** 按会话重算活动计数(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
781
+ async rebuildEventCounts(id: SessionId): Promise<void> {
782
+ const table = this.qualifiedTable("t_event_counts");
783
+ const tSessionEvents = this.tables["t_session_events"];
784
+ const tEvents = this.tables["t_events"];
785
+ await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
786
+ await this.db.execute(sql`
787
+ INSERT INTO ${sql.raw(table)} (f_session_id, f_day, f_type, f_count)
788
+ SELECT ${tSessionEvents}.f_session_id,
789
+ to_char(to_timestamp(${tEvents}.f_created_at / 1000.0), 'YYYY-MM-DD'),
790
+ ${tEvents}.f_type,
791
+ count(*)
792
+ FROM ${tSessionEvents}
793
+ JOIN ${tEvents} ON ${tEvents}.f_event_id = ${tSessionEvents}.f_event_id
794
+ WHERE ${tSessionEvents}.f_session_id = ${id}
795
+ AND ${tEvents}.f_type IN (${sql.raw(COUNTED_EVENT_TYPE_SQL)})
796
+ GROUP BY 1, 2, 3
797
+ `);
798
+ }
799
+
800
+ async deleteEventCounts(id: SessionId): Promise<void> {
801
+ const table = this.qualifiedTable("t_event_counts");
802
+ await this.db.execute(sql`DELETE FROM ${sql.raw(table)} WHERE f_session_id = ${id}`);
803
+ }
804
+
611
805
  private async getPrevBridge(
612
806
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
613
807
  id: SessionId,