@morlay/session-rdb 0.0.20 → 0.0.21-alpha.0

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 (69) hide show
  1. package/README.md +59 -31
  2. package/dist/artifact.d.mts +25 -65
  3. package/dist/artifact.mjs +2 -2
  4. package/dist/{schema-BkQN5GyT.d.mts → backend-DpdtxYpz.d.mts} +346 -93
  5. package/dist/{branch-Co6xlbi0.mjs → branch-5JzX9rUq.mjs} +163 -52
  6. package/dist/deletion.d.mts +7 -0
  7. package/dist/deletion.mjs +2 -0
  8. package/dist/dist-vIVO6bA-.mjs +1524 -0
  9. package/dist/import.d.mts +4 -4
  10. package/dist/import.mjs +28 -11
  11. package/dist/index.d.mts +2 -3
  12. package/dist/index.mjs +4 -1631
  13. package/dist/{log-DO69NQnn.mjs → log-CnYct2Dv.mjs} +37 -82
  14. package/dist/{sqlite-DYExtbLo.mjs → sqlite-fpvm5Dzs.mjs} +205 -75
  15. package/dist/src-CWTWV7vx.mjs +1904 -0
  16. package/dist/storage.d.mts +14 -5
  17. package/dist/storage.mjs +2 -2
  18. package/dist/testing.d.mts +1 -26
  19. package/dist/testing.mjs +10503 -9614
  20. package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +14 -0
  21. package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +1309 -0
  22. package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +14 -0
  23. package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +1031 -0
  24. package/package.json +28 -29
  25. package/src/adapters/to-postgres.ts +1 -3
  26. package/src/adapters/to-sqlite.ts +0 -2
  27. package/src/adapters/types.ts +0 -3
  28. package/src/artifact.ts +0 -2
  29. package/src/backend.ts +31 -2
  30. package/src/branch.ts +223 -135
  31. package/src/deletion.ts +87 -0
  32. package/src/drizzle/postgres-v2.ts +0 -1
  33. package/src/drizzle/postgres-v3.ts +0 -1
  34. package/src/drizzle/sqlite-v2.ts +0 -1
  35. package/src/drizzle/sqlite-v3.ts +0 -1
  36. package/src/entities/v2/session-events.ts +0 -1
  37. package/src/entities/v3/event-usage.ts +25 -0
  38. package/src/entities/v3/events.ts +1 -3
  39. package/src/entities/v3/index.ts +4 -0
  40. package/src/entities/v3/session-events.ts +1 -2
  41. package/src/entities/v3/session-projcache-rows.ts +0 -8
  42. package/src/entities/v3/sessions.ts +3 -3
  43. package/src/entities/v3/storage-units.ts +0 -1
  44. package/src/entities/v3/workspace-sessions.ts +0 -5
  45. package/src/entities/v3/workspace-state.ts +0 -6
  46. package/src/entities/v3/workspaces.ts +0 -5
  47. package/src/export.ts +103 -0
  48. package/src/gc.ts +76 -0
  49. package/src/import-storages.ts +4 -37
  50. package/src/import.ts +54 -31
  51. package/src/index.ts +150 -114
  52. package/src/legacy.ts +4 -42
  53. package/src/log.ts +63 -106
  54. package/src/postgres.ts +249 -22
  55. package/src/schema.ts +6 -6
  56. package/src/session-query.ts +0 -4
  57. package/src/sqlite.ts +231 -55
  58. package/src/storage-takeover/index.ts +2 -28
  59. package/src/storage-takeover/projection-cache.ts +30 -134
  60. package/src/storage-takeover/repository.ts +20 -59
  61. package/src/storage-takeover/storage-backend.ts +3 -33
  62. package/src/storage-takeover/types.ts +14 -56
  63. package/src/storage.ts +0 -2
  64. package/src/testing/contract.ts +19 -50
  65. package/src/testing/coordinator-contract.ts +10 -25
  66. package/src/testing.ts +1 -4
  67. package/src/usage.ts +191 -0
  68. package/dist/index-D55G9n4k.d.mts +0 -271
  69. package/dist/magic-string.es-BgJoa-3K.mjs +0 -1017
