@morlay/session-rdb 0.1.9 → 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 (143) hide show
  1. package/README.md +24 -3
  2. package/dist/artifact.d.mts +118 -3
  3. package/dist/artifact.mjs +5 -3
  4. package/dist/{log-C0q5svm7.d.mts → backend-BKtJ5sO5.d.mts} +189 -291
  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 -3
  9. package/dist/deletion.mjs +2 -2
  10. package/dist/export-CfJQjQfr.mjs +123 -0
  11. package/dist/export.d.mts +1 -3
  12. package/dist/export.mjs +32 -57
  13. package/dist/import.d.mts +1 -3
  14. package/dist/import.mjs +32 -78
  15. package/dist/index-CUD2ZpTA.d.mts +71 -0
  16. package/dist/index.d.mts +20 -2
  17. package/dist/index.mjs +1562 -5
  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 -50
  23. package/dist/storage.mjs +3 -1
  24. package/dist/testing.d.mts +9 -5
  25. package/dist/testing.mjs +27 -8
  26. package/dist/usage-row-BQQM-_t6.mjs +36 -0
  27. package/dist/usage.d.mts +1 -1
  28. package/dist/usage.mjs +8 -42
  29. package/dist/write-guard-CYrY58GM.mjs +174 -0
  30. package/package.json +32 -36
  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} +125 -112
  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 -1630
  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 -272
  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-query.ts +17 -4
  74. package/src/session-store.ts +675 -0
  75. package/src/stats-fold.ts +44 -0
  76. package/src/storage-takeover/index.ts +2 -5
  77. package/src/storage-takeover/legacy-domain-migration.ts +118 -0
  78. package/src/storage-takeover/projection-cache.ts +139 -77
  79. package/src/storage-takeover/read-model.ts +189 -0
  80. package/src/storage-takeover/repository.ts +238 -425
  81. package/src/storage-takeover/storage-backend.ts +47 -149
  82. package/src/storage-takeover/types.ts +45 -14
  83. package/src/storage-takeover/workspace-documents.ts +65 -0
  84. package/src/storage.ts +4 -2
  85. package/src/testing.ts +21 -1
  86. package/src/usage.ts +11 -65
  87. package/src/version.ts +13 -0
  88. package/src/write-path.ts +286 -0
  89. package/dist/branch-DbFW3rJX.mjs +0 -391
  90. package/dist/deletion-ODZFcbZm.mjs +0 -2218
  91. package/dist/gc.d.mts +0 -6
  92. package/dist/gc.mjs +0 -49
  93. package/dist/log-DYxdjJ-l.mjs +0 -562
  94. package/dist/routes-DGBlBE_I.mjs +0 -10
  95. package/dist/sqlite-DjOnMRh2.mjs +0 -1777
  96. package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
  97. package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
  98. package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
  99. package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
  100. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
  101. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
  102. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
  103. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
  104. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
  105. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
  106. package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
  107. package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
  108. package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
  109. package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
  110. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
  111. package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
  112. package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
  113. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
  114. package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
  115. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
  116. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
  117. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
  118. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
  119. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
  120. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
  121. package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
  122. package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
  123. package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
  124. package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
  125. package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
  126. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
  127. package/src/adapters/index.ts +0 -12
  128. package/src/adapters/to-postgres.ts +0 -93
  129. package/src/adapters/to-sqlite.ts +0 -83
  130. package/src/adapters/types.ts +0 -44
  131. package/src/drizzle/postgres-v2.ts +0 -10
  132. package/src/drizzle/postgres-v3.ts +0 -18
  133. package/src/drizzle/sqlite-v2.ts +0 -9
  134. package/src/drizzle/sqlite-v3.ts +0 -18
  135. package/src/entities/v2/index.ts +0 -20
  136. package/src/entities/v2/session-events.ts +0 -10
  137. package/src/entities/v3/schedule-tasks.ts +0 -16
  138. package/src/entities/v3/workspace-sessions.ts +0 -19
  139. package/src/entities/v3/workspace-state.ts +0 -12
  140. package/src/entities/v3/workspaces.ts +0 -14
  141. package/src/gc.ts +0 -77
  142. package/src/postgres.ts +0 -1015
  143. package/src/sqlite.ts +0 -1086
@@ -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
+ }
@@ -9,9 +9,22 @@ import type {
9
9
  } from "@deepseek-ai/dsh-session";
10
10
  import type {
11
11
  ProjectionCheckpoint,
12
+ ProjectionKeySelection,
12
13
  ProjectionSnapshot,
13
14
  SessionProjectionMap,
14
15
  } from "@deepseek-ai/dsh-session-projection";
