@morlay/session-rdb 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -166
  2. package/dist/artifact.d.mts +2 -2
  3. package/dist/artifact.mjs +3 -2
  4. package/dist/{branch-D4nVzQQM.mjs → branch-IQii6Muq.mjs} +1 -8
  5. package/dist/deletion.d.mts +1 -2
  6. package/dist/deletion.mjs +2 -1
  7. package/dist/export.d.mts +1 -3
  8. package/dist/export.mjs +1 -4
  9. package/dist/gc.d.mts +1 -3
  10. package/dist/gc.mjs +1 -3
  11. package/dist/import.d.mts +1 -2
  12. package/dist/import.mjs +2 -7
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.mjs +4 -3
  15. package/dist/{log-BWVFYAh5.mjs → log-D5C8-TGs.mjs} +0 -52
  16. package/dist/{log-CUjKQbOR.d.mts → log-tZMKFRKi.d.mts} +17 -168
  17. package/dist/routes-DGBlBE_I.mjs +10 -0
  18. package/dist/{sqlite-SCiSnwaz.mjs → sqlite-CMMSn5kE.mjs} +24 -62
  19. package/dist/{src-BZ1tffd5.mjs → src-dsd2H70D.mjs} +59 -97
  20. package/dist/storage.d.mts +2 -22
  21. package/dist/storage.mjs +1 -1
  22. package/dist/testing.d.mts +1 -1
  23. package/dist/testing.mjs +1 -1
  24. package/dist/usage.d.mts +1 -1
  25. package/dist/usage.mjs +1 -17
  26. package/package.json +19 -19
  27. package/src/backend.ts +35 -54
  28. package/src/branch.ts +3 -5
  29. package/src/deletion.ts +2 -1
  30. package/src/entities/v3/event-usage.ts +3 -6
  31. package/src/entities/v3/session-counts.ts +3 -5
  32. package/src/entities/v3/session-usage.ts +3 -5
  33. package/src/export.ts +5 -4
  34. package/src/gc.ts +4 -3
  35. package/src/import.ts +5 -6
  36. package/src/index.ts +47 -45
  37. package/src/legacy.ts +3 -5
  38. package/src/log.ts +24 -53
  39. package/src/postgres.ts +70 -61
  40. package/src/routes.ts +16 -0
  41. package/src/rows.ts +11 -15
  42. package/src/session-query.ts +3 -7
  43. package/src/sqlite.ts +70 -58
  44. package/src/storage-takeover/projection-cache.ts +6 -8
  45. package/src/storage-takeover/repository.ts +2 -2
  46. package/src/storage-takeover/types.ts +1 -1
  47. package/src/usage.ts +22 -34
  48. package/src/write-guard.ts +1 -1