@@ -1,15 +1,3 @@
1
- /**
2
- * 投影 checkpoint 服务(`ctx.sessionProjectionCache`)的 rdb 实现:替换上游
3
- * `@deepseek-ai/dsh-session-projection-cache` 插件,公开面与语义逐一对齐
4
- * (cachedSnapshot / cachedPredecessorTitle / hydratePrepared / write /
5
- * coldSnapshot,以及三个强制写点与计数/定时节流),持久层换成 session-rdb
6
- * 的语义专用表 `t_session_projcache` / `t_session_projcache_row`。
7
- *
8
- * 读方法是同步签名(session 列表在请求路径上直接调用),所以服务持有启动时
9
- * 从表中加载的内存 checkpoint 表;写先落库、后更新内存,保证读到的内存值
10
- * 磁盘上一定存在。
11
- */
12
-
13
1
  import { Context, Service } from "@deepseek-ai/cordis";
14
2
  import { SessionLogOffset } from "@deepseek-ai/dsh-session";
15
3
  import type {
@@ -31,79 +19,55 @@ import type {
31
19
  ProjectionCheckpointRow,
32
20
  } from "./types.ts";
33
21
 
34
- /** 服务注册名(与上游插件一致,消费者经 `ctx.get` 解析)。 */
35
22
  export const SESSION_PROJECTION_CACHE_SERVICE = "sessionProjectionCache";
36
23
 
37
- /** 完整身份:当前世代写入的字段都必需。 */
38
24
  type CurrentCheckpointIdentity = CheckpointIdentity & {
39
25
  formatVersion: number;
40
26
  isSeeded: boolean;
41
27
  inheritedEventCount: number;
42
28
  };
43
29
 
44
- /** 缓存写节流参数(上游 Config 的部署值)。 */
45
30
  export interface ProjectionCacheConfig {
46
- /** 两次强制点之间,累积多少个已提交事件强制落一次盘。 */
47
31
  writeEveryEvents: number;
48
- /** 脏 checkpoint 在强制点之间允许滞留的最长毫秒数。 */
32
+
49
33
  writeIntervalMs: number;
50
34
  }
51
35
 
52
- /** class-plugin 装载参数:写节流 + storages 接管访问层 + 介质就绪信号。 */
53
36
  export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
54
37
  repository: StorageRepository;
55
38
  ready: Promise<unknown>;
56
39
  }
57
40
 
58
- /** 每会话写回节流簿记(只对 live session)。 */
59
41
  interface DirtyState {
60
42
  pending: number;
61
43
  timer: ReturnType<typeof setTimeout> | undefined;
62
44
  }
63
45
 
64
- /** 只认 title 的前代提示(与上游一致)。 */
65
46
  const PREDECESSOR_TITLE_KEY = "title" as Extract<keyof SessionProjectionMap, string>;
66
47
 
67
- /** rdb 持久化的投影 checkpoint 服务:同步驱动直读介质,异步驱动用写穿镜像支撑同步签名。 */
68
48
  export class SessionProjectionCacheRdb extends Service {
69
49
  static inject = ["sessionProjections", "sessions"];
70
50
 
71
- /** 异步驱动(PostgreSQL)的同步读镜像:同步驱动(SQLite)直读介质,这里恒为空。 */
72
51
  private readonly records = new Map<SessionId, StoredProjcacheEntry>();
73
52
  private readonly dirty = new Map<Session, DirtyState>();
74
- /** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
53
+
75
54
  private readonly directReads: boolean;
76
55
  private readonly config: ProjectionCacheConfig;
77
56
  private readonly repository: StorageRepository;
78
57
  private readonly ready: Promise<unknown>;
79
58
 
80
- /**
81
- * 本类按 cordis class plugin 装载(`ctx.plugin(SessionProjectionCacheRdb, options)`):
82
- * `static inject` 等依赖就绪后构造,构造后框架调用 `[Service.init]` 安装写入
83
- * 路径——直接 `new` 不会触发 init,写入路径会静默缺失。
84
- * @param ctx - 插件上下文(已注入 sessionProjections 与 sessions)。
85
- * @param options - 写节流 + storages 接管访问层 + 介质就绪信号。
86
- */
87
59
  constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
88
60
  super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
89
61
  this.config = options;
90
62
  this.repository = options.repository;
91
63
  this.ready = options.ready;
92
- this.directReads = options.repository.readProjcacheSync !== undefined;
64
+ this.directReads = options.repository.readProjcacheDirect !== undefined;
93
65
  }
94
66
 
