@morlay/session-rdb 0.1.10-alpha.0 → 0.1.10-alpha.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 (142) hide show
  1. package/README.md +18 -3
  2. package/dist/artifact.d.mts +118 -2
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
  5. package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
  6. package/dist/branch-CG1PUlO6.mjs +833 -0
  7. package/dist/deletion-SSX7IqU8.mjs +35 -0
  8. package/dist/deletion.d.mts +1 -1
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -1
  12. package/dist/export.mjs +31 -55
  13. package/dist/import.d.mts +1 -1
  14. package/dist/import.mjs +31 -76
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1561 -4
  18. package/dist/open-YoyD_kah.mjs +561 -0
  19. package/dist/organize.d.mts +9 -0
  20. package/dist/organize.mjs +30 -0
  21. package/dist/route-DpfY0zq-.mjs +114 -0
  22. package/dist/storage.d.mts +41 -48
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -1
  25. package/dist/testing.mjs +25 -4
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +7 -40
  29. package/dist/write-guard-CYrY58GM.mjs +174 -0
  30. package/package.json +10 -14
  31. package/src/artifact.ts +4 -1
  32. package/src/backend-rdb.ts +331 -0
  33. package/src/backend.ts +32 -15
  34. package/src/{branch.ts → branch/index.ts} +109 -110
  35. package/src/branch/port.ts +62 -0
  36. package/src/branch/semantics.ts +69 -0
  37. package/src/db/open.ts +180 -0
  38. package/src/db/stats.ts +449 -0
  39. package/src/deletion-error.ts +17 -0
  40. package/src/deletion.ts +19 -72
  41. package/src/entities/index.ts +16 -1
  42. package/src/entities/v3/event-usage.ts +3 -3
  43. package/src/entities/v3/events.ts +3 -3
  44. package/src/entities/v3/index.ts +4 -16
  45. package/src/entities/v3/persistence-state.ts +3 -3
  46. package/src/entities/v3/schema-meta.ts +3 -3
  47. package/src/entities/v3/session-counts.ts +3 -3
  48. package/src/entities/v3/session-events.ts +3 -3
  49. package/src/entities/v3/session-projcache-rows.ts +3 -3
  50. package/src/entities/v3/session-usage.ts +3 -3
  51. package/src/entities/v3/sessions.ts +3 -3
  52. package/src/entities/v3/storage-records.ts +17 -0
  53. package/src/entities/v3/storage-units.ts +3 -3
  54. package/src/{schema.ts → event-shape.ts} +0 -46
  55. package/src/export.ts +32 -78
  56. package/src/handles.ts +349 -0
  57. package/src/http/route.ts +178 -0
  58. package/src/import-storages.ts +5 -2
  59. package/src/import.ts +63 -125
  60. package/src/index.ts +15 -1666
  61. package/src/legacy.ts +3 -1
  62. package/src/live-routing.ts +205 -0
  63. package/src/log/export.ts +84 -0
  64. package/src/{log.ts → log/repair.ts} +1 -268
  65. package/src/log/rows.ts +107 -0
  66. package/src/log/usage-row.ts +64 -0
  67. package/src/migrations.ts +196 -0
  68. package/src/organize.ts +38 -0
  69. package/src/read-path.ts +190 -0
  70. package/src/routes.ts +2 -1
  71. package/src/rows.ts +25 -77
  72. package/src/session-ops.ts +444 -0
  73. package/src/session-store.ts +675 -0
  74. package/src/stats-fold.ts +44 -0
  75. package/src/storage-takeover/index.ts +2 -5
  76. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  77. package/src/storage-takeover/projection-cache.ts +122 -71
  78. package/src/storage-takeover/read-model.ts +189 -0
  79. package/src/storage-takeover/repository.ts +236 -442
  80. package/src/storage-takeover/storage-backend.ts +44 -159
  81. package/src/storage-takeover/types.ts +45 -19
  82. package/src/storage-takeover/workspace-documents.ts +65 -0
  83. package/src/storage.ts +4 -2
  84. package/src/testing.ts +21 -1
  85. package/src/usage.ts +11 -65
  86. package/src/version.ts +13 -0
  87. package/src/write-path.ts +286 -0
  88. package/dist/branch-CoPCRKer.mjs +0 -401
  89. package/dist/deletion-CWl89TIR.mjs +0 -2249
  90. package/dist/gc.d.mts +0 -4
  91. package/dist/gc.mjs +0 -48
  92. package/dist/log-CcWy46VE.mjs +0 -558
  93. package/dist/routes-hZjTcX_R.mjs +0 -9
  94. package/dist/sqlite-BNRd1aqR.mjs +0 -1778
  95. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  96. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  97. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  99. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  101. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  103. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  104. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  105. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  106. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  107. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  108. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  109. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  110. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  111. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  112. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  114. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  116. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  118. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  120. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  122. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  123. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  124. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  125. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  126. package/src/adapters/index.ts +0 -12
  127. package/src/adapters/to-postgres.ts +0 -93
  128. package/src/adapters/to-sqlite.ts +0 -83
  129. package/src/adapters/types.ts +0 -44
  130. package/src/drizzle/postgres-v2.ts +0 -10
  131. package/src/drizzle/postgres-v3.ts +0 -18
  132. package/src/drizzle/sqlite-v2.ts +0 -9
  133. package/src/drizzle/sqlite-v3.ts +0 -18
  134. package/src/entities/v2/index.ts +0 -20
  135. package/src/entities/v2/session-events.ts +0 -10
  136. package/src/entities/v3/schedule-tasks.ts +0 -16
  137. package/src/entities/v3/workspace-sessions.ts +0 -19
  138. package/src/entities/v3/workspace-state.ts +0 -12
  139. package/src/entities/v3/workspaces.ts +0 -14
  140. package/src/gc.ts +0 -77
  141. package/src/postgres.ts +0 -1015
  142. package/src/sqlite.ts +0 -1096
