@morlay/session-rdb 0.0.18 → 0.0.20

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 (32) hide show
  1. package/dist/artifact.d.mts +1 -1
  2. package/dist/artifact.mjs +1 -1
  3. package/dist/{import-DZIAjOUr.mjs → branch-Co6xlbi0.mjs} +1 -186
  4. package/dist/import.d.mts +3 -2
  5. package/dist/import.mjs +197 -1
  6. package/dist/{index-BEKoV1Uy.d.mts → index-D55G9n4k.d.mts} +4 -3
  7. package/dist/index.d.mts +2 -2
  8. package/dist/index.mjs +94 -39
  9. package/dist/{magic-string.es-BWtG0AyA.mjs → magic-string.es-BgJoa-3K.mjs} +2 -2
  10. package/dist/{schema-Bo6Hy5gW.d.mts → schema-BkQN5GyT.d.mts} +36 -47
  11. package/dist/{sqlite-BIPdMNQb.mjs → sqlite-DYExtbLo.mjs} +27 -6
  12. package/dist/storage.d.mts +1 -1
  13. package/dist/storage.mjs +1 -1
  14. package/dist/testing.d.mts +2 -1
  15. package/dist/testing.mjs +22 -21
  16. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +25 -78
  17. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +25 -78
  18. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +25 -78
  19. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +25 -78
  20. package/package.json +4 -3
  21. package/src/entities/v3/workspace-state.ts +1 -1
  22. package/src/import-storages.ts +8 -3
  23. package/src/import.ts +41 -4
  24. package/src/index.ts +10 -2
  25. package/src/postgres.ts +15 -9
  26. package/src/session-query.ts +27 -0
  27. package/src/storage-takeover/index.ts +8 -8
  28. package/src/storage-takeover/projection-cache.ts +50 -24
  29. package/src/storage-takeover/repository.ts +66 -20
  30. package/src/storage-takeover/storage-backend.ts +65 -21
  31. package/src/storage-takeover/types.ts +11 -3
  32. package/src/testing.ts +3 -0
@@ -2,9 +2,7 @@
2
2
  "version": "7",
3
3
  "dialect": "sqlite",
4
4
  "id": "9f0f3ef7-6951-441e-ac74-b4eb613de8b4",