16
+
17
+ // 上游的 keys 选择面既接受数组也接受 `ReadonlySet`(`sessionProjections.summaryKeys()` 给的是 Set)。
18
+ // 只看一个键时两种都要认——之前只按数组写了 `.includes`,Set 上直接抛 `keys.includes is not a function`,
19
+ // 列表行于是整列投影都没有(会话标题显示"未命名")。
20
+ function selects(
21
+ keys: ProjectionKeySelection | undefined,
22
+ key: Extract<keyof SessionProjectionMap, string>,
23
+ ): boolean {
24
+ if (keys === undefined) return true;
25
+ if ("has" in keys) return keys.has(key);
26
+ return (keys as readonly string[]).includes(key);
27
+ }
15
28
  import type {
16
29
  StorageRepository,
17
30
  StoredProjcacheEntry,
@@ -33,46 +46,37 @@ type LifecycleIdentity = CheckpointIdentity & {
33
46
  isSeeded: boolean;
34
47
  };
35
48
 
36
- export interface ProjectionCacheConfig {
37
- writeEveryEvents: number;
38
-
39
- writeIntervalMs: number;
40
- }
49
+ // 没有攒批参数:投影检查点按「与已落库那行相比是否有变化」决定写不写,与事件条数 / 时间无关。
41
50
 
42
- export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
43
- repository: StorageRepository;
51
+ export interface SessionProjectionCacheRdbOptions {
52
+ /** backend 打开后才有值(构造期装配、打开后读写)。 */
53
+ repository: () => Promise<StorageRepository>;
44
54
  ready: Promise<unknown>;
45
55
  }
46
56
 
47
- interface DirtyState {
48
- pending: number;
49
- timer: ReturnType<typeof setTimeout> | undefined;
50
- }
51
-
52
57
  const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
53
58
 
54
59
  export class SessionProjectionCacheRdb extends Service {
55
60
  static inject = ["sessionProjections", "sessions"];
56
61
 
57
62
  private readonly records = new Map<SessionId, StoredProjcacheEntry>();
58
- private readonly dirty = new Map<Session, DirtyState>();
63
+ // 同一 tick 内的事件合并成一次「落库 + 检查点比对」,这不是攒批:flush 之后事件已经在库里。
64
+ private readonly queued = new Set<SessionId>();
59
65
 
60
- private readonly directReads: boolean;
61
- private readonly config: ProjectionCacheConfig;
62
- private readonly repository: StorageRepository;
66
+ private repository!: StorageRepository;
63
67
  private readonly ready: Promise<unknown>;
68
+ private readonly openRepository: () => Promise<StorageRepository>;
64
69
 
65
70
  constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
66
71
  super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
67
- this.config = options;
68
- this.repository = options.repository;
72
+ this.openRepository = options.repository;
69
73
  this.ready = options.ready;
70
- this.directReads = options.repository.readProjcacheDirect !== undefined;
71
74
  }
72
75
 
73
76
  protected async [Service.init](): Promise<void> {
74
77
  this.installWritePath();
75
78
  await this.ready;
79
+ this.repository = await this.openRepository();
76
80
 
77
81
  const pruned = await this.repository.pruneStaleProjcache();
78
82
  if (pruned > 0) {
@@ -80,15 +84,12 @@ export class SessionProjectionCacheRdb extends Service {
80
84
  `session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
81
85
  );
82
86
  }
83
- if (!this.directReads) {
84
- for (const entry of await this.repository.loadProjcache()) {
85
- this.records.set(entry.sessionId as SessionId, entry);
86
- }
87
+ for (const entry of await this.repository.loadProjcache()) {
88
+ this.records.set(entry.sessionId as SessionId, entry);
87
89
  }
88
90
  }
89
91
 
90
92
  private lookup(id: SessionId): StoredProjcacheEntry | undefined {
91
- if (this.directReads) return this.repository.readProjcacheDirect?.(id);
92
93
  return this.records.get(id);
93
94
  }
94
95
 
@@ -105,7 +106,7 @@ export class SessionProjectionCacheRdb extends Service {
105
106
  // inherited cut(那个只有写路径与 hydration 用得上)。
106
107
  cachedSnapshot(
107
108
  meta: SessionHeader,
108
- keys?: readonly Extract<keyof SessionProjectionMap, string>[],
109
+ keys?: ProjectionKeySelection,
109
110
  ): ProjectionSnapshot | undefined {
110
111
  const record = this.recordForLifecycle(meta);
111
112
  const snapshot = record === undefined ? undefined : this.viewRecord(record, keys);
@@ -121,19 +122,16 @@ export class SessionProjectionCacheRdb extends Service {
121
122
 
122
123
  private withDirectTitle(
123
124
  id: SessionId,
124
- keys: readonly Extract<keyof SessionProjectionMap, string>[] | undefined,
125
+ keys: ProjectionKeySelection | undefined,
125
126
  snapshot: ProjectionSnapshot | undefined,
126
127
  ): ProjectionSnapshot | undefined {
127
- if (keys !== undefined && !(keys as readonly string[]).includes(PREDECESSOR_TITLE_KEY)) {
128
- return snapshot;
129
- }
128
+ if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
130
129
  if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
131
- const direct = this.repository.readSessionTitleDirect?.(id);
132
- if (direct === undefined) return snapshot;
133
- const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: direct.title };
134
-
130
+ const title = this.records.get(id)?.title;
131
+ if (title === undefined) return snapshot;
132
+ const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: title.title };
135
133
  const asOfSeq =
136
- 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);
137
135
  return { asOfSeq: asOfSeq as SessionSeqCursor, values };
138
136
  }
139
137
 
@@ -150,7 +148,7 @@ export class SessionProjectionCacheRdb extends Service {
150
148
 
151
149
  private viewRecord(
152
150
  record: StoredProjcacheEntry,
153
- keys?: readonly Extract<keyof SessionProjectionMap, string>[],
151
+ keys?: ProjectionKeySelection,
154
152
  ): ProjectionSnapshot | undefined {
155
153
  const values = this.ctx.sessionProjections.viewCheckpoint(
156
154
  record.rows as unknown as ProjectionCheckpoint,
@@ -189,17 +187,63 @@ export class SessionProjectionCacheRdb extends Service {
189
187
  }
190
188
 
191
189
  async write(session: Session): Promise<void> {
192
- const rows = this.ctx.sessionProjections.checkpoint(session);
193
- 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
+ }
194
212
 
195
- if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
196
- await this.put(
213
+ const changed = changedRows(previous, rows);
214
+ const removedKeys = Object.keys(previous ?? {}).filter((key) => rows[key] === undefined);
215
+ await this.patch(
197
216
  session.id,
198
217
  identityOf(session.header, session.inheritedEventCount),
199
- rows as unknown as Record<string, ProjectionCheckpointRow>,
218
+ changed,
219
+ removedKeys,
200
220
  );
201
221
  }
202
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
+
203
247
  // rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
204
248
  // 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
205
249
  async truncateTo(
@@ -245,21 +289,8 @@ export class SessionProjectionCacheRdb extends Service {
245
289
  }
246
290
 
247
291
  private installWritePath(): void {
248
- this.ctx.on("session/event", (session: Session, event: SessionEvent) => {
249
- if (event.type === "turn/end") {
250
- void this.flushSoft(session, "turn/end");
251
- return;
252
- }
253
- const state = this.dirty.get(session) ?? { pending: 0, timer: undefined };
254
- this.dirty.set(session, state);
255
- state.pending += 1;
256
- if (state.pending >= this.config.writeEveryEvents) {
257
- void this.flushSoft(session, "count threshold");
258
- return;
259
- }
260
- state.timer ??= setTimeout(() => {
261
- void this.flushSoft(session, "interval");
262
- }, this.config.writeIntervalMs);
292
+ this.ctx.on("session/event", (session: Session) => {
293
+ this.scheduleWrite(session, "event");
263
294
  });
264
295
 
265
296
  this.ctx.on("session/created", (session: Session) => {
@@ -268,19 +299,19 @@ export class SessionProjectionCacheRdb extends Service {
268
299
 
269
300
  this.ctx.on("session/disposed", (session: Session) => {
270
301
  void this.flushSoft(session, "detach");
271
- this.markClean(session);
272
- this.dirty.delete(session);
302
+ this.queued.delete(session.id);
273
303
  });
304
+ }
274
305
 
275
- this.ctx.effect(
276
- () => () => {
277
- for (const state of this.dirty.values()) {
278
- if (state.timer !== undefined) clearTimeout(state.timer);
279
- }
280
- this.dirty.clear();
281
- },
282
- "sessionProjectionCacheRdb.timers",
283
- );
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
+ });
284
315
  }
285
316
 
286
317
  private async flushSoft(session: Session, trigger: string): Promise<void> {
@@ -293,16 +324,6 @@ export class SessionProjectionCacheRdb extends Service {
293
324
  }
294
325
  }
295
326
 
296
- private markClean(session: Session): void {
297
- const state = this.dirty.get(session);
298
- if (state === undefined) return;
299
- state.pending = 0;
300
- if (state.timer !== undefined) {
301
- clearTimeout(state.timer);
302
- state.timer = undefined;
303
- }
304
- }
305
-
306
327
  private async put(
307
328
  id: SessionId,
308
329
  identity: CheckpointIdentity,
@@ -311,9 +332,50 @@ export class SessionProjectionCacheRdb extends Service {
311
332
  const detached = detachJson(rows);
312
333
 
313
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
+ }
314
344
 
315
- if (!this.directReads) this.records.set(id, { sessionId: id, identity, rows: detached });
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
+ }
365
+
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;
316
377
  }
378
+ return true;
317
379
  }
318
380
 
319
381
  function detachJson(