@@ -0,0 +1,44 @@
1
+ import type { SessionEvent } from "@deepseek-ai/dsh-session";
2
+ import type { SessionCountBucket, SessionUsageBucket } from "./backend.ts";
3
+ import type { EventUsageRow } from "./log/usage-row.ts";
4
+ import { COUNTED_EVENT_TYPES, addActivityCount, localDayKey } from "./usage.ts";
5
+
6
+ // 一批事件折成活动计数的桶:只数四种类型,按本地日聚合成四项。
7
+ export function sessionCountBuckets(events: readonly SessionEvent[]): SessionCountBucket[] {
8
+ const counted = new Map<string, SessionCountBucket>();
9
+ for (const event of events) {
10
+ if (!(COUNTED_EVENT_TYPES as readonly string[]).includes(event.type)) continue;
11
+ const day = localDayKey(event.time);
12
+ const bucket = counted.get(day) ?? { day, turns: 0, steps: 0, userInputs: 0, toolCalls: 0 };
13
+ addActivityCount(bucket, event.type, 1);
14
+ counted.set(day, bucket);
15
+ }
16
+ return [...counted.values()];
17
+ }
18
+
19
+ // 一批用量行折成 token 桶:按「本地日 × 模型」聚合(模型未知落空串,与表口径一致)。
20
+ export function sessionUsageBuckets(usageRows: readonly EventUsageRow[]): SessionUsageBucket[] {
21
+ const counted = new Map<string, SessionUsageBucket>();
22
+ for (const row of usageRows) {
23
+ const provider = row.fProvider ?? "";
24
+ const model = row.fModel ?? "";
25
+ const key = `${row.fDay}#${provider}#${model}`;
26
+ const bucket = counted.get(key) ?? {
27
+ day: row.fDay,
28
+ provider,
29
+ model,
30
+ inputTokens: 0,
31
+ outputTokens: 0,
32
+ cacheReadTokens: 0,
33
+ reasoningTokens: 0,
34
+ totalTokens: 0,
35
+ };
36
+ bucket.inputTokens += row.fInputTokens;
37
+ bucket.outputTokens += row.fOutputTokens;
38
+ bucket.cacheReadTokens += row.fCacheReadTokens;
39
+ bucket.reasoningTokens += row.fReasoningTokens;
40
+ bucket.totalTokens += row.fTotalTokens;
41
+ counted.set(key, bucket);
42
+ }
43
+ return [...counted.values()];
44
+ }
@@ -1,14 +1,12 @@
1
1
  import type { Context } from "@deepseek-ai/cordis";
2
2
  import { storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
3
- import type { ProjectionCacheConfig } from "./projection-cache.ts";
4
3
  import { SessionProjectionCacheRdb } from "./projection-cache.ts";
5
4
  import { RDB_STORAGE_BACKEND, RdbStorageBackend } from "./storage-backend.ts";
6
5
  import type { StorageRepository } from "./types.ts";
7
6
 
8
7
  export interface StorageTakeoverOptions {
9
- repository: StorageRepository;
10
-
11
- projectionCache: ProjectionCacheConfig;
8
+ /** backend 打开后才有值:接管在构造期完成,真正的读写在打开后发生。 */
9
+ repository: () => Promise<StorageRepository>;
12
10
 
13
11
  ready: Promise<unknown>;
14
12
  }
@@ -27,7 +25,6 @@ export function installStorageTakeover(ctx: Context, options: StorageTakeoverOpt
27
25
  });