5
- "prevIds": [
6
- "1d8acf30-2759-4afb-9aa6-81ee2b470237"
7
- ],
5
+ "prevIds": ["1d8acf30-2759-4afb-9aa6-81ee2b470237"],
8
6
  "ddl": [
9
7
  {
10
8
  "name": "t_events",
@@ -357,13 +355,9 @@
357
355
  "table": "t_sessions"
358
356
  },
359
357
  {
360
- "columns": [
361
- "f_session_id"
362
- ],
358
+ "columns": ["f_session_id"],
363
359
  "tableTo": "t_sessions",
364
- "columnsTo": [
365
- "f_session_id"
366
- ],
360
+ "columnsTo": ["f_session_id"],
367
361
  "onUpdate": "NO ACTION",
368
362
  "onDelete": "CASCADE",
369
363
  "nameExplicit": false,
@@ -372,13 +366,9 @@
372
366
  "table": "t_session_events"
373
367
  },
374
368
  {
375
- "columns": [
376
- "f_event_id"
377
- ],
369
+ "columns": ["f_event_id"],
378
370
  "tableTo": "t_events",
379
- "columnsTo": [
380
- "f_event_id"
381
- ],
371
+ "columnsTo": ["f_event_id"],
382
372
  "onUpdate": "NO ACTION",
383
373
  "onDelete": "CASCADE",
384
374
  "nameExplicit": false,
@@ -387,45 +377,35 @@
387
377
  "table": "t_session_events"
388
378
  },
389
379
  {
390
- "columns": [
391
- "f_id"
392
- ],
380
+ "columns": ["f_id"],
393
381
  "nameExplicit": false,
394
382
  "name": "t_events_pk",
395
383
  "table": "t_events",
396
384
  "entityType": "pks"
397
385
  },
398
386
  {
399
- "columns": [
400
- "f_singleton"
401
- ],
387
+ "columns": ["f_singleton"],
402
388
  "nameExplicit": false,
403
389
  "name": "t_persistence_state_pk",
404
390
  "table": "t_persistence_state",
405
391
  "entityType": "pks"
406
392
  },
407
393
  {
408
- "columns": [
409
- "f_key"
410
- ],
394
+ "columns": ["f_key"],
411
395
  "nameExplicit": false,
412
396
  "name": "t_schema_meta_pk",
413
397
  "table": "t_schema_meta",
414
398
  "entityType": "pks"
415
399
  },
416
400
  {
417
- "columns": [
418
- "f_id"
419
- ],
401
+ "columns": ["f_id"],
420
402
  "nameExplicit": false,
421
403
  "name": "t_session_events_pk",
422
404
  "table": "t_session_events",
423
405
  "entityType": "pks"
424
406
  },
425
407
  {
426
- "columns": [
427
- "f_id"
428
- ],
408
+ "columns": ["f_id"],
429
409
  "nameExplicit": false,
430
410
  "name": "t_sessions_pk",
431
411
  "table": "t_sessions",
@@ -502,28 +482,21 @@
502
482
  "table": "t_session_events"
503
483
  },
504
484
  {
505
- "columns": [
506
- "f_session_id",
507
- "f_sequence"
508
- ],
485
+ "columns": ["f_session_id", "f_sequence"],
509
486
  "nameExplicit": true,
510
487
  "name": "uq_session_events_session_sequence",
511
488
  "entityType": "uniques",
512
489
  "table": "t_session_events"
513
490
  },
514
491
  {
515
- "columns": [
516
- "f_event_id"
517
- ],
492
+ "columns": ["f_event_id"],
518
493
  "nameExplicit": false,
519
494
  "name": "t_events_f_event_id_unique",
520
495
  "entityType": "uniques",
521
496
  "table": "t_events"
522
497
  },
523
498
  {
524
- "columns": [
525
- "f_session_id"
526
- ],
499
+ "columns": ["f_session_id"],
527
500
  "nameExplicit": false,
528
501
  "name": "t_sessions_f_session_id_unique",
529
502
  "entityType": "uniques",
@@ -816,74 +789,56 @@
816
789
  "table": "t_workspace_sessions"
817
790
  },
818
791
  {
819
- "columns": [
820
- "f_id"
821
- ],
792
+ "columns": ["f_id"],
822
793
  "nameExplicit": false,
823
794
  "name": "t_session_projcache_row_pk",
824
795
  "table": "t_session_projcache_row",
825
796
  "entityType": "pks"
826
797
  },
827
798
  {
828
- "columns": [
829
- "f_name"
830
- ],
799
+ "columns": ["f_name"],
831
800
  "nameExplicit": false,
832
801
  "name": "t_storage_units_pk",
833
802
  "table": "t_storage_units",
834
803
  "entityType": "pks"
835
804
  },
836
805
  {
837
- "columns": [
838
- "f_singleton"
839
- ],
806
+ "columns": ["f_singleton"],
840
807
  "nameExplicit": false,
841
808
  "name": "t_workspace_state_pk",
842
809
  "table": "t_workspace_state",
843
810
  "entityType": "pks"
844
811
  },
845
812
  {
846
- "columns": [
847
- "f_id"
848
- ],
813
+ "columns": ["f_id"],
849
814
  "nameExplicit": false,
850
815
  "name": "t_workspaces_pk",
851
816
  "table": "t_workspaces",
852
817
  "entityType": "pks"
853
818
  },
854
819
  {
855
- "columns": [
856
- "f_id"
857
- ],
820
+ "columns": ["f_id"],
858
821
  "nameExplicit": false,
859
822
  "name": "t_workspace_sessions_pk",
860
823
  "table": "t_workspace_sessions",
861
824
  "entityType": "pks"
862
825
  },
863
826
  {
864
- "columns": [
865
- "f_session_id",
866
- "f_key"
867
- ],
827
+ "columns": ["f_session_id", "f_key"],
868
828
  "nameExplicit": true,
869
829
  "name": "uq_session_projcache_row_session_key",
870
830
  "entityType": "uniques",
871
831
  "table": "t_session_projcache_row"
872
832
  },
873
833
  {
874
- "columns": [
875
- "f_workspace_id"
876
- ],
834
+ "columns": ["f_workspace_id"],
877
835
  "nameExplicit": false,
878
836
  "name": "t_workspaces_f_workspace_id_unique",
879
837
  "entityType": "uniques",
880
838
  "table": "t_workspaces"
881
839
  },
882
840
  {
883
- "columns": [
884
- "f_workspace_id",
885
- "f_session_id"
886
- ],
841
+ "columns": ["f_workspace_id", "f_session_id"],
887
842
  "nameExplicit": true,
888
843
  "name": "uq_workspace_sessions_workspace_session",
889
844
  "entityType": "uniques",
@@ -924,13 +879,9 @@
924
879
  "table": "t_workspace_state"
925
880
  },
926
881
  {
927
- "columns": [
928
- "f_session_id"
929
- ],
882
+ "columns": ["f_session_id"],
930
883
  "tableTo": "t_sessions",
931
- "columnsTo": [
932
- "f_session_id"
933
- ],
884
+ "columnsTo": ["f_session_id"],
934
885
  "onUpdate": "NO ACTION",
935
886
  "onDelete": "CASCADE",
936
887
  "nameExplicit": false,
@@ -939,13 +890,9 @@
939
890
  "table": "t_session_projcache_row"
940
891
  },
941
892
  {
942
- "columns": [
943
- "f_workspace_id"
944
- ],
893
+ "columns": ["f_workspace_id"],
945
894
  "tableTo": "t_workspaces",
946
- "columnsTo": [
947
- "f_workspace_id"
948
- ],
895
+ "columnsTo": ["f_workspace_id"],
949
896
  "onUpdate": "NO ACTION",
950
897
  "onDelete": "CASCADE",
951
898
  "nameExplicit": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "RDB durable session backend for DeepSeek Harness: implements both session-persistence and session-branch (rewind / retry / fork) providers.",
5
5
  "keywords": [
6
6
  "branch",
@@ -35,7 +35,7 @@
35
35
  "./cordis.patch.yml": "./cordis.patch.yml"
36
36
  },
37
37
  "dependencies": {
38
- "@morlay/session-branch": "^0.0.6",
38
+ "@morlay/session-branch": "^0.0.8",
39
39
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
40
40
  "fflate": "^0.8.2",
41
41
  "pg": "^8.23.0"
@@ -60,6 +60,7 @@
60
60
  "@deepseek-ai/dsh-session-format": "^0.1.5-rc.2",
61
61
  "@deepseek-ai/dsh-session-format-catalog": "^0.1.5-rc.2",
62
62
  "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.2",
63
+ "@deepseek-ai/dsh-session-query": "^0.1.5-rc.2",
63
64
  "@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
64
65
  "@deepseek-ai/dsh-storage": "^0.1.5-rc.2",
65
66
  "@deepseek-ai/schemastery": "^3.18.2"
@@ -81,7 +82,7 @@
81
82
  "magic-string": "0.30.21",
82
83
  "tinyrainbow": "3.1.1",
83
84
  "vitest": "4.1.11",
84
- "zod": "4.6.1"
85
+ "zod": "4.6.5"
85
86
  },
86
87
  "scripts": {
87
88
  "build": "pnpm exec tsdown",
@@ -4,7 +4,7 @@ import type { TableDef } from "../../adapters/types.ts";
4
4
  * workspace 域 global 单例(上游 `workspaceDomainState`):`initialized` 是
5
5
  * 引导标记,`pendingMutation` 拆成操作与目标两列(create / delete 的可恢复
6
6
  * 两写标记)。显示顺序(`workspaceIds`)在 `t_workspaces.f_position`,
7
- * 归档集在 `t_session_archives`。
7
+ * 归档集由 `t_sessions.f_archived_at` 承载(归档不触碰归属槽位)。
8
8
  */
9
9
  export const workspaceState: TableDef = {
10
10
  name: "t_workspace_state",
@@ -42,9 +42,11 @@ export interface StoragesImportOptions {
42
42
  }
43
43
 
44
44
  const WORKSPACE_UNIT = "workspace";
45
- const WORKSPACE_UNIT_VERSIONS = new Set([2]);
45
+ /** workspace 文档可接受的域版本集(对齐上游 `workspaceDomainSpec`,见对齐测试)。 */
46
+ export const WORKSPACE_UNIT_VERSIONS: ReadonlySet<number> = new Set([2]);
46
47
  const PROJCACHE_UNIT = "session_projcache";
47
- const PROJCACHE_UNIT_VERSIONS = new Set([7, 3, 4, 5, 6]);
48
+ /** 旧投影缓存文档可接受的域版本集(对齐上游 spec version compatibleVersions)。 */
49
+ export const PROJCACHE_UNIT_VERSIONS: ReadonlySet<number> = new Set([7, 3, 4, 5, 6]);
48
50
 
49
51
  /**
50
52
  * Import the legacy storages documents into the rdb tables.
@@ -62,7 +64,10 @@ export async function importStorages(
62
64
  const workspaceDocument = await readJson(join(storagesRoot, `${WORKSPACE_UNIT}.json`));
63
65
  if (workspaceDocument !== undefined) {
64
66
  const tables = unitTables(workspaceDocument, WORKSPACE_UNIT, WORKSPACE_UNIT_VERSIONS);
65
- await repository.insertUnitVersion(WORKSPACE_UNIT, WORKSPACE_UNIT_VERSIONS.values().next().value as number);
67
+ await repository.insertUnitVersion(
68
+ WORKSPACE_UNIT,
69
+ WORKSPACE_UNIT_VERSIONS.values().next().value as number,
70
+ );
66
71
  for (const [id, record] of Object.entries(tables["workspaces"] ?? {})) {
67
72
  await repository.putWorkspace(id, record as WorkspaceRecord);
68
73
  result.workspaces += 1;
package/src/import.ts CHANGED
@@ -2,12 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import type { Context } from "@deepseek-ai/cordis";
3
3
  import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
4
4
  import type { Session, SessionEvent, SessionId, SessionHeader } from "@deepseek-ai/dsh-session";
5
+ import { parseSessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
5
6
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
6
7
  import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
7
8
  import { unzipSync } from "fflate";
8
9
  import { replaceLiveSessionLog } from "./branch.ts";
9
10
  import type { SessionPersistenceRdb } from "./index.ts";
10
11
 
12
+ /** 当前世代产物的文件名(读入侧接受任意 canonical 世代名,见 parseImportZip)。 */
11
13
  export const SESSION_LOG_ARTIFACT_FILENAME = "session.jsonl";
12
14
 
13
15
  export const SESSION_IMPORT_PATH = "/api/session.import";
@@ -107,11 +109,36 @@ export function parseImportZip(zip: Uint8Array): SessionStorageMetadata & {
107
109
  } catch {
108
110
  throw new Error("imported zip is not a valid ZIP archive");
109
111
  }
110
- const artifact = entries[SESSION_LOG_ARTIFACT_FILENAME];
112
+ // 上游导出按世代命名(`session.v3.jsonl`),早期 rdb 导出沿用 `session.jsonl`:
113
+ // 两者都接受,按 canonical 名字规则识别(临时/大写/压缩后缀名不视为产物)。
114
+ const artifact = Object.entries(entries).find(
115
+ ([name]) => parseSessionFormatLogFilename(name) !== undefined,
116
+ );
111
117
  if (artifact === undefined) {
112
- throw new Error(`imported zip is missing ${SESSION_LOG_ARTIFACT_FILENAME}`);
118
+ throw new Error(
119
+ `imported zip has no session log artifact (expected ${SESSION_LOG_ARTIFACT_FILENAME} ` +
120
+ "or a canonical session.vN.jsonl)",
121
+ );
113
122
  }
114
- return parseJsonlArtifact(new TextDecoder().decode(artifact));
123
+ return parseJsonlArtifact(new TextDecoder().decode(artifact[1]));
124
+ }
125
+
126
+ // 上游 Agent 的取消面(duck-type):停止运行中的 loop。
127
+ interface ImportAgentLike {
128
+ cancel?(cause: { kind: "user" }, options?: { keepInbox?: boolean }): void;
129
+ whenIdle(): Promise<void>;
130
+ }
131
+
132
+ // persistImport 会先 rewind(-1) 截断 live 会话的内存 log,不能与 agent 写日志
133
+ // 并发;keepInbox 保留排队输入(截断后由 rewind 的 live 钩子 durable 取消)。
134
+ async function stopAgentLoop(ctx: Context, sessionId: SessionId): Promise<void> {
135
+ const agents = ctx.get("agents") as
136
+ | { get(id: SessionId): ImportAgentLike | undefined }
137
+ | undefined;
138
+ const agent = agents?.get(sessionId);
139
+ if (agent === undefined) return;
140
+ agent.cancel?.({ kind: "user" }, { keepInbox: true });
141
+ await agent.whenIdle();
115
142
  }
116
143
 
117
144
  export async function persistImport(
@@ -120,12 +147,15 @@ export async function persistImport(
120
147
  imported: SessionStorageMetadata & { events: SessionEvent[] },
121
148
  targetId?: SessionId,
122
149
  sessions?: { get(id: SessionId): Session | undefined },
150
+ stopLoop?: (id: SessionId) => Promise<void>,
123
151
  ): Promise<SessionId> {
124
152
  const id = targetId ?? (`session-${randomUUID()}` as SessionId);
125
153
  if (targetId !== undefined) {
126
154
  if (branch === undefined) {
127
155
  throw new Error("sessionBranch service is unavailable");
128
156
  }
157
+ // 覆盖语义先 rewind(-1) 截断:停止运行中的 loop(未注入端口时空操作)。
158
+ if (stopLoop !== undefined) await stopLoop(targetId);
129
159
  await branch.rewind(targetId, -1);
130
160
  // rewind 截断后追加导入事件(覆盖语义)。live 会话的 write handle 由
131
161
  // live 路由持有——复用而非 open(open 会撞 SessionAlreadyOwnedError)。
@@ -252,7 +282,14 @@ export function registerSessionImport(ctx: Context, persistence: SessionPersiste
252
282
  const sessions = webCtx.get("sessions") as
253
283
  | { get(id: SessionId): Session | undefined }
254
284
  | undefined;
255
- const id = await persistImport(persistence, branch, imported, targetId, sessions);
285
+ const id = await persistImport(
286
+ persistence,
287
+ branch,
288
+ imported,
289
+ targetId,
290
+ sessions,
291
+ (sessionId) => stopAgentLoop(webCtx, sessionId),
292
+ );
256
293
  res.writeHead(200, { "content-type": "application/json" });
257
294
  res.end(JSON.stringify({ sessionId: id }));
258
295
  } catch (error: unknown) {
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type SessionPersistenceStatOptions,
31
31
  } from "@deepseek-ai/dsh-session-persistence";
32
32
  import {
33
+ SESSION_FORMAT_VERSION,
33
34
  SessionLogOffset,
34
35
  type Session,
35
36
  type SessionEvent,
@@ -37,6 +38,7 @@ import {
37
38
  type SessionId,
38
39
  type SurfaceEventType,
39
40
  } from "@deepseek-ai/dsh-session";
41
+ import { sessionFormatLogFilename } from "@deepseek-ai/dsh-session-format";
40
42
  import { type Backend, type BackendTx, type EventInsert } from "./backend.ts";
41
43
  import { WriteGuard } from "./write-guard.ts";
42
44
  import { repairReadView, rowToMeta, scanRows, toJsonlArtifact } from "./log.ts";
@@ -50,6 +52,7 @@ import { SqliteBackend } from "./sqlite.ts";
50
52
  import { PostgresBackend } from "./postgres.ts";
51
53
  import { SessionBranchRdb } from "./branch.ts";
52
54
  import { registerSessionImport } from "./import.ts";
55
+ import { SessionQueryRdb } from "./session-query.ts";
53
56
  import { adoptLegacyRows, convertLegacyRows, isLegacyVersion } from "./legacy.ts";
54
57
  import { installStorageTakeover } from "./storage-takeover/index.ts";
55
58
 
@@ -532,6 +535,10 @@ export class SessionPersistenceRdb extends SessionPersistence {
532
535
  this.installLiveRouting(ctx);
533
536
  // 分支 provider 服务(rewind / forkFrom / timeline),随 fiber 卸载自动回滚。
534
537
  new SessionBranchRdb(this.ctx);
538
+ // 会话查询服务:接管官方 session-query-sqlite(同名 provide 会 fail loud,
539
+ // 其装配行由 better-session patch 禁用);class-plugin 装载以复用基类的
540
+ // 精确读 / 过滤 / 血缘实现。
541
+ this.ctx.plugin(SessionQueryRdb, {});
535
542
  // 导入端点:webServer + connection 就绪后注册 `/api/session.import`。
536
543
  registerSessionImport(this.ctx, this);
537
544
  // storages 接管:storage hub 的 `rdb` 后端(workspace 域)与
@@ -706,7 +713,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
706
713
 
707
714
  // --- RDB 特有能力(rewind / fork / 导出 / 测试支撑) ---
708
715
 
709
- /** 导出 artifact(jsonl v2 文本),视图只读不落库。 */
716
+ /** 导出当前世代的 jsonl artifact,视图只读不落库。 */
710
717
  async readRaw(
711
718
  id: SessionId,
712
719
  signal?: AbortSignal,
@@ -724,7 +731,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
724
731
  return {
725
732
  meta: log.meta,
726
733
  inheritedEventCount,
727
- filename: "session.jsonl",
734
+ // 内容按当前世代编码,文件名必须声明同一世代(上游导出用 session.vN.jsonl)。
735
+ filename: sessionFormatLogFilename(SESSION_FORMAT_VERSION),
728
736
  content: toJsonlArtifact(log.meta, inheritedEventCount, log.events),
729
737
  };
730
738
  }
package/src/postgres.ts CHANGED
@@ -60,16 +60,22 @@ export class PostgresBackend implements Backend {
60
60
  this.opened.catch(() => {});
61
61
  this.storage = createStorageRepository({
62
62
  db: () => this.opened.then(() => this.txOverride ?? this.db),
63
+ // storages 写事务用 serializable:workspace 域是整记录替换,多实例并发写
64
+ // 同一记录在 read committed 下是「最后提交者赢」的静默覆盖;提升隔离级别
65
+ // 后冲突以序列化失败暴露(与「并发写入 fail loud」一致),调用方重试即可。
63
66
  writeAtomically: (fn) =>
64
- this.db.transaction(async (tx) => {
65
- const previous = this.txOverride;
66
- this.txOverride = tx;
67
- try {
68
- return await fn();
69
- } finally {
70
- this.txOverride = previous;
71
- }
72
- }),
67
+ this.db.transaction(
68
+ async (tx) => {
69
+ const previous = this.txOverride;
70
+ this.txOverride = tx;
71
+ try {
72
+ return await fn();
73
+ } finally {
74
+ this.txOverride = previous;
75
+ }
76
+ },
77
+ { isolationLevel: "serializable" },
78
+ ),
73
79
  tables: this.tables,
74
80
  });
75
81
  }
@@ -0,0 +1,27 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import { SessionQueryEngine, SessionQueryError, type Config } from "@deepseek-ai/dsh-session-query";
3
+
4
+ // 会话查询服务的 rdb 实现:精确读 / 过滤 / 血缘全部复用上游基类(数据面走
5
+ // ctx.sessions 与我们的 ctx.sessionPersistence),这里只接管服务所有权。
6
+ // 全文检索与官方 `openAt: never` 装配保持一致——直接拒绝,不引入 FTS 索引、
7
+ // node:sqlite 或派生库,搜索语义留待显式实现(见 ADR 0006)。
8
+ export class SessionQueryRdb extends SessionQueryEngine {
9
+ constructor(ctx: Context, config: Config = {}) {
10
+ super(ctx, config);
11
+ }
12
+
13
+ override async searchSessions(): Promise<never> {
14
+ throw searchDisabled();
15
+ }
16
+
17
+ override async searchEvents(): Promise<never> {
18
+ throw searchDisabled();
19
+ }
20
+ }
21
+
22
+ function searchDisabled(): SessionQueryError {
23
+ return new SessionQueryError(
24
+ "session search is disabled: this deployment serves session queries from the rdb backend without a full-text index",
25
+ "SESSION_QUERY_SEARCH_DISABLED",
26
+ );
27
+ }
@@ -48,13 +48,13 @@ export function installStorageTakeover(ctx: Context, options: StorageTakeoverOpt
48
48
  storageCtx.provide(storageBackendServiceKey(RDB_STORAGE_BACKEND), backend);
49
49
  });
50
50
 
51
- // 投影 checkpoint:上游插件被禁用后由本服务提供同名 API
52
- ctx.inject(["sessionProjections"], (projectionCtx) => {
53
- new SessionProjectionCacheRdb(
54
- projectionCtx,
55
- options.projectionCache,
56
- options.repository,
57
- options.ready,
58
- );
51
+ // 投影 checkpoint:上游插件被禁用后由本服务提供同名 API。按 cordis
52
+ // class plugin 装载(而不是直接 new):`static inject` 负责等依赖就绪,
53
+ // 框架负责调用 `[Service.init]` 安装写入路径——直接 new 会静默跳过 init,
54
+ // 导致 session/created、turn/end、计数与定时器的 checkpoint 写入全部失效。
55
+ ctx.plugin(SessionProjectionCacheRdb, {
56
+ ...options.projectionCache,
57
+ repository: options.repository,
58
+ ready: options.ready,
59
59
  });
60
60
  }
@@ -49,6 +49,12 @@ export interface ProjectionCacheConfig {
49
49
  writeIntervalMs: number;
50
50
  }
51
51
 
52
+ /** class-plugin 装载参数:写节流 + storages 接管访问层 + 介质就绪信号。 */
53
+ export interface SessionProjectionCacheRdbOptions extends ProjectionCacheConfig {
54
+ repository: StorageRepository;
55
+ ready: Promise<unknown>;
56
+ }
57
+
52
58
  /** 每会话写回节流簿记(只对 live session)。 */
53
59
  interface DirtyState {
54
60
  pending: number;
@@ -67,35 +73,48 @@ export class SessionProjectionCacheRdb extends Service {
67
73
  private readonly dirty = new Map<Session, DirtyState>();
68
74
  /** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
69
75
  private readonly directReads: boolean;
76
+ private readonly config: ProjectionCacheConfig;
77
+ private readonly repository: StorageRepository;
78
+ private readonly ready: Promise<unknown>;
70
79
 
71
80
  /**
72
- * @param ctx - 插件上下文(须已注入 sessionProjections 与 sessions)。
73
- * @param config - 写节流参数。
74
- * @param repository - storages 接管表访问层。
75
- * @param ready - 介质就绪信号(直读介质前必须等到)。
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 接管访问层 + 介质就绪信号。
76
86
  */
77
- constructor(
78
- ctx: Context,
79
- private readonly config: ProjectionCacheConfig,
80
- private readonly repository: StorageRepository,
81
- private readonly ready: Promise<unknown>,
82
- ) {
87
+ constructor(ctx: Context, options: SessionProjectionCacheRdbOptions) {
83
88
  super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
84
- this.directReads = repository.readProjcacheSync !== undefined;
89
+ this.config = options;
90
+ this.repository = options.repository;
91
+ this.ready = options.ready;
92
+ this.directReads = options.repository.readProjcacheSync !== undefined;
85
93
  }
86
94
 
87
95
  /**
88
- * Wait for the medium, then install the write path. Only the async driver
89
- * needs a startup mirror: the sync driver serves every read from the table.
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.
90
100
  */
91
101
  protected async [Service.init](): Promise<void> {
102
+ this.installWritePath();
92
103
  await this.ready;
104
+ // 一次性清理旧版写入路径留下的陈旧行(水位为负却已有事件):这些行会把
105
+ // 有对话的会话在 cold 列表里误判为空白。缓存是派生数据,删除让会话回到
106
+ // 「未知即可见」,等待下一次强制点重写。
107
+ const pruned = await this.repository.pruneStaleProjcache();
108
+ if (pruned > 0) {
109
+ this.ctx.logger.info(
110
+ `session projection cache: pruned stale snapshot(s) for ${String(pruned)} session(s)`,
111
+ );
112
+ }
93
113
  if (!this.directReads) {
94
114
  for (const entry of await this.repository.loadProjcache()) {
95
115
  this.records.set(entry.sessionId as SessionId, entry);
96
116
  }
97
117
  }
98
- this.installWritePath();
99
118
  }
100
119
 
101
120
  /** Read one session's stored record: straight from the medium, or from the async mirror. */
@@ -113,7 +132,10 @@ export class SessionProjectionCacheRdb extends Service {
113
132
  * @param expected - the log identity the caller holds (live or stored header).
114
133
  * @returns the identity-matching record, or `undefined` (absent or unrelated).
115
134
  */
116
- private recordFor(id: SessionId, expected: CurrentCheckpointIdentity): StoredProjcacheEntry | undefined {
135
+ private recordFor(
136
+ id: SessionId,
137
+ expected: CurrentCheckpointIdentity,
138
+ ): StoredProjcacheEntry | undefined {
117
139
  const record = this.lookup(id);
118
140
  if (record === undefined) return undefined;
119
141
  return identityMatches(record.identity, expected) ? record : undefined;
@@ -329,12 +351,15 @@ export class SessionProjectionCacheRdb extends Service {
329
351
  this.dirty.delete(session);
330
352
  });
331
353
 
332
- this.ctx.effect(() => () => {
333
- for (const state of this.dirty.values()) {
334
- if (state.timer !== undefined) clearTimeout(state.timer);
335
- }
336
- this.dirty.clear();
337
- }, "sessionProjectionCacheRdb.timers");
354
+ this.ctx.effect(
355
+ () => () => {
356
+ for (const state of this.dirty.values()) {
357
+ if (state.timer !== undefined) clearTimeout(state.timer);
358
+ }
359
+ this.dirty.clear();
360
+ },
361
+ "sessionProjectionCacheRdb.timers",
362
+ );
338
363
  }
339
364
 
340
365
  /** One fail-soft durable checkpoint. */
@@ -374,7 +399,9 @@ export class SessionProjectionCacheRdb extends Service {
374
399
  }
375
400
 
376
401
  /** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
377
- function detachJson(rows: Record<string, ProjectionCheckpointRow>): Record<string, ProjectionCheckpointRow> {
402
+ function detachJson(
403
+ rows: Record<string, ProjectionCheckpointRow>,
404
+ ): Record<string, ProjectionCheckpointRow> {
378
405
  let text: string | undefined;
379
406
  try {
380
407
  text = JSON.stringify(rows);
@@ -416,8 +443,7 @@ function identityOf(
416
443
  */
417
444
  function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
418
445
  return (
419
- stored.formatVersion === expected.formatVersion &&
420
- lifecycleIdentityMatches(stored, expected)
446
+ stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected)
421
447
  );
422
448
  }
423
449