95
- /**
96
- * Install the write path before the async medium wait: listeners must exist
97
- * from plugin activation on, or sessions created while the medium settles
98
- * lose their mandatory checkpoints. Only the async driver needs the startup
99
- * mirror; the sync driver serves every read from the table.
100
- */
101
67
  protected async [Service.init](): Promise<void> {
102
68
  this.installWritePath();
103
69
  await this.ready;
104
- // 一次性清理旧版写入路径留下的陈旧行(水位为负却已有事件):这些行会把
105
- // 有对话的会话在 cold 列表里误判为空白。缓存是派生数据,删除让会话回到
106
- // 「未知即可见」,等待下一次强制点重写。
70
+
107
71
  const pruned = await this.repository.pruneStaleProjcache();
108
72
  if (pruned > 0) {
109
73
  this.ctx.logger.info(
@@ -117,21 +81,11 @@ export class SessionProjectionCacheRdb extends Service {
117
81
  }
118
82
  }
119
83
 
120
- /** Read one session's stored record: straight from the medium, or from the async mirror. */
121
84
  private lookup(id: SessionId): StoredProjcacheEntry | undefined {
122
- if (this.directReads) return this.repository.readProjcacheSync?.(id);
85
+ if (this.directReads) return this.repository.readProjcacheDirect?.(id);
123
86
  return this.records.get(id);
124
87
  }
125
88
 
126
- /**
127
- * The stored record for one session, accepted only when its bound log
128
- * identity matches `expected`. A session id names a slot, not a lifecycle:
129
- * a recreated id or a persistence store swapped under a surviving cache
130
- * must not let an old record seed state folded from an unrelated log.
131
- * @param id - the session whose record is read.
132
- * @param expected - the log identity the caller holds (live or stored header).
133
- * @returns the identity-matching record, or `undefined` (absent or unrelated).
134
- */
135
89
  private recordFor(
136
90
  id: SessionId,
137
91
  expected: CurrentCheckpointIdentity,
@@ -141,14 +95,6 @@ export class SessionProjectionCacheRdb extends Service {
141
95
  return identityMatches(record.identity, expected) ? record : undefined;
142
96
  }
143
97
 
144
- /**
145
- * The cached projection cut for one stored (cold) or live header.
146
- * @param meta - authoritative Session header.
147
- * @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
148
- * @param keys - optional projection keys required by the caller's audience.
149
- * @returns the cut (`asOfSeq` = lowest served-row watermark), or `undefined`
150
- * when no usable row exists for this lifecycle.
151
- */
152
98
  cachedSnapshot(
153
99
  meta: SessionHeader,
154
100
  inheritedEventCount: SessionLogOffset,
@@ -159,10 +105,6 @@ export class SessionProjectionCacheRdb extends Service {
159
105
  return this.withDirectTitle(meta.id, keys, snapshot);
160
106
  }
161
107
 
162
- /**
163
- * 标题是会话数据本身(`t_sessions.f_title`,由 rdb 写路径与 rewind 维护):
164
- * checkpoint 行里没有 title 时直接取该列,列表消费不依赖缓存行是否存在。
165
- */
166
108
  private withDirectTitle(
167
109
  id: SessionId,
168
110
  keys: readonly Extract<keyof SessionProjectionMap, string>[] | undefined,
@@ -172,23 +114,15 @@ export class SessionProjectionCacheRdb extends Service {
172
114
  return snapshot;
173
115
  }
174
116
  if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
175
- const direct = this.repository.readSessionTitleSync?.(id);
117
+ const direct = this.repository.readSessionTitleDirect?.(id);
176
118
  if (direct === undefined) return snapshot;
177
119
  const values = { ...snapshot?.values, [PREDECESSOR_TITLE_KEY]: direct.title };
178
- // 水位取最低(under-claim 安全):已有块的水位与直取行取小。
120
+
179
121
  const asOfSeq =
180
122
  snapshot === undefined ? direct.seq : Math.min(snapshot.asOfSeq as number, direct.seq);
181
123
  return { asOfSeq: asOfSeq as SessionSeqCursor, values };
182
124
  }
183
125
 
184
- /**
185
- * Read only a predecessor checkpoint's title as a zero-I/O listing hint.
186
- * @param meta - authoritative listed Session header.
187
- * @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
188
- * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
189
- * when the record is current, newer, unrelated, missing, or incompatible
190
- * with the title unit.
191
- */
192
126
  cachedPredecessorTitle(
193
127
  meta: SessionHeader,
194
128
  inheritedEventCount: SessionLogOffset,
@@ -202,7 +136,6 @@ export class SessionProjectionCacheRdb extends Service {
202
136
  return title === undefined ? undefined : { ...title, asOfSeq: -1 };
203
137
  }
204
138
 
205
- /** View selected wire rows and bind them to their lowest served watermark. */
206
139
  private viewRecord(
207
140
  record: StoredProjcacheEntry,
208
141
  keys?: readonly Extract<keyof SessionProjectionMap, string>[],
@@ -213,9 +146,7 @@ export class SessionProjectionCacheRdb extends Service {
213
146
  );
214
147
  const servedKeys = Object.keys(values);
215
148
  if (servedKeys.length === 0) return undefined;
216
- // The block carries ONE cut: the lowest served watermark is the seq every
217
- // value is at least current as of (under-claiming is safe under
218
- // higher-seq-wins; over-claiming would let a stale value outrank pushes).
149
+
219
150
  const firstKey = servedKeys[0] as string;
220
151
  let asOfSeq = (record.rows[firstKey] as ProjectionCheckpointRow).seq as SessionSeqCursor;
221
152
  for (const key of servedKeys.slice(1)) {
@@ -225,15 +156,6 @@ export class SessionProjectionCacheRdb extends Service {
225
156
  return { asOfSeq, values };
226
157
  }
227
158
 
228
- /**
229
- * Hydrate projection cells for an already-prepared Session without another
230
- * persistence read. The cache seeds matching rows; the supplied exact log
231
- * advances every unit to the observation cut. No checkpoint is written
232
- * because the logical observation may contain recovery events not yet durable.
233
- * @param session - exact unpublished Session retained by persistence.
234
- * @param events - exact logical event prefix represented by the observation.
235
- * @returns all projection values at the event cut.
236
- */
237
159
  hydratePrepared(session: Session, events: readonly SessionEvent[]): ProjectionSnapshot {
238
160
  const record = this.recordFor(
239
161
  session.id,
@@ -250,26 +172,14 @@ export class SessionProjectionCacheRdb extends Service {
250
172
  SessionLogOffset(0),
251
173
  );
252
174
  } catch {
253
- // Cached rows are disposable derived data. Retry from the exact log so a
254
- // stale schema cannot make a valid Session unreadable.
255
175
  return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
256
176
  }
257
177
  }
258
178
 
259
- /**
260
- * Durably checkpoint one live session NOW (all mandatory points call this).
261
- * NOT fail-soft — callers on the fail-soft paths contain it.
262
- * @param session - the live session to checkpoint.
263
- * @returns resolution after durability.
264
- */
265
179
  async write(session: Session): Promise<void> {
266
180
  const rows = this.ctx.sessionProjections.checkpoint(session);
267
181
  this.markClean(session);
268
- // Durability barrier: the checkpoint cut was taken above, so flushing
269
- // AFTER it guarantees every event inside the cut is durably logged
270
- // before the cache row lands — a crash can leave the cache behind the
271
- // log (longer tail replay) but never ahead of it (phantom values folded
272
- // from events no stored log contains).
182
+
273
183
  if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
274
184
  await this.put(
275
185
  session.id,
@@ -278,15 +188,24 @@ export class SessionProjectionCacheRdb extends Service {
278
188
  );
279
189
  }
280
190
 
281
- /**
282
- * Cold-read one session's projections from its complete log. Each unit is
283
- * seeded from the identity-checked cached rows and the refreshed checkpoint
284
- * is written back (fail-soft, fire-and-forget).
285
- * @param meta - the stored session header (identity witness).
286
- * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity.
287
- * @param events - the session's complete log, in seq order.
288
- * @returns the projection cut at the log end.
289
- */
191
+ // rewind 后 head 回退:把水位超出新 head 的投影单元丢弃,保留仍在截断前缀内的行。
192
+ // 这是零 I/O 读的更新(不 fold 日志):下次零 I/O 读拿到的是截断后的水位,而不是旧值。
193
+ async truncateTo(
194
+ meta: SessionHeader,
195
+ inheritedEventCount: SessionLogOffset,
196
+ headSeq: number,
197
+ ): Promise<void> {
198
+ const identity = identityOf(meta, inheritedEventCount);
199
+ const record = this.recordFor(meta.id, identity);
200
+ if (record === undefined) return;
201
+ const rows = Object.fromEntries(
202
+ Object.entries(record.rows).filter(
203
+ ([, row]) => ((row as ProjectionCheckpointRow).seq as number) <= headSeq,
204
+ ),
205
+ );
206
+ await this.put(meta.id, identity, rows as unknown as Record<string, ProjectionCheckpointRow>);
207
+ }
208
+
290
209
  coldSnapshot(
291
210
  meta: SessionHeader,
292
211
  inheritedEventCount: SessionLogOffset,
@@ -300,8 +219,7 @@ export class SessionProjectionCacheRdb extends Service {
300
219
  meta,
301
220
  inheritedEventCount,
302
221
  );
303
- // Refresh the row so the next cold read seeds from it; fail-soft and
304
- // fire-and-forget — a failed write-back only costs a longer tail replay.
222
+
305
223
  void this.put(
306
224
  meta.id,
307
225
  identity,
@@ -314,12 +232,7 @@ export class SessionProjectionCacheRdb extends Service {
314
232
  return restored.snapshot;
315
233
  }
316
234
 
317
- // --- write-behind (throttle + mandatory points) ---
318
-
319
235
  private installWritePath(): void {
320
- // Every committed event advances the dirty counter; turn/end is a
321
- // mandatory point (the durable value most reads want is the turn-final
322
- // one), count/interval throttle the in-turn stream.
323
236
  this.ctx.on("session/event", (session: Session, event: SessionEvent) => {
324
237
  if (event.type === "turn/end") {
325
238
  void this.flushSoft(session, "turn/end");
@@ -337,14 +250,10 @@ export class SessionProjectionCacheRdb extends Service {
337
250
  }, this.config.writeIntervalMs);
338
251
  });
339
252
 
340
- // Creation is the FIRST mandatory point: a session that never talks (a
341
- // forked child seeded with its ancestor's title, say) would otherwise
342
- // get its first row only at detach.
343
253
  this.ctx.on("session/created", (session: Session) => {
344
254
  void this.flushSoft(session, "create");
345
255
  });
346
256
 
347
- // Detach (the live-to-cold moment): the final mandatory point.
348
257
  this.ctx.on("session/disposed", (session: Session) => {
349
258
  void this.flushSoft(session, "detach");
350
259
  this.markClean(session);
@@ -362,7 +271,6 @@ export class SessionProjectionCacheRdb extends Service {
362
271
  );
363
272
  }
364
273
 
365
- /** One fail-soft durable checkpoint. */
366
274
  private async flushSoft(session: Session, trigger: string): Promise<void> {
367
275
  try {
368
276
  await this.write(session);
@@ -373,7 +281,6 @@ export class SessionProjectionCacheRdb extends Service {
373
281
  }
374
282
  }
375
283
 
376
- /** Reset one session's dirty bookkeeping (its checkpoint is being written). */
377
284
  private markClean(session: Session): void {
378
285
  const state = this.dirty.get(session);
379
286
  if (state === undefined) return;
@@ -384,21 +291,19 @@ export class SessionProjectionCacheRdb extends Service {
384
291
  }
385
292
  }
386
293
 
387
- /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
388
294
  private async put(
389
295
  id: SessionId,
390
296
  identity: CheckpointIdentity,
391
297
  rows: Record<string, ProjectionCheckpointRow>,
392
298
  ): Promise<void> {
393
299
  const detached = detachJson(rows);
394
- // 介质只存行:checkpoint 的 identity 由会话行承载(直读时现取)。
300
+
395
301
  await this.repository.putProjcache(id, detached);
396
- // 异步驱动的镜像要 identity 才能做校验,随写入一起带上。
302
+
397
303
  if (!this.directReads) this.records.set(id, { sessionId: id, identity, rows: detached });
398
304
  }
399
305
  }
400
306
 
401
- /** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
402
307
  function detachJson(
403
308
  rows: Record<string, ProjectionCheckpointRow>,
404
309
  ): Record<string, ProjectionCheckpointRow> {
@@ -417,7 +322,6 @@ function detachJson(
417
322
  return JSON.parse(text) as Record<string, ProjectionCheckpointRow>;
418
323
  }
419
324
 
420
- /** Project a header onto the identity fields a record is bound to. */
421
325
  function identityOf(
422
326
  header: SessionHeader,
423
327
  inheritedEventCount: SessionLogOffset,
@@ -435,19 +339,12 @@ function identityOf(
435
339
  };
436
340
  }
437
341
 
438
- /**
439
- * Whether a stored record's bound identity names the caller's lifecycle.
440
- * An absent format generation cannot prove the fold semantics and never
441
- * matches. Once the format matches, absent lineage fields (records admitted
442
- * via compatible versions predate them) read as the unseeded lineage.
443
- */
444
342
  function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
445
343
  return (
446
344
  stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected)
447
345
  );
448
346
  }
449
347
 
450
- /** Match one predecessor cache record to the authoritative listed lifecycle. */
451
348
  function predecessorIdentityMatches(
452
349
  stored: CheckpointIdentity,
453
350
  expected: CurrentCheckpointIdentity,
@@ -457,7 +354,6 @@ function predecessorIdentityMatches(
457
354
  return predecessor && lifecycleIdentityMatches(stored, expected);
458
355
  }
459
356
 
460
- /** Match the format-independent fields that distinguish one Session lifecycle. */
461
357
  function lifecycleIdentityMatches(
462
358
  stored: CheckpointIdentity,
463
359
  expected: CurrentCheckpointIdentity,
@@ -1,12 +1,3 @@
1
- /**
2
- * storages 接管表的方言无关实现:SQLite 与 PostgreSQL 的 drizzle 查询构建器
3
- * 共享同一套调用形状(SQLite 走同步 `.all()`/`.get()`/`.run()`,PostgreSQL
4
- * 走 thenable),差别由本模块的执行 helper 吸收。
5
- *
6
- * workspace 域是权威数据:记录、归属、显示顺序与归档集都拆成语义表,每次写入
7
- * 在介质事务内完成(`host.writeAtomically`),不留部分应用的中间态。
8
- */
9
-
10
1
  import { randomUUID } from "node:crypto";
11
2
  import { and, eq, gte, inArray, isNotNull, lt, notInArray, sql } from "drizzle-orm";
12
3
  import {
@@ -27,36 +18,23 @@ import type {
27
18
  WorkspaceRecord,
28
19
  } from "./types.ts";
29
20
 
30
- /** 注入口:介质句柄 + 方言表对象 + 事务。 */
31
21
  export interface StorageRepositoryHost {
32
- /** 解析 drizzle 句柄;介质未就绪时调用方等待其 open 完成。 */
33
22
  db: () => Promise<unknown>;
34
- /**
35
- * 同步驱动的 drizzle 句柄(SQLite)。缺席时 {@link StorageRepository.readProjcacheSync}
36
- * 不提供:异步驱动(PostgreSQL)没有同步读,调用方只能维护写穿镜像。
37
- * @returns the open drizzle handle.
38
- */
39
- dbSync?: () => unknown;
40
- /** `toSqliteSchema` / `toPostgresSchema` 产出的方言表对象。 */
23
+
24
+ /** 不经 await、直接拿到的数据库句柄;未开放时只走异步的 `db`。 */
25
+ dbDirect?: () => unknown;
26
+
41
27
  tables: Record<string, unknown>;
42
- /**
43
- * Run `fn` inside one medium transaction: every statement issued through
44
- * {@link StorageRepositoryHost.db} while it runs joins that transaction, and
45
- * a failure rolls the whole write back.
46
- * @param fn - statements to run atomically.
47
- * @returns the callback's result.
48
- */
28
+
49
29
  writeAtomically: <T>(fn: () => Promise<T>) => Promise<T>;
50
30
  }
51
31
 
52
- /** 多行查询:SQLite 同步 `.all()`,PostgreSQL await。 */
53
32
  async function allRows<T>(query: unknown): Promise<T[]> {
54
33
  const q = query as { all?: () => T[] };
55
34
  if (typeof q.all === "function") return q.all();
56
35
  return (await query) as T[];
57
36
  }
58
37
 
59
- /** 单行查询:SQLite 同步 `.get()`,PostgreSQL await 后取首行。 */
60
38
  async function oneRow<T>(query: unknown): Promise<T | undefined> {
61
39
  const q = query as { get?: () => T | undefined };
62
40
  if (typeof q.get === "function") return q.get();
@@ -64,7 +42,6 @@ async function oneRow<T>(query: unknown): Promise<T | undefined> {
64
42
  return rows[0];
65
43
  }
66
44
 
67
- /** 写入语句:SQLite 同步 `.run()`,PostgreSQL await。 */
68
45
  async function runQuery(query: unknown): Promise<void> {
69
46
  const q = query as { run?: () => unknown };
70
47
  if (typeof q.run === "function") {
@@ -74,7 +51,6 @@ async function runQuery(query: unknown): Promise<void> {
74
51
  await query;
75
52
  }
76
53
 
77
- /** `t_sessions` 上承载 checkpoint identity 的列(与投影行 1:1,不另存一份)。 */
78
54
  interface SessionIdentityRow {
79
55
  fVersion: number;
80
56
  fCreatedAt: number;
@@ -82,7 +58,6 @@ interface SessionIdentityRow {
82
58
  fSeedLength: number | null;
83
59
  }
84
60
 
85
- /** `t_session_projcache_row` 的行形状(存储层视角)。 */
86
61
  interface ProjcacheRowRecord {
87
62
  fSessionId: string;
88
63
  fKey: string;
@@ -91,7 +66,6 @@ interface ProjcacheRowRecord {
91
66
  fVal: string;
92
67
  }
93
68
 
94
- /** 一行会话元数据 → 上游 `CheckpointIdentity`。 */
95
69
  function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
96
70
  return {
97
71
  formatVersion: row.fVersion,
@@ -102,12 +76,10 @@ function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
102
76
  };
103
77
  }
104
78
 
105
- /** 存储的整数水位 → 上游 seq cursor(-1 是空日志哨兵)。 */
106
79
  function seqCursor(seq: number): SessionSeqCursor {
107
80
  return seq === -1 ? -1 : SessionSeq(seq);
108
81
  }
109
82
 
110
- /** 两写标记的两列 → 上游 `pendingMutation`(未知操作按缺失处理)。 */
111
83
  function pendingMutationOf(row: {
112
84
  fPendingOperation: string | null;
113
85
  fPendingWorkspaceId: string | null;
@@ -117,13 +89,7 @@ function pendingMutationOf(row: {
117
89
  return { operation, workspaceId: (row.fPendingWorkspaceId ?? "") as WorkspaceId };
118
90
  }
119
91
 
120
- /**
121
- * 构建一个介质的 storages 接管访问层。
122
- * @param host - 介质句柄、方言表对象与事务执行器。
123
- * @returns 方言无关的访问层。
124
- */
125
92
  export function createStorageRepository(host: StorageRepositoryHost): StorageRepository {
126
- /* eslint-disable @typescript-eslint/no-explicit-any -- drizzle 双方言表对象只在运行期共享查询形状 */
127
93
  const tables = host.tables as Record<string, any>;
128
94
  const tUnits = tables["t_storage_units"]!;
129
95
  const tSessions = tables["t_sessions"]!;
@@ -131,7 +97,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
131
97
  const tWorkspaceSessions = tables["t_workspace_sessions"]!;
132
98
  const tWorkspaceState = tables["t_workspace_state"]!;
133
99
  const tProjcacheRows = tables["t_session_projcache_row"]!;
134
- /** checkpoint identity 的列投影:直接复用会话行,不再单存记录头。 */
100
+
135
101
  const sessionIdentity = {
136
102
  fSessionId: tSessions.fSessionId,
137
103
  fVersion: tSessions.fVersion,
@@ -139,9 +105,8 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
139
105
  fCwd: tSessions.fCwd,
140
106
  fSeedLength: tSessions.fSeedLength,
141
107
  };
142
- /* eslint-enable @typescript-eslint/no-explicit-any */
143
108
 
144
- const dbx = async (): Promise<any> => (await host.db()) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
109
+ const dbx = async (): Promise<any> => (await host.db()) as any;
145
110
 
146
111
  return {
147
112
  async readUnitVersion(name: string): Promise<number | undefined> {
@@ -209,7 +174,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
209
174
  .values(values)
210
175
  .onConflictDoUpdate({ target: tWorkspaces.fWorkspaceId, set: values }),
211
176
  );
212
- // 归属整体替换:DELETE + 有序 INSERT 在同一事务内,读不到中间态。
177
+
213
178
  await runQuery(
214
179
  db.delete(tWorkspaceSessions).where(eq(tWorkspaceSessions.fWorkspaceId, id)),
215
180
  );
@@ -287,7 +252,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
287
252
  .values(values)
288
253
  .onConflictDoUpdate({ target: tWorkspaceState.fSingleton, set: values }),
289
254
  );
290
- // 显示顺序整体替换:先清位,再按数组顺序定位(不在集合里的记录保持 -1)。
255
+
291
256
  await runQuery(db.update(tWorkspaces).set({ fPosition: -1 }));
292
257
  for (const [position, workspaceId] of state.workspaceIds.entries()) {
293
258
  await runQuery(
@@ -297,7 +262,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
297
262
  .where(eq(tWorkspaces.fWorkspaceId, workspaceId)),
298
263
  );
299
264
  }
300
- // 归档集整体替换:标记落在会话行(f_archived_at),未列出的会话清空标记。
265
+
301
266
  const archived = state.archivedSessionIds;
302
267
  await runQuery(
303
268
  archived.length === 0
@@ -314,10 +279,6 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
314
279
  );
315
280
  const stamp = Date.now();
316
281
  for (const sessionId of archived) {
317
- // 归档标记落在会话行上(f_archived_at);live 但从未物化的会话还没有
318
- // 行——补一行骨架(head=-1、无 cwd/seed),否则标记无处可写,重启后
319
- // 归档集由介质重算时会静默丢失。真正物化走 upsertSession,其冲突列
320
- // 不含 f_archived_at,所以标记在物化后仍保留。
321
282
  await runQuery(
322
283
  db
323
284
  .insert(tSessions)
@@ -338,7 +299,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
338
299
  })
339
300
  .onConflictDoUpdate({
340
301
  target: tSessions.fSessionId,
341
- // 首次归档时间保留:重复写入同一集合不刷新标记。
302
+
342
303
  set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
343
304
  }),
344
305
  );
@@ -362,7 +323,6 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
362
323
  });
363
324
  }
364
325
  for (const row of rows) {
365
- // 没有会话行的行读作缺失:checkpoint 的 identity 由会话行承载。
366
326
  const entry = entries.get(row.fSessionId);
367
327
  if (entry === undefined) continue;
368
328
  entry.rows[row.fKey] = {
@@ -371,7 +331,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
371
331
  val: JSON.parse(row.fVal),
372
332
  } satisfies ProjectionCheckpointRow;
373
333
  }
374
- // 没有投影行的会话没有 checkpoint(避免把空记录装进异步镜像)。
334
+
375
335
  return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
376
336
  },
377
337
 
@@ -421,12 +381,11 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
421
381
  return stale.length;
422
382
  },
423
383
 
424
- // 同步驱动(SQLite)才有:读路径直接查介质,进程内不维护 checkpoint 镜像。
425
- ...(host.dbSync === undefined
384
+ ...(host.dbDirect === undefined
426
385
  ? {}
427
386
  : {
428
- readProjcacheSync: (sessionId: string): StoredProjcacheEntry | undefined => {
429
- const db = host.dbSync!() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
387
+ readProjcacheDirect: (sessionId: string): StoredProjcacheEntry | undefined => {
388
+ const db = host.dbDirect!() as any;
430
389
  const stored = db
431
390
  .select()
432
391
  .from(tProjcacheRows)
@@ -438,7 +397,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
438
397
  .from(tSessions)
439
398
  .where(eq(tSessions.fSessionId, sessionId))
440
399
  .get() as (SessionIdentityRow & { fSessionId: string }) | undefined;
441
- // 没有会话行(未物化或已删)就没有可校验的 identity → 读作缺失。
400
+
442
401
  if (session === undefined) return undefined;
443
402
  const rows: ProjectionCheckpoint = {};
444
403
  for (const row of stored) {
@@ -450,8 +409,10 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
450
409
  }
451
410
  return { sessionId: SessionId(sessionId), identity: identityOfSession(session), rows };
452
411
  },
453
- readSessionTitleSync: (sessionId: string): { title: string; seq: number } | undefined => {
454
- const db = host.dbSync!() as any; // eslint-disable-line @typescript-eslint/no-explicit-any
412
+ readSessionTitleDirect: (
413
+ sessionId: string,
414
+ ): { title: string; seq: number } | undefined => {
415
+ const db = host.dbDirect!() as any;
455
416
  const row = db
456
417
  .select({ fTitle: tSessions.fTitle, fTitleSeq: tSessions.fTitleSeq })
457
418
  .from(tSessions)