28
26
 
29
27
  ctx.plugin(SessionProjectionCacheRdb, {
30
- ...options.projectionCache,
31
28
  repository: options.repository,
32
29
  ready: options.ready,
33
30
  });
@@ -0,0 +1,118 @@
1
+ // 一次性搬运:旧版把 workspace / schedule / cua 授权域拆成各自的语义表,现在所有域只落
2
+ // `t_storage_records`。旧表保留不删(可回退),这里把数据读成上游的文档形状写进新表。
3
+ //
4
+ // 幂等由调用方用 `t_schema_meta` 的标记控制:搬过一次就不再搬,否则用户清空工作区后重启会把旧数据搬回来。
5
+ import type { SessionId } from "@deepseek-ai/dsh-session";
6
+ import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
7
+ import type { StorageRepository } from "./types.ts";
8
+
9
+ export interface LegacyWorkspaceRow {
10
+ id: string;
11
+ path: string;
12
+ title: string;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ position: number;
16
+ }
17
+
18
+ export interface LegacyLinkRow {
19
+ workspaceId: string;
20
+ sessionId: string;
21
+ position: number;
22
+ }
23
+
24
+ export interface LegacyWorkspaceStateRow {
25
+ initialized: number | boolean;
26
+ pendingOperation: string | null;
27
+ pendingWorkspaceId: string | null;
28
+ }
29
+
30
+ export interface LegacyScheduleRow {
31
+ id: string;
32
+ sessionId: string;
33
+ status: string;
34
+ record: string;
35
+ lastDelivery: string | null;
36
+ deliveryHistory: string | null;
37
+ }
38
+
39
+ export interface LegacyGlobalRow {
40
+ domain: string;
41
+ value: unknown;
42
+ }
43
+
44
+ export interface LegacyStorageRows {
45
+ workspaces: LegacyWorkspaceRow[];
46
+ links: LegacyLinkRow[];
47
+ state: LegacyWorkspaceStateRow | undefined;
48
+ archived: string[];
49
+ pinned: Array<{ id: string; seq: number }>;
50
+ tasks: LegacyScheduleRow[];
51
+ globals: LegacyGlobalRow[];
52
+ }
53
+
54
+ export const LEGACY_STORAGE_MIGRATION_KEY = "legacy_domain_storage_migrated";
55
+
56
+ /**
57
+ * 把旧语义表的内容写进通用域存储(`workspace` / `schedule` / 上一版 `t_storage_globals` 的域)。
58
+ * @param rows - 旧表读出来的行。
59
+ * @param repository - 通用域存储。
60
+ */
61
+ export async function migrateLegacyDomainStorage(
62
+ rows: LegacyStorageRows,
63
+ repository: StorageRepository,
64
+ ): Promise<void> {
65
+ if (rows.workspaces.length > 0 || rows.state !== undefined) {
66
+ for (const row of rows.workspaces) {
67
+ const sessionIds = rows.links
68
+ .filter((link) => link.workspaceId === row.id)
69
+ .sort((left, right) => left.position - right.position)
70
+ .map((link) => link.sessionId as SessionId);
71
+ await repository.putDomainRecord("workspace", "workspaces", row.id, {
72
+ path: row.path,
73
+ title: row.title,
74
+ sessionIds,
75
+ createdAt: row.createdAt,
76
+ updatedAt: row.updatedAt,
77
+ });
78
+ }
79
+ const pending = rows.state?.pendingOperation;
80
+ const pendingMutation =
81
+ pending === "create" || pending === "delete"
82
+ ? {
83
+ operation: pending,
84
+ workspaceId: (rows.state?.pendingWorkspaceId ?? "") as WorkspaceId,
85
+ }
86
+ : undefined;
87
+ await repository.writeDomainGlobal("workspace", {
88
+ initialized: rows.state === undefined ? true : Boolean(rows.state.initialized),
89
+ workspaceIds: rows.workspaces
90
+ .filter((row) => row.position >= 0)
91
+ .sort((left, right) => left.position - right.position)
92
+ .map((row) => row.id as WorkspaceId),
93
+ archivedSessionIds: rows.archived.map((id) => id as SessionId),
94
+ pinnedSessionIds: rows.pinned
95
+ .sort((left, right) => left.seq - right.seq)
96
+ .map((row) => row.id as SessionId),
97
+ ...(pendingMutation === undefined ? {} : { pendingMutation }),
98
+ });
99
+ await repository.insertUnitVersion("workspace", 2);
100
+ }
101
+
102
+ for (const row of rows.tasks) {
103
+ await repository.putDomainRecord("schedule", "tasks", row.id, {
104
+ sessionId: row.sessionId as SessionId,
105
+ record: JSON.parse(row.record),
106
+ status: row.status === "inactive" ? "inactive" : "active",
107
+ ...(row.lastDelivery === null ? {} : { lastDelivery: JSON.parse(row.lastDelivery) }),
108
+ ...(row.deliveryHistory === null ? {} : { deliveryHistory: JSON.parse(row.deliveryHistory) }),
109
+ });
110
+ }
111
+ if (rows.tasks.length > 0) await repository.insertUnitVersion("schedule", 1);
112
+
113
+ // 上一版只有一个域一张的 `t_storage_globals`(cua 授权域),搬进同一张通用表。
114
+ for (const row of rows.globals) {
115
+ await repository.writeDomainGlobal(row.domain, row.value);
116
+ await repository.insertUnitVersion(row.domain, 1);
117
+ }
118
+ }
@@ -46,46 +46,37 @@ type LifecycleIdentity = CheckpointIdentity & {
46
46
  isSeeded: boolean;
47
47
  };