package/src/log.ts CHANGED
@@ -133,11 +133,9 @@ const SURFACE_EVENT_TYPES = new Set([
133
133
 
134
134
  const METERING_EVENT_TYPES = new Set(["compaction/summary", "compaction/prune"]);
135
135
 
136
- /**
137
- * 升序的 surface 事件 seq 索引:读视图修复里的溯源(replace 的 `sourceEventSeqs` 与
138
- * metering 的 `shadowedSeqs`)共用这一份,避免每个 replace 各扫一遍全量事件。
139
- * 前提是输入按 seq 有序(`readLog` 与 live snapshot 都如此)。
140
- */
136
+ // 升序的 surface 事件 seq 索引:读视图修复里的溯源(replace 的 `sourceEventSeqs` 与
137
+ // metering 的 `shadowedSeqs`)共用这一份,避免每个 replace 各扫一遍全量事件。
138
+ // 前提是输入按 seq 有序(`readLog` 与 live snapshot 都如此)。
141
139
  function surfaceSeqIndex(events: readonly SessionEvent[]): number[] {
142
140
  const seqs: number[] = [];
143
141
  for (const event of events) {
@@ -146,7 +144,7 @@ function surfaceSeqIndex(events: readonly SessionEvent[]): number[] {
146
144
  return seqs;
147
145
  }
148
146
 
149
- /** `[startSeq, endSeq]` 内的 surface seq(含端点),二分定位后切片。 */
147
+ // `[startSeq, endSeq]` 内的 surface seq(含端点),二分定位后切片。
150
148
  function surfaceSeqsInRange(seqs: readonly number[], startSeq: number, endSeq: number): number[] {
151
149
  let low = 0;
152
150
  let high = seqs.length;
@@ -461,20 +459,11 @@ export function renameLegacyPtcEvents(events: SessionEvent[]): void {
461
459
  }
462
460
  }
463
461
 
464
- /**
465
- * 把**旧代(v3 及更早)的消息形状**归一到当前格式(v4):迁移链(严格)拒绝那些形状时,读路径仍要
466
- * 能把它们读出来——只动字段与标记,不改坐标、不改内容。
467
- *
468
- * 三件事:
469
- * - `system/message` 的 source:`{ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }` → `{ kind: 'system-prompt' }`
470
- * (v4 起 system 消息只认这个 kind);
471
- * - `tool/result` 的消息:v3 是 user 角色、结果块包在 `content[0]`;v4 是 `role: 'tool'` + 顶层
472
- * `toolCallId` + `content` 就是结果块;
473
- * - **本仓库自造、上游不认识的事件类型**(见 `OWN_EVENT_TYPES`:历史遗留的与我们当前在写的)标
474
- * `ignorable: true`——v4 的校验对未知类型只接受可忽略信封。
475
- *
476
- * 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
477
- */
462
+ // 把**旧代(v3 及更早)的消息形状**归一到当前格式(v4):迁移链(严格)拒绝那些形状时,读路径仍要能把它们
463
+ // 读出来——只动字段与标记,不改坐标、不改内容。三件事:`system/message` 的 source 去掉 plugin 形状(v4 只认
464
+ // `{ kind: 'system-prompt' }`)、`tool/result` 改成 `role: 'tool'` + 顶层 `toolCallId`、`OWN_EVENT_TYPES` 里的
465
+ // 事件补 `ignorable: true` 信封。
466
+ // 只认这张白名单:**其它未知类型保持 fail loud**(那是「数据来自更新的 harness」的信号,不能吞掉)。
478
467
  export function normalizeToCurrentShape(events: SessionEvent[]): void {
479
468
  for (const event of events) {
480
469
  const type = (event as { type: string }).type;
@@ -520,15 +509,9 @@ function liftToolResultMessage(message: unknown): void {
520
509
  record["source"] = { kind: "tool", callId };
521
510
  }
522
511
 
523
- /**
524
- * 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封。
525
- *
526
- * 上游的持久化校验(`validateStoredEvents`)只放行「已知类型」或「带可忽略信封的未知类型」,我们的类型
527
- * 按构造就在已知表之外——不补的话连**写入**都会被拒(`appendBatch` 里那道校验)。上游 `Session.append`
528
- * 没有承载这个信封的口子(第三个参数只服务 surface 事件),所以这一笔由持久化层补:canonical log 里的
529
- * 形状不变,落库的那一份带上信封。
530
- * @param events - 即将落库的批次(`materializeAppendBatch` 的 JSON 快照副本,可写)。
531
- */
512
+ // 落库前给**本仓库自造的事件类型**补上 `ignorable: true` 信封:上游持久化校验(`validateStoredEvents`)只放行
513
+ // 「已知类型」或「带可忽略信封的未知类型」,不补连写入都会被拒。`Session.append` 没有承载信封的口子,所以这一笔
514
+ // 由持久化层补——canonical log 的形状不变,落库的那一份带上信封;`events` 是即将落库的批次(可写)。
532
515
  export function sealOwnEvents(events: readonly SessionEvent[]): void {
533
516
  for (const event of events) {
534
517
  if (OWN_EVENT_TYPES.has((event as { type: string }).type)) {
@@ -537,25 +520,19 @@ export function sealOwnEvents(events: readonly SessionEvent[]): void {
537
520
  }
538
521
  }
539
522
 
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
- * 会话打不开)。**不要**用「所有未知类型」代替(那会掩盖更新版本的未知事件——那种必须拒读)。
551
- */
523
+ // 本仓库自造、上游不认识的事件类型:读的时候标 `ignorable: true`,v4 的持久化校验才肯跳过它们
524
+ // (`validateStoredEvents` 只放行「已知类型」或「带可忽略信封的未知类型」)。两类都在表里:既有日志里会出现、
525
+ // 但本仓库不再产出的 `session-branch/version`,与当前在写的 `session-mode/selected`(上游已知类型表由上游仓库
526
+ // 的声明生成,外部插件的事件按构造就在它之外)。
527
+ // 新增自造事件类型时**必须**登记到这里,否则读路径 fail loud。**不要**用「所有未知类型」代替(那会掩盖更新
528
+ // 版本的未知事件——那种必须拒读)。
552
529
  export const OWN_EVENT_TYPES: ReadonlySet<string> = new Set([
553
530
  "session-branch/version",
554
531
  "session-mode/selected",
555
532
  ]);
556
533
 
557
- /** 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——写路径曾把回退
558
- * 视图的结果以当前版本号落库,所以**版本号不可信**,读之前要按内容判一次。 */
534
+ // 这批事件是否需要按当前形状归一(旧代消息形状,或尚未标 `ignorable` 的自造事件类型)——既有日志里可能有以
535
+ // 当前版本号落库的回退视图结果,所以**版本号不可信**,读之前要按内容判一次。
559
536
  export function needsShapeAdoption(events: readonly SessionEvent[]): boolean {
560
537
  for (const event of events) {
561
538
  const type = (event as { type: string }).type;
@@ -745,7 +722,7 @@ export function titleOfEventData(data: string): string | undefined {
745
722
  }
746
723
  }
747
724
 
748
- /** 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。 */
725
+ // 用量行的列(`t_event_usage`):读侧维度(本地日与两个归属标记)在写入时一起物化。
749
726
  export interface EventUsageRow {
750
727
  fEventId: string;
751
728
  fCreatedAt: number;
@@ -769,15 +746,9 @@ function textField(value: unknown): string | null {
769
746
  return typeof value === "string" && value !== "" ? value : null;
770
747
  }
771
748
 
772
- /**
773
- * 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有):写路径据此记录
774
- * `t_event_usage`,统计因此不必逐行解析 JSON。两种 `f_data` 结构(带信封 / 老格式)都认。
775
- * 读侧维度一起物化:本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务,
776
- * 提交即被引用),`f_subagent` 取写入它的会话是否子代理。
777
- * @param event - 待写入的事件行。
778
- * @param subagent - 写入它的会话是否 `origin === 'subagent'`。
779
- * @returns 用量行,或该事件没有用量时的 undefined。
780
- */
749
+ // 一条事件行的用量(只有带 `usage` 的 `assistant/message` 才有;没有用量时为 undefined):写路径据此记录
750
+ // `t_event_usage`,统计因此不必逐行解析 JSON,两种 `f_data` 结构(带信封 / 老格式)都认。读侧维度一起物化:
751
+ // 本地日由事件时间算,`f_referenced` 直接 1(这一批事件行与桥接行同事务),`f_subagent` 取写入它的会话是否子代理。
781
752
  export function usageRowOf(
782
753
  event: {
783
754
  fEventId: string;
package/src/postgres.ts CHANGED
@@ -38,7 +38,10 @@ const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", impo
38
38
 
39
39
  const pgWriteQueues = new Map<string, Promise<void>>();
40
40
 
41
- /** 按介质(连接串 + schema)串行化写事务,与 SQLite 侧的 `enqueueSqliteTx` 同形。 */
41
+ // 引用标记的分批重算:一次绑定的参数有上限,按块走(与 sqlite 侧同值)。
42
+ const REFRESH_FLAG_CHUNK = 400;
43
+
44
+ // 按介质(连接串 + schema)串行化写事务,与 SQLite 侧的 `enqueueSqliteTx` 同形。
42
45
  function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
43
46
  const tail = pgWriteQueues.get(key) ?? Promise.resolve();
44
47
  const run = tail.then(fn);
@@ -52,18 +55,18 @@ function enqueuePgWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
52
55
  return run;
53
56
  }
54
57
 
55
- /** pg 的 text 列窄化:非字符串(含 null)都不当作文本。 */
58
+ // pg 的 text 列窄化:非字符串(含 null)都不当作文本。
56
59
  function text(value: unknown): string | null {
57
60
  return typeof value === "string" ? value : null;
58
61
  }
59
62
 
60
- /** pg 的 count/sum 回落成字符串:统一转成有限数。 */
63
+ // pg 的 count/sum 回落成字符串:统一转成有限数。
61
64
  function numeric(value: unknown): number {
62
65
  const parsed = typeof value === "number" ? value : Number(value ?? 0);
63
66
  return Number.isFinite(parsed) ? parsed : 0;
64
67
  }
65
68
 
66
- /** SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。 */
69
+ // SQL 的缺失求和是 NULL:没有 usage 字段的行使该字段计 0。
67
70
  function tokenTotalsOf(row: Record<string, unknown>): UsageTokenTotals {
68
71
  return {
69
72
  inputTokens: numeric(row["input_tokens"]),
@@ -78,7 +81,7 @@ function emptyActivity(): UsageActivityTotals {
78
81
  return { turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
79
82
  }
80
83
 
81
- /** `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。 */
84
+ // `t_session_counts` 的四项计数折成活动计数(pg 的 sum 回落成字符串)。
82
85
  function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
83
86
  return {
84
87
  turns: numeric(row["turns"]),
@@ -88,7 +91,7 @@ function activityTotalsOf(row: Record<string, unknown>): UsageActivityTotals {
88
91
  };
89
92
  }
90
93
 
91
- /** `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。 */
94
+ // `IN (...)` 用的类型字面量(与 `COUNTED_EVENT_TYPES` 同源)。
92
95
  const COUNTED_EVENT_TYPE_SQL = COUNTED_EVENT_TYPES.map((type) => `'${type}'`).join(", ");
93
96
 
94
97
  export interface PostgresBackendOptions {
@@ -124,10 +127,9 @@ export class PostgresBackend implements Backend {
124
127
  });
125
128
 
126
129
  this.opened.catch(() => {});
127
- // 写事务按介质串行(与 SQLite 侧的 enqueueSqliteTx 同形):并发 `writeAtomically`
128
- // 会互相覆盖实例级的 `txOverride`,让先开始的事务的语句落到别人的事务(一次失败
129
- // 会把另一个成功事务一起拖垮),或退化成 autocommit。介质身份用 `identityBase`
130
- // (宿主 + 库 + schema)——同一进程内多实例连同一库时也共用同一条队列。
130
+ // 写事务按介质串行(与 SQLite 侧 `enqueueSqliteTx` 同形):并发 `writeAtomically` 会互相覆盖实例级的
131
+ // `txOverride`,让语句落进别人的事务或退化成 autocommit;介质身份用 `identityBase`(宿主 + 库 + schema),
132
+ // 同一进程内多实例连同一库时共用同一条队列。
131
133
  const writeQueueKey = options.identityBase;
132
134
  this.storage = createStorageRepository({
133
135
  db: () => this.opened.then(() => this.txOverride ?? this.db),
@@ -486,26 +488,22 @@ export class PostgresBackend implements Backend {
486
488
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
487
489
  id: SessionId,
488
490
  fromSequence: number,
489
- ): Promise<void> {
490
- await exec
491
- .delete(this.tables["t_session_events"])
492
- .where(
493
- and(
494
- eq(this.tables["t_session_events"].fSessionId, id),
495
- gte(this.tables["t_session_events"].fSequence, fromSequence),
496
- ),
497
- )
498
- .execute();
491
+ ): Promise<readonly string[]> {
492
+ const table = this.tables["t_session_events"];
493
+ const result = (await exec.execute(sql`
494
+ DELETE FROM ${table} WHERE f_session_id = ${id} AND f_sequence >= ${fromSequence}
495
+ RETURNING f_event_id
496
+ `)) as unknown as { rows: Array<{ f_event_id: string }> };
497
+ return result.rows.map((row) => row.f_event_id);
499
498
  }
500
499
 
501
500
  private async deleteSession(
502
501
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
503
502
  id: SessionId,
504
- ): Promise<void> {
505
- await exec
506
- .delete(this.tables["t_session_events"])
507
- .where(eq(this.tables["t_session_events"].fSessionId, id))
508
- .execute();
503
+ ): Promise<readonly string[]> {
504
+ const result = (await exec.execute(sql`
505
+ DELETE FROM ${this.tables["t_session_events"]} WHERE f_session_id = ${id} RETURNING f_event_id
506
+ `)) as unknown as { rows: Array<{ f_event_id: string }> };
509
507
  await exec
510
508
  .delete(this.tables["t_workspace_sessions"])
511
509
  .where(eq(this.tables["t_workspace_sessions"].fSessionId, id))
@@ -518,9 +516,10 @@ export class PostgresBackend implements Backend {
518
516
  .delete(this.tables["t_sessions"])
519
517
  .where(eq(this.tables["t_sessions"].fSessionId, id))
520
518
  .execute();
519
+ return result.rows.map((row) => row.f_event_id);
521
520
  }
522
521
 
523
- /** 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。 */
522
+ // 事件行可能被多个会话共享(fork 派生),所以孤儿只能在全库范围内判定。
524
523
  async collectOrphans(): Promise<number> {
525
524
  const tEvents = this.tables["t_events"];
526
525
  const tEventUsage = this.tables["t_event_usage"];
@@ -547,7 +546,7 @@ export class PostgresBackend implements Backend {
547
546
  return result.rowCount ?? 0;
548
547
  }
549
548
 
550
- /** 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。 */
549
+ // 父会话被删后留下的 subagent 会话:父已不在表里,或本来就没有父。
551
550
  async listOrphanSubagentSessions(): Promise<SessionId[]> {
552
551
  const tSessions = this.tables["t_sessions"];
553
552
  const result = (await this.db.execute(sql`
@@ -562,12 +561,16 @@ export class PostgresBackend implements Backend {
562
561
  private async deleteSessions(
563
562
  exec: PgAsyncDatabase<NodePgQueryResultHKT>,
564
563
  ids: SessionId[],
565
- ): Promise<number> {
566
- if (ids.length === 0) return 0;
567
- await exec
568
- .delete(this.tables["t_session_events"])
569
- .where(inArray(this.tables["t_session_events"].fSessionId, ids))
570
- .execute();
564
+ ): Promise<{ deleted: number; eventIds: readonly string[] }> {
565
+ if (ids.length === 0) return { deleted: 0, eventIds: [] };
566
+ const removed = (await exec.execute(sql`
567
+ DELETE FROM ${this.tables["t_session_events"]}
568
+ WHERE f_session_id IN (${sql.join(
569
+ ids.map((id) => sql`${id}`),
570
+ sql`, `,
571
+ )})
572
+ RETURNING f_event_id
573
+ `)) as unknown as { rows: Array<{ f_event_id: string }> };
571
574
  await exec
572
575
  .delete(this.tables["t_workspace_sessions"])
573
576
  .where(inArray(this.tables["t_workspace_sessions"].fSessionId, ids))
@@ -580,14 +583,17 @@ export class PostgresBackend implements Backend {
580
583
  .delete(this.tables["t_sessions"])
581
584
  .where(inArray(this.tables["t_sessions"].fSessionId, ids))
582
585
  .execute()) as unknown as { rowCount?: number | null };
583
- return result.rowCount ?? 0;
586
+ return {
587
+ deleted: Number(result.rowCount ?? 0),
588
+ eventIds: removed.rows.map((row) => row.f_event_id),
589
+ };
584
590
  }
585
591
 
586
592
  async vacuum(): Promise<void> {
587
593
  await this.db.execute(sql`VACUUM ANALYZE`);
588
594
  }
589
595
 
590
- /** 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。 */
596
+ // 派生统计表的回填:任一表为空即按事件表全量重算(幂等,迁移删表重建后自愈)。
591
597
  private async backfillUsageTables(): Promise<void> {
592
598
  // 顺序有依赖:用量行(带物化列)先落,两张会话汇总表再从它 / 事件表重算。
593
599
  await this.backfillEventUsage();
@@ -602,10 +608,8 @@ export class PostgresBackend implements Backend {
602
608
  return Number(existing.rows[0]?.["n"] ?? 0) === 0;
603
609
  }
604
610
 
605
- /**
606
- * 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
607
- * 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
608
- */
611
+ // 用量行:一条 `assistant/message` 事件行一行,物化本地日与两个归属标记。
612
+ // 本地日按数据库会话时区渲染(部署前提是它与 host 时区一致,`localDayKey` 同口径)。
609
613
  private async backfillEventUsage(): Promise<void> {
610
614
  if (!(await this.tableIsEmpty(this.qualifiedTable("t_event_usage")))) return;
611
615
  const tEventUsage = this.tables["t_event_usage"];
@@ -645,7 +649,7 @@ export class PostgresBackend implements Backend {
645
649
  `);
646
650
  }
647
651
 
648
- /** 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。 */
652
+ // 会话 × 本地日 × 模型的 token 汇总:从桥接行 × 用量行重算(含 fork 继承前缀)。
649
653
  private async backfillSessionUsage(): Promise<void> {
650
654
  if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_usage")))) return;
651
655
  const tSessionUsage = this.tables["t_session_usage"];
@@ -665,7 +669,7 @@ export class PostgresBackend implements Backend {
665
669
  `);
666
670
  }
667
671
 
668
- /** 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。 */
672
+ // 会话 × 本地日的活动计数:从桥接行 × 事件类型重算。
669
673
  private async backfillSessionCounts(): Promise<void> {
670
674
  if (!(await this.tableIsEmpty(this.qualifiedTable("t_session_counts")))) return;
671
675
  const tSessionCounts = this.tables["t_session_counts"];
@@ -688,7 +692,7 @@ export class PostgresBackend implements Backend {
688
692
  `);
689
693
  }
690
694
 
691
- /** 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。 */
695
+ // 用量聚合:与 SQLite 侧同形,读 `t_event_usage`,数值列回来是字符串。
692
696
  async listSessionRows(query: SessionListRowsQuery = {}): Promise<SessionListRowsPage> {
693
697
  const needle = query.query?.trim().toLowerCase() ?? "";
694
698
  const like = `%${needle}%`;
@@ -741,10 +745,8 @@ export class PostgresBackend implements Backend {
741
745
  };
742
746
  }
743
747
 
744
- /**
745
- * 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
746
- * 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
747
- */
748
+ // 用量聚合:与 SQLite 侧同形——只读三张派生统计表(用量行给去重口径的总量与「天 × 模型」桶,
749
+ // 两张会话汇总表给按会话的行),数值列回来是字符串。物化列让这里不做 `EXISTS`、不回连事件表。
748
750
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
749
751
  const usageSince = sinceMs === undefined ? sql`` : sql` AND f_created_at >= ${sinceMs}`;
750
752
  const dayFilter = (column: string): ReturnType<typeof sql> =>
@@ -846,13 +848,13 @@ export class PostgresBackend implements Backend {
846
848
  };
847
849
  }
848
850
 
849
- /** 表名限定到配置的 schema。 */
851
+ // 表名限定到配置的 schema。
850
852
  private qualifiedTable(name: string): string {
851
853
  const schema = this.options.schema ?? "public";
852
854
  return schema === "public" ? name : `"${schema}".${name}`;
853
855
  }
854
856
 
855
- /** token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。 */
857
+ // token 用量旁路累加:派生表 `t_session_usage`,不在写事务里(失败可丢,表可销毁重建)。
856
858
  async incrementSessionUsage(
857
859
  id: SessionId,
858
860
  buckets: readonly SessionUsageBucket[],
@@ -877,7 +879,7 @@ export class PostgresBackend implements Backend {
877
879
  }
878
880
  }
879
881
 
880
- /** 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。 */
882
+ // 活动计数旁路累加:派生表 `t_session_counts`,同上(四项计数一次落)。
881
883
  async incrementSessionCounts(
882
884
  id: SessionId,
883
885
  buckets: readonly SessionCountBucket[],
@@ -899,7 +901,7 @@ export class PostgresBackend implements Backend {
899
901
  }
900
902
  }
901
903
 
902
- /** 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。 */
904
+ // 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。
903
905
  async rebuildSessionStats(id: SessionId): Promise<void> {
904
906
  const tSessionUsage = this.tables["t_session_usage"];
905
907
  const tSessionCounts = this.tables["t_session_counts"];
@@ -936,20 +938,27 @@ export class PostgresBackend implements Backend {
936
938
  `);
937
939
  }
938
940
 
939
- /** 全量重算用量行的引用标记(rewind / fork / 会话删除之后)。 */
940
- async refreshEventUsageFlags(): Promise<void> {
941
+ // 重算这些用量行的引用标记(会话删除 / 日志重写之后;撤回与 fork 都不该动统计)。
942
+ async refreshEventUsageFlags(eventIds: readonly string[]): Promise<void> {
941
943
  const tEventUsage = this.tables["t_event_usage"];
942
944
  const tSessionEvents = this.tables["t_session_events"];
943
945
  const tSessions = this.tables["t_sessions"];
944
- await this.db.execute(sql`
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
952
- `);
946
+ for (let at = 0; at < eventIds.length; at += REFRESH_FLAG_CHUNK) {
947
+ const chunk = eventIds.slice(at, at + REFRESH_FLAG_CHUNK);
948
+ await this.db.execute(sql`
949
+ UPDATE ${tEventUsage} SET
950
+ f_referenced = (EXISTS (SELECT 1 FROM ${tSessionEvents} rb
951
+ WHERE rb.f_event_id = ${tEventUsage}.f_event_id))::int,
952
+ f_subagent = (EXISTS (SELECT 1 FROM ${tSessionEvents} sb
953
+ JOIN ${tSessions} ss ON ss.f_session_id = sb.f_session_id
954
+ WHERE sb.f_event_id = ${tEventUsage}.f_event_id
955
+ AND ss.f_origin = 'subagent'))::int
956
+ WHERE f_event_id IN (${sql.join(
957
+ chunk.map((id) => sql`${id}`),
958
+ sql`, `,
959
+ )})
960
+ `);
961
+ }
953
962
  }
954
963
 
955
964
  async deleteSessionStats(id: SessionId): Promise<void> {
package/src/routes.ts ADDED
@@ -0,0 +1,16 @@
1
+ // 本仓库会话管理面 HTTP 路径的 home:整族挂在同一前缀下,与上游的 `/api` 面(connection 的 prefix 路由)
2
+ // 分开。同路径的 exact 路由会遮蔽上游同名路由的 GET/HEAD 通道(官方会话日志下载因此 405),
3
+ // 见 ADR-管理面HTTP路由前缀与上游分家。
4
+ export const MORLAY_SESSION_API_PREFIX = "/api/morlay/v1/session";
5
+
6
+ export const SESSION_ROWS_PATH = `${MORLAY_SESSION_API_PREFIX}/rows`;
7
+
8
+ export const SESSION_EXPORT_PATH = `${MORLAY_SESSION_API_PREFIX}/export`;
9
+
10
+ export const SESSION_IMPORT_PATH = `${MORLAY_SESSION_API_PREFIX}/import`;
11
+
12
+ export const SESSION_DELETE_PATH = `${MORLAY_SESSION_API_PREFIX}/delete`;
13
+
14
+ export const SESSION_GC_PATH = `${MORLAY_SESSION_API_PREFIX}/gc`;
15
+
16
+ export const SESSION_USAGE_PATH = `${MORLAY_SESSION_API_PREFIX}/usage`;
package/src/rows.ts CHANGED
@@ -1,32 +1,28 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import type { Backend } from "./backend.ts";
3
+ import { SESSION_ROWS_PATH } from "./routes.ts";
3
4
 
4
- /**
5
- * 管理面的会话列表:**我们自己的**一条路由(完整语料,含归档),与官方 `session/list` 分开。
6
- *
7
- * 官方那条(`session/list` → `ctx.sessionQuery.listSessions`)按部署策略默认排除已归档——那是给上游
8
- * UI 用的。归档集的管理动作要有完整列表,所以这里单独给一条:标题直接从会话列取,最后活动时间用
9
- * 该会话最后一个事件的时间。
10
- */
11
- export const SESSION_ROWS_PATH = "/api/session.rows";
5
+ // 管理面的会话列表路由:完整语料(含归档)+ 标题 + 最后活动时间,与官方 `session/list` 分开
6
+ // (见 ADR-会话列表两条路)。
7
+ export { SESSION_ROWS_PATH };
12
8
 
13
9
  export interface SessionRowsItem {
14
10
  sessionId: string;
15
11
  title: string | null;
16
- /** 会话 header 的 origin(`subagent` 表示子代理会话)。 */
12
+ // 会话 header 的 origin(`subagent` 表示子代理会话)。
17
13
  origin: string | null;
18
14
  cwd: string | null;
19
15
  createdAt: number;
20
- /** 最后一个事件的时间(无事件时等于 `createdAt`)。 */
16
+ // 最后一个事件的时间(无事件时等于 `createdAt`)。
21
17
  updatedAt: number;
22
18
  archived: boolean;
23
- /** 所属工作区标题;不属于任何工作区时为 null。 */
19
+ // 所属工作区标题;不属于任何工作区时为 null。
24
20
  workspace: string | null;
25
21
  }
26
22
 
27
- /** 请求:搜索、子代理过滤与分页都在后端做——前端分页等于每次拉全量,数据一多就崩。 */
23
+ // 请求:搜索、子代理过滤与分页都在后端做——前端分页等于每次拉全量,数据一多就崩。
28
24
  export interface SessionRowsRequest {
29
- /** 匹配标题或所属工作区标题(大小写不敏感)。 */
25
+ // 匹配标题或所属工作区标题(大小写不敏感)。
30
26
  query?: string;
31
27
  includeSubagents?: boolean;
32
28
  page?: number;
@@ -35,13 +31,13 @@ export interface SessionRowsRequest {
35
31
 
36
32
  export interface SessionRowsValue {
37
33
  items: SessionRowsItem[];
38
- /** 过滤后的总数(分页前),前端据此算页数。 */
34
+ // 过滤后的总数(分页前),前端据此算页数。
39
35
  total: number;
40
36
  page: number;
41
37
  pageSize: number;
42
38
  }
43
39
 
44
- /** 一页的条数上限:超过就按上限截断(这个接口给页面用,不是导出通道)。 */
40
+ // 一页的条数上限:超过就按上限截断(这个接口给页面用,不是导出通道)。
45
41
  const MAX_PAGE_SIZE = 200;
46
42
  const DEFAULT_PAGE_SIZE = 20;
47
43
 
@@ -11,12 +11,8 @@ export class SessionQueryRdb extends SessionQueryEngine {
11
11
  super(ctx, config);
12
12
  }
13
13
 
14
- /**
15
- * 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,列表与搜索都不该把它混进
16
- * 日常集合(上游 client 再用 workspace registry 的归档集做「只看归档」的派生)。
17
- *
18
- * 完整语料(含归档)走我们自己的管理面路由 `SESSION_ROWS_PATH`——两条路分开,不在一处混。
19
- */
14
+ // 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,上游 client 用 workspace registry
15
+ // 的归档集做「只看归档」的派生;完整语料走管理面路由 `SESSION_ROWS_PATH`。
20
16
  override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
21
17
  const records = await super.listSessions(signal);
22
18
  const archived = this.archivedSessionIds();
@@ -24,7 +20,7 @@ export class SessionQueryRdb extends SessionQueryEngine {
24
20
  return records.filter((record) => !archived.has(String(record.header.id)));
25
21
  }
26
22
 
27
- /** 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。 */
23
+ // 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。
28
24
  private archivedSessionIds(): ReadonlySet<string> {
29
25
  const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
30
26
  | { archivedSessionIds?: readonly string[] }