48
48
 
49
- export interface ProjectionCacheConfig {
50
- writeEveryEvents: number;
49
+ // 没有攒批参数:投影检查点按「与已落库那行相比是否有变化」决定写不写,与事件条数 / 时间无关。
51
50
 
52
- writeIntervalMs: number;
53
- }
54
-
55
- export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
56
- repository: StorageRepository;
51
+ export interface SessionProjectionCacheRdbOptions {
52
+ /** backend 打开后才有值(构造期装配、打开后读写)。 */
53
+ repository: () => Promise<StorageRepository>;
57
54
  ready: Promise<unknown>;
58
55
  }
59
56
 
60
- interface DirtyState {
61
- pending: number;
62
- timer: ReturnType<typeof setTimeout> | undefined;
63
- }
64
-
65
57
  const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
66
58
 
67
59
  export class SessionProjectionCacheRdb extends Service {
68
60
  static inject = ["sessionProjections", "sessions"];
69
61
 
70
62
  private readonly records = new Map<SessionId, StoredProjcacheEntry>();
71
- private readonly dirty = new Map<Session, DirtyState>();
63
+ // 同一 tick 内的事件合并成一次「落库 + 检查点比对」,这不是攒批:flush 之后事件已经在库里。
64
+ private readonly queued = new Set<SessionId>();
72
65
 
73
- private readonly directReads: boolean;
74
- private readonly config: ProjectionCacheConfig;
75
- private readonly repository: StorageRepository;
66
+ private repository!: StorageRepository;
76
67
  private readonly ready: Promise<unknown>;
68
+ private readonly openRepository: () => Promise<StorageRepository>;
77
69
 
78
70
  constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
79
71
  super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
80
- this.config = options;
81
- this.repository = options.repository;
72
+ this.openRepository = options.repository;
82
73
  this.ready = options.ready;
83
- this.directReads = options.repository.readProjcacheDirect !== undefined;
84
74
  }
85
75
 
86
76
  protected async [Service.init](): Promise<void> {
87
77
  this.installWritePath();
88
78
  await this.ready;
79
+ this.repository = await this.openRepository();
89
80
 
90
81
  const pruned = await this.repository.pruneStaleProjcache();
91
82
  if (pruned > 0) {
@@ -93,15 +84,12 @@ export class SessionProjectionCacheRdb extends Service {
93
84
  `session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
94
85
  );
95
86
  }
96
- if (!this.directReads) {
97
- for (const entry of await this.repository.loadProjcache()) {
98
- this.records.set(entry.sessionId as SessionId, entry);
99
- }
87
+ for (const entry of await this.repository.loadProjcache()) {
88
+ this.records.set(entry.sessionId as SessionId, entry);
100
89
  }
101
90
  }
102
91
 
103
92
  private lookup(id: SessionId): StoredProjcacheEntry | undefined {
104
- if (this.directReads) return this.repository.readProjcacheDirect?.(id);
105
93
  return this.records.get(id);
106
94
  }
107
95
 
@@ -139,12 +127,11 @@ export class SessionProjectionCacheRdb extends Service {
139
127
  ): ProjectionSnapshot | undefined {
140
128
  if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
141
129
  if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
142
- const direct = this.repository.readSessionTitleDirect?.(id);
143
- if (direct === undefined) return snapshot;
144
- const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: direct.title };
145
-
130
+ const title = this.records.get(id)?.title;
131
+ if (title === undefined) return snapshot;
132
+ const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: title.title };
146
133
  const asOfSeq =
147
- snapshot === undefined ? direct.seq : Math.min(snapshot.asOfSeq as number, direct.seq);
134
+ snapshot === undefined ? title.seq : Math.min(snapshot.asOfSeq as number, title.seq);
148
135
  return { asOfSeq: asOfSeq as SessionSeqCursor, values };
149
136
  }
150
137
 
@@ -200,17 +187,63 @@ export class SessionProjectionCacheRdb extends Service {
200
187
  }
201
188
 
202
189
  async write(session: Session): Promise<void> {
203
- const rows = this.ctx.sessionProjections.checkpoint(session);
204
- this.markClean(session);
190
+ // 先比差异,**有变化才碰 SQLite**:没变化直接返回,既不写检查点、也不推上游 flush
191
+ //(事件落库的主节奏是上游 handle 的批量窗口,逐条 flush 会把写放大到每秒数十次——
192
+ // 见 [设计 写路径](../designs/20260917-写路径.md)「否掉的选项」)。
193
+ const rows = this.ctx.sessionProjections.checkpoint(session) as unknown as Record<
194
+ string,
195
+ ProjectionCheckpointRow
196
+ >;
197
+ const previous = this.lookup(session.id)?.rows;
198
+ if (previous !== undefined && sameCheckpoint(previous, rows)) return;
199
+
200
+ if (this.ctx.sessions.get(session.id) === session) {
201
+ // 检查点是内存 log 的快照:先把 live 事件落库再写检查点,但 **flush 失败不该阻断检查点**——
202
+ // 否则撤回后缓存会停在旧水位,读路径一直显示撤回前的数据(要重启实例才从库里重新加载)。
203
+ // flush 失败本身由上游记账,这里只补一条 warn。
204
+ try {
205
+ await this.ctx.sessions.flush(session);
206
+ } catch (error: unknown) {
207
+ this.ctx.logger.warn(
208
+ `session projection cache: flush before checkpoint for "${session.id}" failed (checkpoint still written from the in-memory log): ${error instanceof Error ? error.message : String(error)}`,
209
+ );
210
+ }
211
+ }
205
212
 
206
- if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
207
- await this.put(
213
+ const changed = changedRows(previous, rows);
214
+ const removedKeys = Object.keys(previous ?? {}).filter((key) => rows[key] === undefined);
215
+ await this.patch(
208
216
  session.id,
209
217
  identityOf(session.header, session.inheritedEventCount),
210
- rows as unknown as Record<string, ProjectionCheckpointRow>,
218
+ changed,
219
+ removedKeys,
211
220
  );
212
221
  }
213
222
 
223
+ // 增量写回:只 upsert 变化的行、删掉消失的 key,其余行不碰(整份删重插是纯写放大)。
224
+ private async patch(
225
+ id: SessionId,
226
+ identity: CheckpointIdentity,
227
+ changed: Record<string, ProjectionCheckpointRow>,
228
+ removedKeys: readonly string[],
229
+ ): Promise<void> {
230
+ const detachedChanged = detachJson(changed);
231
+ await this.repository.patchProjcache(id, detachedChanged, removedKeys);
232
+
233
+ const merged: Record<string, ProjectionCheckpointRow> = {};
234
+ const previous = this.records.get(id)?.rows;
235
+ if (previous !== undefined) Object.assign(merged, previous);
236
+ Object.assign(merged, detachedChanged);
237
+ for (const key of removedKeys) delete merged[key];
238
+ const title = await this.repository.readSessionTitle(id);
239
+ this.records.set(id, {
240
+ sessionId: id,
241
+ identity,
242
+ rows: merged,
243
+ ...(title === undefined ? {} : { title }),
244
+ });
245
+ }
246
+
214
247
  // rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
215
248
  // 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
216
249
  async truncateTo(
@@ -256,21 +289,8 @@ export class SessionProjectionCacheRdb extends Service {
256
289
  }
257
290
 
258
291
  private installWritePath(): void {
259
- this.ctx.on("session/event", (session: Session, event: SessionEvent) => {
260
- if (event.type === "turn/end") {
261
- void this.flushSoft(session, "turn/end");
262
- return;
263
- }
264
- const state = this.dirty.get(session) ?? { pending: 0, timer: undefined };
265
- this.dirty.set(session, state);
266
- state.pending += 1;
267
- if (state.pending >= this.config.writeEveryEvents) {
268
- void this.flushSoft(session, "count threshold");
269
- return;
270
- }
271
- state.timer ??= setTimeout(() => {
272
- void this.flushSoft(session, "interval");
273
- }, this.config.writeIntervalMs);
292
+ this.ctx.on("session/event", (session: Session) => {
293
+ this.scheduleWrite(session, "event");
274
294
  });
275
295
 
276
296
  this.ctx.on("session/created", (session: Session) => {
@@ -279,19 +299,19 @@ export class SessionProjectionCacheRdb extends Service {
279
299
 
280
300
  this.ctx.on("session/disposed", (session: Session) => {
281
301
  void this.flushSoft(session, "detach");
282
- this.markClean(session);
283
- this.dirty.delete(session);
302
+ this.queued.delete(session.id);
284
303
  });
304
+ }
285
305
 
286
- this.ctx.effect(
287
- () => () => {
288
- for (const state of this.dirty.values()) {
289
- if (state.timer !== undefined) clearTimeout(state.timer);
290
- }
291
- this.dirty.clear();
292
- },
293
- "sessionProjectionCacheRdb.timers",
294
- );
306
+ // 同一 tick 的多条事件合成一次:flush 一次、checkpoint 取一次、diff 一次。
307
+ // 落库时机不再取决于事件条数或定时器——事件一到就写库,检查点则只在真的变了才写。
308
+ private scheduleWrite(session: Session, trigger: string): void {
309
+ if (this.queued.has(session.id)) return;
310
+ this.queued.add(session.id);
311
+ queueMicrotask(() => {
312
+ this.queued.delete(session.id);
313
+ void this.flushSoft(session, trigger);
314
+ });
295
315
  }
296
316
 
297
317
  private async flushSoft(session: Session, trigger: string): Promise<void> {
@@ -304,16 +324,6 @@ export class SessionProjectionCacheRdb extends Service {
304
324
  }
305
325
  }
306
326
 
307
- private markClean(session: Session): void {
308
- const state = this.dirty.get(session);
309
- if (state === undefined) return;
310
- state.pending = 0;
311
- if (state.timer !== undefined) {
312
- clearTimeout(state.timer);
313
- state.timer = undefined;
314
- }
315
- }
316
-
317
327
  private async put(
318
328
  id: SessionId,
319
329
  identity: CheckpointIdentity,
@@ -322,9 +332,50 @@ export class SessionProjectionCacheRdb extends Service {
322
332
  const detached = detachJson(rows);
323
333
 
324
334
  await this.repository.putProjcache(id, detached);
335
+ const title = await this.repository.readSessionTitle(id);
336
+ this.records.set(id, {
337
+ sessionId: id,
338
+ identity,
339
+ rows: detached,
340
+ ...(title === undefined ? {} : { title }),
341
+ });
342
+ }
343
+ }
344
+
345
+ // 「有没有变化」的判据:逐行比 `ver` 与 `val`,**不比 `seq`**。`seq` 是「最后折入的事件」水位,
346
+ // 上游投影对每个事件都会推进它(哪怕 apply 返回同一份 state),拿它当判据等于每个事件都写一次。
347
+ // 值没变就跳过写回:读侧仍会从旧水位重放那几条事件,得到同样的值——缓存要的是「值对不对」,
348
+ // 不是「水位最新」。`val` 是纯 JSON(写入前已 detach),逐键序列化比较即可。
349
+ function sameRow(before: ProjectionCheckpointRow, after: ProjectionCheckpointRow): boolean {
350
+ return before.ver === after.ver && JSON.stringify(before.val) === JSON.stringify(after.val);
351
+ }
352
+
353
+ // 只挑出真的变了的行:写库只碰这些行,其余行原样留在库里(不删不重插)。
354
+ function changedRows(
355
+ previous: Record<string, ProjectionCheckpointRow> | undefined,
356
+ next: Record<string, ProjectionCheckpointRow>,
357
+ ): Record<string, ProjectionCheckpointRow> {
358
+ const changed: Record<string, ProjectionCheckpointRow> = {};
359
+ for (const [key, row] of Object.entries(next)) {
360
+ const before = previous?.[key];
361
+ if (before === undefined || !sameRow(before, row)) changed[key] = row;
362
+ }
363
+ return changed;
364
+ }
325
365
 
326
- if (!this.directReads) this.records.set(id, { sessionId: id, identity, rows: detached });
366
+ function sameCheckpoint(
367
+ previous: Record<string, ProjectionCheckpointRow>,
368
+ next: Record<string, ProjectionCheckpointRow>,
369
+ ): boolean {
370
+ const keys = Object.keys(next);
371
+ if (keys.length !== Object.keys(previous).length) return false;
372
+ for (const key of keys) {
373
+ const before = previous[key];
374
+ const after = next[key];
375
+ if (before === undefined || after === undefined) return false;
376
+ if (!sameRow(before, after)) return false;
327
377
  }
378
+ return true;
328
379
  }
329
380
 
330
381
  function detachJson(
@@ -0,0 +1,189 @@
1
+ import type { Database } from "@morlay/rdbkit";
2
+ import {
3
+ and,
4
+ E,
5
+ eq,
6
+ gte,
7
+ isNull,
8
+ lt,
9
+ notInSubquery,
10
+ or,
11
+ type Condition,
12
+ type ExprOperand,
13
+ } from "@morlay/rdbkit";
14
+ import type { EventRow, ReadModel, SessionRow } from "../backend.ts";
15
+ import { events, sessionEvents, sessions } from "../entities/index.ts";
16
+
17
+ export type { ReadModel };
18
+
19
+ export function createReadModel(database: Database): ReadModel {
20
+ const sessionsTable = sessions.table(database);
21
+ const eventsTable = events.table(database);
22
+ const links = sessionEvents.table(database);
23
+
24
+ return {
25
+ async getSession(id) {
26
+ for await (const row of sessionsTable.select().where(eq(sessionsTable.f_session_id, id))) {
27
+ return sessionRowOf(row);
28
+ }
29
+ return undefined;
30
+ },
31
+
32
+ async getEventRows(id, fromSequence) {
33
+ const rows: EventRow[] = [];
34
+ const condition =
35
+ fromSequence === undefined
36
+ ? eq(links.f_session_id, id)
37
+ : and(eq(links.f_session_id, id), gte(links.f_sequence, fromSequence));
38
+ for await (const row of links
39
+ .select({
40
+ fEventId: links.f_event_id,
41
+ fSequence: links.f_sequence,
42
+ fType: eventsTable.f_type,
43
+ fKind: eventsTable.f_kind,
44
+ fRole: eventsTable.f_role,
45
+ fName: eventsTable.f_name,
46
+ fActionId: eventsTable.f_action_id,
47
+ fCreatedAt: eventsTable.f_created_at,
48
+ fData: eventsTable.f_data,
49
+ fSurfaceOp: links.f_surface_op,
50
+ })
51
+ .join(events, eq(eventsTable.f_event_id, links.f_event_id))
52
+ .where(condition)
53
+ .orderBy("f_sequence")) {
54
+ rows.push(eventRowOf(row));
55
+ }
56
+ return rows;
57
+ },
58
+
59
+ async getEventTypeAt(id, sequence) {
60
+ for await (const row of links
61
+ .select({ fType: eventsTable.f_type })
62
+ .join(events, eq(eventsTable.f_event_id, links.f_event_id))
63
+ .where(and(eq(links.f_session_id, id), eq(links.f_sequence, sequence)))
64
+ .limit(1)) {
65
+ return row.fType;
66
+ }
67
+ return undefined;
68
+ },
69
+
70
+ async getEventTypesBefore(id, beforeSequence, limit) {
71
+ const rows: Array<Pick<EventRow, "fSequence" | "fType">> = [];
72
+ for await (const row of links
73
+ .select({ fSequence: links.f_sequence, fType: eventsTable.f_type })
74
+ .join(events, eq(eventsTable.f_event_id, links.f_event_id))
75
+ .where(and(eq(links.f_session_id, id), lt(links.f_sequence, beforeSequence)))
76
+ .orderBy("f_sequence", "desc")
77
+ .limit(limit)) {
78
+ rows.push({ fSequence: Number(row.fSequence), fType: row.fType });
79
+ }
80
+ return rows;
81
+ },
82
+
83
+ async listSessions() {
84
+ const rows: SessionRow[] = [];
85
+ for await (const row of sessionsTable.select()) rows.push(sessionRowOf(row));
86
+ return rows;
87
+ },
88
+
89
+ async latestTitleEvent(id) {
90
+ for await (const row of links
91
+ .select({ fSequence: links.f_sequence, fData: eventsTable.f_data })
92
+ .join(events, eq(eventsTable.f_event_id, links.f_event_id))
93
+ .where(and(eq(links.f_session_id, id), eq(eventsTable.f_type, "session/title")))
94
+ .orderBy("f_sequence", "desc")
95
+ .limit(1)) {
96
+ return { fSequence: Number(row.fSequence), fData: row.fData };
97
+ }
98
+ return undefined;
99
+ },
100
+
101
+ async listOrphanSubagentSessions() {
102
+ const ids: string[] = [];
103
+ for await (const row of sessionsTable
104
+ .select({ id: sessionsTable.f_session_id })
105
+ .where(
106
+ and(
107
+ eq(sessionsTable.f_origin, "subagent"),
108
+ or(
109
+ isNull(sessionsTable.f_parent_session),
110
+ notInSubquery(
111
+ sessionsTable.f_parent_session,
112
+ sessionsTable.select({ id: sessionsTable.f_session_id }).toFragment(),
113
+ ),
114
+ ),
115
+ ),
116
+ )) {
117
+ ids.push(row.id);
118
+ }
119
+ return ids;
120
+ },
121
+ };
122
+ }
123
+
124
+ /** `f_last_event_at` 的「只增不减」(两方言同一份表达式,值走绑定)。 */
125
+ export function lastEventAtExpr(value: number): ExprOperand<number> {
126
+ return E.maxOf(E.column("f_last_event_at"), value);
127
+ }
128
+
129
+ /** header 的「变了才写」条件:逐列 `IS DISTINCT FROM` 的 OR(值全部走绑定参数)。 */
130
+ export function headerChangedCondition(
131
+ header: Readonly<Record<string, string | number | null>>,
132
+ ): Condition {
133
+ return E.or(
134
+ ...Object.entries(header).map(([name, value]) =>
135
+ E.isDistinctFrom(E.column(name, "t_sessions"), value),
136
+ ),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * 事件行的数值列统一收回 number:`f_created_at` 在表定义里是 `bigint`(rdbkit 读回 BigInt),
142
+ * 事件坐标与毫秒时间都在安全整数范围内。
143
+ */
144
+ function eventRowOf(row: {
145
+ fEventId: string;
146
+ fSequence: number | bigint;
147
+ fType: string;
148
+ fKind: string;
149
+ fRole: string;
150
+ fName: string;
151
+ fActionId: string;
152
+ fCreatedAt: number | bigint;
153
+ fData: string;
154
+ fSurfaceOp: string | null;
155
+ }): EventRow {
156
+ return {
157
+ fEventId: row.fEventId,
158
+ fSequence: Number(row.fSequence),
159
+ fType: row.fType,
160
+ fKind: row.fKind,
161
+ fRole: row.fRole,
162
+ fName: row.fName,
163
+ fActionId: row.fActionId,
164
+ fCreatedAt: Number(row.fCreatedAt),
165
+ fData: row.fData,
166
+ fSurfaceOp: row.fSurfaceOp,
167
+ };
168
+ }
169
+
170
+ /** 列名原样 → 会话行的 camelCase(列名由表定义固定,这里是唯一一处映射)。 */
171
+ export function sessionRowOf(row: Record<string, unknown>): SessionRow { return {
172
+ fSessionId: String(row["f_session_id"]),
173
+ fHeadEventId: String(row["f_head_event_id"]),
174
+ fHeadSequence: Number(row["f_head_sequence"]),
175
+ fVersion: Number(row["f_version"]),
176
+ fCreatedAt: Number(row["f_created_at"]),
177
+ fCwd: (row["f_cwd"] as string | null) ?? null,
178
+ fParentSession: (row["f_parent_session"] as string | null) ?? null,
179
+ fSeedLength: row["f_seed_length"] === null ? null : Number(row["f_seed_length"]),
180
+ fOrigin: (row["f_origin"] as string | null) ?? null,
181
+ fDelegationDepth: row["f_delegation_depth"] === null ? null : Number(row["f_delegation_depth"]),
182
+ fAgentPreset: (row["f_agent_preset"] as string | null) ?? null,
183
+ fIncarnation: String(row["f_incarnation"]),
184
+ fRevision: Number(row["f_revision"]),
185
+ fArchivedAt: row["f_archived_at"] === null ? null : Number(row["f_archived_at"]),
186
+ fPinnedSeq: row["f_pinned_seq"] === null ? null : Number(row["f_pinned_seq"]),
187
+ fLastEventAt: row["f_last_event_at"] === null ? null : Number(row["f_last_event_at"]),
188
+ };
189
+ }