@morlay/session-rdb 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +44 -10
  2. package/dist/artifact.d.mts +9 -2
  3. package/dist/artifact.mjs +3 -3
  4. package/dist/{import-BPEHfHNk.mjs → import-DZIAjOUr.mjs} +2 -1
  5. package/dist/import.d.mts +1 -1
  6. package/dist/import.mjs +1 -1
  7. package/dist/{index-CgAqCQAb.d.mts → index-BEKoV1Uy.d.mts} +11 -2
  8. package/dist/index.d.mts +3 -3
  9. package/dist/index.mjs +509 -6
  10. package/dist/{log-BIuXv09P.mjs → log-DO69NQnn.mjs} +16 -1
  11. package/dist/schema-Bo6Hy5gW.d.mts +3476 -0
  12. package/dist/{sqlite-IH5I48aL.mjs → sqlite-BIPdMNQb.mjs} +504 -6
  13. package/dist/storage.d.mts +10 -2
  14. package/dist/storage.mjs +2 -2
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +65 -0
  18. package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +1236 -0
  19. package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +10 -0
  20. package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +1236 -0
  21. package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +66 -0
  22. package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +958 -0
  23. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +13 -0
  24. package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +958 -0
  25. package/package.json +20 -18
  26. package/src/backend.ts +7 -0
  27. package/src/branch.ts +2 -0
  28. package/src/drizzle/postgres-v3.ts +5 -0
  29. package/src/drizzle/sqlite-v3.ts +5 -0
  30. package/src/entities/v3/index.ts +20 -0
  31. package/src/entities/v3/session-projcache-rows.ts +27 -0
  32. package/src/entities/v3/sessions.ts +6 -0
  33. package/src/entities/v3/storage-units.ts +10 -0
  34. package/src/entities/v3/workspace-sessions.ts +24 -0
  35. package/src/entities/v3/workspace-state.ts +18 -0
  36. package/src/entities/v3/workspaces.ts +19 -0
  37. package/src/import-storages.ts +173 -0
  38. package/src/index.ts +53 -0
  39. package/src/log.ts +19 -0
  40. package/src/postgres.ts +70 -2
  41. package/src/schema.ts +16 -2
  42. package/src/sqlite.ts +87 -4
  43. package/src/storage-takeover/index.ts +60 -0
  44. package/src/storage-takeover/projection-cache.ts +445 -0
  45. package/src/storage-takeover/repository.ts +420 -0
  46. package/src/storage-takeover/storage-backend.ts +177 -0
  47. package/src/storage-takeover/types.ts +82 -0
  48. package/dist/schema-COK7-wRV.d.mts +0 -104
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { a as SCHEMA_VERSION, g as WriteGuard, h as toPostgresSchema, i as EVENT_ENCODING, m as postgresTableDefs, r as DEFAULT_BUSY_TIMEOUT_MS, s as eventDimensions, t as SqliteBackend } from "./sqlite-IH5I48aL.mjs";
2
- import { c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as toJsonlArtifact, m as sessionInsertRow, p as sessionConflictRow, s as repairReadView } from "./log-BIuXv09P.mjs";
3
- import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-BPEHfHNk.mjs";
1
+ import { S as WriteGuard, a as EVENT_ENCODING, b as postgresTableDefs, c as eventDimensions, i as DEFAULT_BUSY_TIMEOUT_MS, o as SCHEMA_VERSION, r as createStorageRepository, t as SqliteBackend, x as toPostgresSchema } from "./sqlite-BIPdMNQb.mjs";
2
+ import { _ as toJsonlArtifact, c as repairRequestHeaders, d as rowToMeta, f as scanRows, g as titleOfEventData, m as sessionInsertRow, p as sessionConflictRow, s as repairReadView } from "./log-DO69NQnn.mjs";
3
+ import { c as SessionBranchRdbProvider, l as locateTurnEnd, o as registerSessionImport, s as SessionBranchRdb } from "./import-DZIAjOUr.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { Pool } from "pg";
@@ -8,10 +8,12 @@ import { drizzle } from "drizzle-orm/node-postgres";
8
8
  import { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionHandleClosedError, SessionPersistence, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertVersion, materializeAppendBatch, materializeCreateHeader, validateStoredEvents } from "@deepseek-ai/dsh-session-persistence";
9
9
  import { SESSION_FORMAT_VERSION, SessionLogOffset } from "@deepseek-ai/dsh-session";
10
10
  import { sessionFormatCatalog } from "@deepseek-ai/dsh-session-format-catalog";
11
- import { and, eq, gte, sql } from "drizzle-orm";
11
+ import { and, desc, eq, gte, sql } from "drizzle-orm";
12
12
  import { readdirSync } from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { migrate } from "drizzle-orm/node-postgres/migrator";
15
+ import { StorageError, storageBackendServiceKey } from "@deepseek-ai/dsh-storage";
16
+ import { Service } from "@deepseek-ai/cordis";
15
17
  //#region src/postgres.ts
16
18
  /** drizzle-kit 生成的迁移目录(随包根 drizzle/ 发布;src/dist 形态经相对 URL 统一解析)。 */
17
19
  const postgresMigrationsDir = fileURLToPath(new URL("../drizzle/postgres/", import.meta.url));
@@ -21,12 +23,46 @@ var PostgresBackend = class PostgresBackend {
21
23
  kind = "postgres";
22
24
  storeIdentity;
23
25
  tables;
26
+ /** storages 接管表访问层;句柄在 open()(含迁移)完成后解析。 */
27
+ storage;
28
+ opened;
29
+ resolveOpened;
30
+ rejectOpened;
31
+ /** 事务期间的连接覆盖:storage 写方法经它加入当前事务。 */
32
+ txOverride;
24
33
  constructor(db, options) {
25
34
  this.db = db;
26
35
  this.options = options;
27
36
  this.tables = toPostgresSchema(postgresTableDefs, this.options.schema ?? "public");
37
+ this.opened = new Promise((resolve, reject) => {
38
+ this.resolveOpened = resolve;
39
+ this.rejectOpened = reject;
40
+ });
41
+ this.opened.catch(() => {});
42
+ this.storage = createStorageRepository({
43
+ db: () => this.opened.then(() => this.txOverride ?? this.db),
44
+ writeAtomically: (fn) => this.db.transaction(async (tx) => {
45
+ const previous = this.txOverride;
46
+ this.txOverride = tx;
47
+ try {
48
+ return await fn();
49
+ } finally {
50
+ this.txOverride = previous;
51
+ }
52
+ }),
53
+ tables: this.tables
54
+ });
28
55
  }
29
56
  async open() {
57
+ try {
58
+ await this.doOpen();
59
+ this.resolveOpened();
60
+ } catch (error) {
61
+ this.rejectOpened(error);
62
+ throw error;
63
+ }
64
+ }
65
+ async doOpen() {
30
66
  const schema = this.options.schema ?? "public";
31
67
  const qualifiedMeta = schema === "public" ? "t_schema_meta" : `"${schema}".t_schema_meta`;
32
68
  if ((await this.db.execute(sql`SELECT to_regclass(${qualifiedMeta}) IS NOT NULL AS exists`)).rows[0]?.exists === true) {
@@ -87,6 +123,7 @@ var PostgresBackend = class PostgresBackend {
87
123
  insertBridges: (rows) => this.insertBridges(tx, rows),
88
124
  updateHead: (id, headEventId, headSequence) => this.updateHead(tx, id, headEventId, headSequence),
89
125
  bumpRevision: (id) => this.bumpRevision(tx, id),
126
+ refreshTitle: (id) => this.refreshTitle(tx, id),
90
127
  deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
91
128
  getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence)
92
129
  };
@@ -136,6 +173,20 @@ var PostgresBackend = class PostgresBackend {
136
173
  async bumpRevision(exec, id) {
137
174
  await exec.update(this.tables["t_sessions"]).set({ fRevision: sql`${this.tables["t_sessions"].fRevision} + 1` }).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
138
175
  }
176
+ /** 从事件表重算标题(最后一条 `session/title`),写回会话行的 f_title 列。 */
177
+ async refreshTitle(exec, id) {
178
+ const bridges = this.tables["t_session_events"];
179
+ const entities = this.tables["t_events"];
180
+ const row = (await exec.select({
181
+ fSequence: bridges.fSequence,
182
+ fData: entities.fData
183
+ }).from(bridges).innerJoin(entities, eq(entities.fEventId, bridges.fEventId)).where(and(eq(bridges.fSessionId, id), eq(entities.fType, "session/title"))).orderBy(desc(bridges.fSequence)).limit(1).execute())[0];
184
+ const title = row === void 0 ? void 0 : titleOfEventData(row.fData);
185
+ await exec.update(this.tables["t_sessions"]).set({
186
+ fTitle: title ?? null,
187
+ fTitleSeq: title === void 0 ? null : row.fSequence
188
+ }).where(eq(this.tables["t_sessions"].fSessionId, id)).execute();
189
+ }
139
190
  async deleteBridgeTail(exec, id, fromSequence) {
140
191
  await exec.delete(this.tables["t_session_events"]).where(and(eq(this.tables["t_session_events"].fSessionId, id), gte(this.tables["t_session_events"].fSequence, fromSequence))).execute();
141
192
  }
@@ -250,7 +301,436 @@ function adoptLegacyRows(row, eventRows) {
250
301
  };
251
302
  }
252
303
  //#endregion
304
+ //#region src/storage-takeover/projection-cache.ts
305
+ /**
306
+ * 投影 checkpoint 服务(`ctx.sessionProjectionCache`)的 rdb 实现:替换上游
307
+ * `@deepseek-ai/dsh-session-projection-cache` 插件,公开面与语义逐一对齐
308
+ * (cachedSnapshot / cachedPredecessorTitle / hydratePrepared / write /
309
+ * coldSnapshot,以及三个强制写点与计数/定时节流),持久层换成 session-rdb
310
+ * 的语义专用表 `t_session_projcache` / `t_session_projcache_row`。
311
+ *
312
+ * 读方法是同步签名(session 列表在请求路径上直接调用),所以服务持有启动时
313
+ * 从表中加载的内存 checkpoint 表;写先落库、后更新内存,保证读到的内存值
314
+ * 磁盘上一定存在。
315
+ */
316
+ /** 服务注册名(与上游插件一致,消费者经 `ctx.get` 解析)。 */
317
+ const SESSION_PROJECTION_CACHE_SERVICE = "sessionProjectionCache";
318
+ /** 只认 title 的前代提示(与上游一致)。 */
319
+ const PREDECESSOR_TITLE_KEY = "title";
320
+ /** rdb 持久化的投影 checkpoint 服务:同步驱动直读介质,异步驱动用写穿镜像支撑同步签名。 */
321
+ var SessionProjectionCacheRdb = class extends Service {
322
+ config;
323
+ repository;
324
+ ready;
325
+ static inject = ["sessionProjections", "sessions"];
326
+ /** 异步驱动(PostgreSQL)的同步读镜像:同步驱动(SQLite)直读介质,这里恒为空。 */
327
+ records = /* @__PURE__ */ new Map();
328
+ dirty = /* @__PURE__ */ new Map();
329
+ /** 读路径是否直读介质(`readProjcacheSync` 存在即同步驱动)。 */
330
+ directReads;
331
+ /**
332
+ * @param ctx - 插件上下文(须已注入 sessionProjections 与 sessions)。
333
+ * @param config - 写节流参数。
334
+ * @param repository - storages 接管表访问层。
335
+ * @param ready - 介质就绪信号(直读介质前必须等到)。
336
+ */
337
+ constructor(ctx, config, repository, ready) {
338
+ super(ctx, SESSION_PROJECTION_CACHE_SERVICE);
339
+ this.config = config;
340
+ this.repository = repository;
341
+ this.ready = ready;
342
+ this.directReads = repository.readProjcacheSync !== void 0;
343
+ }
344
+ /**
345
+ * Wait for the medium, then install the write path. Only the async driver
346
+ * needs a startup mirror: the sync driver serves every read from the table.
347
+ */
348
+ async [Service.init]() {
349
+ await this.ready;
350
+ if (!this.directReads) for (const entry of await this.repository.loadProjcache()) this.records.set(entry.sessionId, entry);
351
+ this.installWritePath();
352
+ }
353
+ /** Read one session's stored record: straight from the medium, or from the async mirror. */
354
+ lookup(id) {
355
+ if (this.directReads) return this.repository.readProjcacheSync?.(id);
356
+ return this.records.get(id);
357
+ }
358
+ /**
359
+ * The stored record for one session, accepted only when its bound log
360
+ * identity matches `expected`. A session id names a slot, not a lifecycle:
361
+ * a recreated id or a persistence store swapped under a surviving cache
362
+ * must not let an old record seed state folded from an unrelated log.
363
+ * @param id - the session whose record is read.
364
+ * @param expected - the log identity the caller holds (live or stored header).
365
+ * @returns the identity-matching record, or `undefined` (absent or unrelated).
366
+ */
367
+ recordFor(id, expected) {
368
+ const record = this.lookup(id);
369
+ if (record === void 0) return void 0;
370
+ return identityMatches(record.identity, expected) ? record : void 0;
371
+ }
372
+ /**
373
+ * The cached projection cut for one stored (cold) or live header.
374
+ * @param meta - authoritative Session header.
375
+ * @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
376
+ * @param keys - optional projection keys required by the caller's audience.
377
+ * @returns the cut (`asOfSeq` = lowest served-row watermark), or `undefined`
378
+ * when no usable row exists for this lifecycle.
379
+ */
380
+ cachedSnapshot(meta, inheritedEventCount, keys) {
381
+ const record = this.recordFor(meta.id, identityOf(meta, inheritedEventCount));
382
+ const snapshot = record === void 0 ? void 0 : this.viewRecord(record, keys);
383
+ return this.withDirectTitle(meta.id, keys, snapshot);
384
+ }
385
+ /**
386
+ * 标题是会话数据本身(`t_sessions.f_title`,由 rdb 写路径与 rewind 维护):
387
+ * checkpoint 行里没有 title 时直接取该列,列表消费不依赖缓存行是否存在。
388
+ */
389
+ withDirectTitle(id, keys, snapshot) {
390
+ if (keys !== void 0 && !keys.includes(PREDECESSOR_TITLE_KEY)) return snapshot;
391
+ if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== void 0) return snapshot;
392
+ const direct = this.repository.readSessionTitleSync?.(id);
393
+ if (direct === void 0) return snapshot;
394
+ const values = {
395
+ ...snapshot?.values,
396
+ [PREDECESSOR_TITLE_KEY]: direct.title
397
+ };
398
+ return {
399
+ asOfSeq: snapshot === void 0 ? direct.seq : Math.min(snapshot.asOfSeq, direct.seq),
400
+ values
401
+ };
402
+ }
403
+ /**
404
+ * Read only a predecessor checkpoint's title as a zero-I/O listing hint.
405
+ * @param meta - authoritative listed Session header.
406
+ * @param inheritedEventCount - exact inherited cut completing the lifecycle identity.
407
+ * @returns a title-only checkpoint view with `asOfSeq: -1`, or `undefined`
408
+ * when the record is current, newer, unrelated, missing, or incompatible
409
+ * with the title unit.
410
+ */
411
+ cachedPredecessorTitle(meta, inheritedEventCount) {
412
+ const expected = identityOf(meta, inheritedEventCount);
413
+ const record = this.lookup(meta.id);
414
+ if (record === void 0 || !predecessorIdentityMatches(record.identity, expected)) return;
415
+ const title = this.viewRecord(record, [PREDECESSOR_TITLE_KEY]);
416
+ return title === void 0 ? void 0 : {
417
+ ...title,
418
+ asOfSeq: -1
419
+ };
420
+ }
421
+ /** View selected wire rows and bind them to their lowest served watermark. */
422
+ viewRecord(record, keys) {
423
+ const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, keys);
424
+ const servedKeys = Object.keys(values);
425
+ if (servedKeys.length === 0) return void 0;
426
+ const firstKey = servedKeys[0];
427
+ let asOfSeq = record.rows[firstKey].seq;
428
+ for (const key of servedKeys.slice(1)) {
429
+ const row = record.rows[key];
430
+ if (row.seq < asOfSeq) asOfSeq = row.seq;
431
+ }
432
+ return {
433
+ asOfSeq,
434
+ values
435
+ };
436
+ }
437
+ /**
438
+ * Hydrate projection cells for an already-prepared Session without another
439
+ * persistence read. The cache seeds matching rows; the supplied exact log
440
+ * advances every unit to the observation cut. No checkpoint is written
441
+ * because the logical observation may contain recovery events not yet durable.
442
+ * @param session - exact unpublished Session retained by persistence.
443
+ * @param events - exact logical event prefix represented by the observation.
444
+ * @returns all projection values at the event cut.
445
+ */
446
+ hydratePrepared(session, events) {
447
+ const record = this.recordFor(session.id, identityOf(session.header, session.inheritedEventCount));
448
+ if (record === void 0) return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
449
+ try {
450
+ return this.ctx.sessionProjections.hydrate(session, record.rows, events, SessionLogOffset(0));
451
+ } catch {
452
+ return this.ctx.sessionProjections.hydrate(session, {}, events, SessionLogOffset(0));
453
+ }
454
+ }
455
+ /**
456
+ * Durably checkpoint one live session NOW (all mandatory points call this).
457
+ * NOT fail-soft — callers on the fail-soft paths contain it.
458
+ * @param session - the live session to checkpoint.
459
+ * @returns resolution after durability.
460
+ */
461
+ async write(session) {
462
+ const rows = this.ctx.sessionProjections.checkpoint(session);
463
+ this.markClean(session);
464
+ if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
465
+ await this.put(session.id, identityOf(session.header, session.inheritedEventCount), rows);
466
+ }
467
+ /**
468
+ * Cold-read one session's projections from its complete log. Each unit is
469
+ * seeded from the identity-checked cached rows and the refreshed checkpoint
470
+ * is written back (fail-soft, fire-and-forget).
471
+ * @param meta - the stored session header (identity witness).
472
+ * @param inheritedEventCount - exact inherited prefix length for projection initialization and identity.
473
+ * @param events - the session's complete log, in seq order.
474
+ * @returns the projection cut at the log end.
475
+ */
476
+ coldSnapshot(meta, inheritedEventCount, events) {
477
+ const identity = identityOf(meta, inheritedEventCount);
478
+ const restored = this.ctx.sessionProjections.restore(this.recordFor(meta.id, identity)?.rows ?? {}, events, SessionLogOffset(0), meta, inheritedEventCount);
479
+ this.put(meta.id, identity, restored.checkpoint).catch((error) => {
480
+ this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`);
481
+ });
482
+ return restored.snapshot;
483
+ }
484
+ installWritePath() {
485
+ this.ctx.on("session/event", (session, event) => {
486
+ if (event.type === "turn/end") {
487
+ this.flushSoft(session, "turn/end");
488
+ return;
489
+ }
490
+ const state = this.dirty.get(session) ?? {
491
+ pending: 0,
492
+ timer: void 0
493
+ };
494
+ this.dirty.set(session, state);
495
+ state.pending += 1;
496
+ if (state.pending >= this.config.writeEveryEvents) {
497
+ this.flushSoft(session, "count threshold");
498
+ return;
499
+ }
500
+ state.timer ??= setTimeout(() => {
501
+ this.flushSoft(session, "interval");
502
+ }, this.config.writeIntervalMs);
503
+ });
504
+ this.ctx.on("session/created", (session) => {
505
+ this.flushSoft(session, "create");
506
+ });
507
+ this.ctx.on("session/disposed", (session) => {
508
+ this.flushSoft(session, "detach");
509
+ this.markClean(session);
510
+ this.dirty.delete(session);
511
+ });
512
+ this.ctx.effect(() => () => {
513
+ for (const state of this.dirty.values()) if (state.timer !== void 0) clearTimeout(state.timer);
514
+ this.dirty.clear();
515
+ }, "sessionProjectionCacheRdb.timers");
516
+ }
517
+ /** One fail-soft durable checkpoint. */
518
+ async flushSoft(session, trigger) {
519
+ try {
520
+ await this.write(session);
521
+ } catch (error) {
522
+ this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`);
523
+ }
524
+ }
525
+ /** Reset one session's dirty bookkeeping (its checkpoint is being written). */
526
+ markClean(session) {
527
+ const state = this.dirty.get(session);
528
+ if (state === void 0) return;
529
+ state.pending = 0;
530
+ if (state.timer !== void 0) {
531
+ clearTimeout(state.timer);
532
+ state.timer = void 0;
533
+ }
534
+ }
535
+ /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
536
+ async put(id, identity, rows) {
537
+ const detached = detachJson(rows);
538
+ await this.repository.putProjcache(id, detached);
539
+ if (!this.directReads) this.records.set(id, {
540
+ sessionId: id,
541
+ identity,
542
+ rows: detached
543
+ });
544
+ }
545
+ };
546
+ /** Detach one checkpoint from live unit state, refusing non-lossless JSON. */
547
+ function detachJson(rows) {
548
+ let text;
549
+ try {
550
+ text = JSON.stringify(rows);
551
+ } catch (error) {
552
+ throw new TypeError(`projection checkpoint is not losslessly JSON-serializable: ${String(error)}`, { cause: error });
553
+ }
554
+ if (text === void 0) throw new TypeError("projection checkpoint is not losslessly JSON-serializable");
555
+ return JSON.parse(text);
556
+ }
557
+ /** Project a header onto the identity fields a record is bound to. */
558
+ function identityOf(header, inheritedEventCount) {
559
+ const cut = SessionLogOffset(inheritedEventCount);
560
+ if (!header.isSeeded && cut !== 0) throw new Error("unseeded projection-cache identity inherited event count must be 0");
561
+ return {
562
+ formatVersion: header.version,
563
+ createdAt: header.createdAt,
564
+ ...header.cwd === void 0 ? {} : { cwd: header.cwd },
565
+ isSeeded: header.isSeeded,
566
+ inheritedEventCount: cut
567
+ };
568
+ }
569
+ /**
570
+ * Whether a stored record's bound identity names the caller's lifecycle.
571
+ * An absent format generation cannot prove the fold semantics and never
572
+ * matches. Once the format matches, absent lineage fields (records admitted
573
+ * via compatible versions predate them) read as the unseeded lineage.
574
+ */
575
+ function identityMatches(stored, expected) {
576
+ return stored.formatVersion === expected.formatVersion && lifecycleIdentityMatches(stored, expected);
577
+ }
578
+ /** Match one predecessor cache record to the authoritative listed lifecycle. */
579
+ function predecessorIdentityMatches(stored, expected) {
580
+ return (stored.formatVersion === void 0 || stored.formatVersion < expected.formatVersion) && lifecycleIdentityMatches(stored, expected);
581
+ }
582
+ /** Match the format-independent fields that distinguish one Session lifecycle. */
583
+ function lifecycleIdentityMatches(stored, expected) {
584
+ return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd && (stored.isSeeded ?? false) === expected.isSeeded && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount;
585
+ }
586
+ //#endregion
587
+ //#region src/storage-takeover/storage-backend.ts
588
+ /**
589
+ * storages 接管:注册在 storage hub 上的 `rdb` KV 后端。它把 workspace 域
590
+ * (上游 single 布局:一张 `workspaces` 表 + global 单例)映射到 session-rdb
591
+ * 的语义专用表,与事件日志同库同连接;域层(`ctx.storageDomain`)通过
592
+ * `routes` 把 `workspace` 域路由到本后端。
593
+ *
594
+ * 本后端只服务已知域的专用表:未知 unit 名 fail loud,而不是静默落到通用
595
+ * 存储——表结构是显式维护的(见 docs/schema.md)。
596
+ */
597
+ /** 本后端唯一服务的域:上游 `workspaceDomainSpec`(v2,single 布局)。 */
598
+ const WORKSPACE_UNIT = "workspace";
599
+ const WORKSPACE_TABLE = "workspaces";
600
+ /** 只服务 workspace 域的 KV 后端。 */
601
+ var RdbStorageBackend = class {
602
+ repository;
603
+ kv = { open: (descriptor) => this.openUnit(descriptor) };
604
+ /** 已打开(或打开中)的 unit 名;重复 open 是调用方 bug。 */
605
+ open = /* @__PURE__ */ new Set();
606
+ closed = false;
607
+ /**
608
+ * @param repository - storages 接管表访问层(与事件日志同介质)。
609
+ */
610
+ constructor(repository) {
611
+ this.repository = repository;
612
+ }
613
+ async openUnit(descriptor) {
614
+ if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
615
+ if (descriptor.name !== WORKSPACE_UNIT) throw new Error(`rdb storage backend serves only the '${WORKSPACE_UNIT}' domain (requested '${descriptor.name}')`);
616
+ if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) throw new Error(`rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape (table '${WORKSPACE_TABLE}' plus a global slot)`);
617
+ if (this.open.has(descriptor.name)) throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
618
+ const stored = await this.repository.readUnitVersion(descriptor.name);
619
+ if (stored === void 0) await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
620
+ else if (stored !== descriptor.version) throw new StorageError("version-mismatch", `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, incompatible with descriptor version ${descriptor.version}`);
621
+ this.open.add(descriptor.name);
622
+ return new WorkspaceKvUnit(this.repository, descriptor, () => {
623
+ this.open.delete(descriptor.name);
624
+ });
625
+ }
626
+ /**
627
+ * Release the backend. The medium (the session database) belongs to the
628
+ * owning session-rdb plugin, so closing open units here does not close it.
629
+ * @returns resolution after the name table is cleared.
630
+ */
631
+ async close() {
632
+ this.closed = true;
633
+ this.open.clear();
634
+ }
635
+ };
636
+ /** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
637
+ var WorkspaceKvUnit = class {
638
+ repository;
639
+ descriptor;
640
+ onClose;
641
+ closed = false;
642
+ constructor(repository, descriptor, onClose) {
643
+ this.repository = repository;
644
+ this.descriptor = descriptor;
645
+ this.onClose = onClose;
646
+ }
647
+ async loadAll() {
648
+ this.assertOpen();
649
+ const records = Object.create(null);
650
+ for (const { id, record } of await this.repository.listWorkspaces()) records[id] = record;
651
+ const state = await this.repository.readWorkspaceState();
652
+ return {
653
+ tables: { [WORKSPACE_TABLE]: records },
654
+ global: state
655
+ };
656
+ }
657
+ async putRecord(table, key, value) {
658
+ this.assertOpen();
659
+ this.assertTable(table);
660
+ await this.repository.putWorkspace(key, workspaceRecordOf(value));
661
+ }
662
+ async deleteRecord(table, key) {
663
+ this.assertOpen();
664
+ this.assertTable(table);
665
+ await this.repository.deleteWorkspace(key);
666
+ }
667
+ async setGlobal(value) {
668
+ this.assertOpen();
669
+ await this.repository.writeWorkspaceState(workspaceStateOf(value));
670
+ }
671
+ async close() {
672
+ if (this.closed) return;
673
+ this.closed = true;
674
+ this.onClose();
675
+ }
676
+ assertOpen() {
677
+ if (this.closed) throw new StorageError("closed", `kv unit '${this.descriptor.name}' is closed`);
678
+ }
679
+ assertTable(table) {
680
+ if (table !== WORKSPACE_TABLE) throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
681
+ }
682
+ };
683
+ /** Narrow one opaque KV record to the workspace record the domain shipped. */
684
+ function workspaceRecordOf(value) {
685
+ const record = value;
686
+ if (typeof record !== "object" || record === null || typeof record.path !== "string" || typeof record.title !== "string" || !Array.isArray(record.sessionIds) || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string") throw new TypeError("workspace record does not match the stored shape");
687
+ return {
688
+ path: record.path,
689
+ title: record.title,
690
+ sessionIds: record.sessionIds.map((id) => id),
691
+ createdAt: record.createdAt,
692
+ updatedAt: record.updatedAt
693
+ };
694
+ }
695
+ /** Narrow one opaque KV global to the workspace registry state the domain shipped. */
696
+ function workspaceStateOf(value) {
697
+ const state = value;
698
+ if (typeof state !== "object" || state === null || typeof state.initialized !== "boolean" || !Array.isArray(state.workspaceIds) || !Array.isArray(state.archivedSessionIds)) throw new TypeError("workspace registry state does not match the stored shape");
699
+ return {
700
+ initialized: state.initialized,
701
+ workspaceIds: state.workspaceIds.map((id) => id),
702
+ archivedSessionIds: state.archivedSessionIds.map((id) => id),
703
+ ...state.pendingMutation === void 0 ? {} : { pendingMutation: state.pendingMutation }
704
+ };
705
+ }
706
+ //#endregion
707
+ //#region src/storage-takeover/index.ts
708
+ /**
709
+ * Install the storages takeover on the session-rdb plugin context.
710
+ * @param ctx - session-rdb plugin context.
711
+ * @param options - repository and projection-cache throttle parameters.
712
+ */
713
+ function installStorageTakeover(ctx, options) {
714
+ ctx.inject(["storage"], (storageCtx) => {
715
+ const backend = new RdbStorageBackend(options.repository);
716
+ storageCtx.effect(() => {
717
+ const unregister = storageCtx.storage.backend.register("rdb", backend);
718
+ return async () => {
719
+ unregister();
720
+ await backend.close();
721
+ };
722
+ }, "session-rdb.storageBackend");
723
+ storageCtx.provide(storageBackendServiceKey("rdb"), backend);
724
+ });
725
+ ctx.inject(["sessionProjections"], (projectionCtx) => {
726
+ new SessionProjectionCacheRdb(projectionCtx, options.projectionCache, options.repository, options.ready);
727
+ });
728
+ }
729
+ //#endregion
253
730
  //#region src/index.ts
731
+ /** 投影缓存默认写节流(与上游 base 装配的部署值一致)。 */
732
+ const DEFAULT_PROJECTION_WRITE_EVERY_EVENTS = 200;
733
+ const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5e3;
254
734
  /** 会话级写所有权与 live 路由簿记。 */
255
735
  var RdbBackendTracker = class {
256
736
  name;
@@ -519,11 +999,25 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
519
999
  "truncate",
520
1000
  "persist"
521
1001
  ]).default("wal"),
522
- busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS)
1002
+ busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
1003
+ projectionCache: z.object({
1004
+ writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
1005
+ writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
1006
+ }).default({
1007
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1008
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1009
+ })
523
1010
  }), z.object({
524
1011
  type: z.const("postgres"),
525
1012
  connectionString: z.string().required(),
526
- schema: z.string().default("public")
1013
+ schema: z.string().default("public"),
1014
+ projectionCache: z.object({
1015
+ writeEveryEvents: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_EVERY_EVENTS),
1016
+ writeIntervalMs: z.natural().min(1).default(DEFAULT_PROJECTION_WRITE_INTERVAL_MS)
1017
+ }).default({
1018
+ writeEveryEvents: DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1019
+ writeIntervalMs: DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1020
+ })
527
1021
  })]);
528
1022
  static settingsNs = "session-rdb";
529
1023
  name = "session-rdb";
@@ -554,6 +1048,14 @@ var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersisten
554
1048
  this.installLiveRouting(ctx);
555
1049
  new SessionBranchRdb(this.ctx);
556
1050
  registerSessionImport(this.ctx, this);
1051
+ installStorageTakeover(this.ctx, {
1052
+ repository: this.backend.storage,
1053
+ ready: this.ready,
1054
+ projectionCache: {
1055
+ writeEveryEvents: this.config.projectionCache?.writeEveryEvents ?? DEFAULT_PROJECTION_WRITE_EVERY_EVENTS,
1056
+ writeIntervalMs: this.config.projectionCache?.writeIntervalMs ?? DEFAULT_PROJECTION_WRITE_INTERVAL_MS
1057
+ }
1058
+ });
557
1059
  }
558
1060
  async init() {
559
1061
  await this.backend.open();
@@ -1064,6 +1566,7 @@ async function appendEventTail(tx, meta, events, anchor, reuse) {
1064
1566
  }
1065
1567
  if (eventRows.length > 0) await tx.insertEvents(eventRows);
1066
1568
  await tx.insertBridges(bridgeRows);
1569
+ if (events.some((event) => event.type === "session/title")) await tx.refreshTitle(meta.id);
1067
1570
  return {
1068
1571
  headEventId: parentId,
1069
1572
  headSequence: nextSeq - 1
@@ -513,5 +513,20 @@ function toJsonlArtifact(meta, inheritedEventCount, events) {
513
513
  for (const event of events) lines.push(JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event)));
514
514
  return lines.join("\n");
515
515
  }
516
+ /**
517
+ * 事件行 JSON → `session/title` 的标题文本。存储层写路径保证 `f_data` 是完整
518
+ * 事件 JSON;形状不符(或损坏)按「无标题」处理——标题是派生显示数据。
519
+ * @param data - `t_events.f_data` 原文。
520
+ * @returns 标题文本,或 `undefined`。
521
+ */
522
+ function titleOfEventData(data) {
523
+ try {
524
+ const value = JSON.parse(data);
525
+ const title = value.data?.title ?? value.title;
526
+ return typeof title === "string" ? title : void 0;
527
+ } catch {
528
+ return;
529
+ }
530
+ }
516
531
  //#endregion
517
- export { repairAssistantSettlement as a, repairRequestHeaders as c, rowToMeta as d, scanRows as f, toJsonlArtifact as g, syncMeteringRanges as h, renameLegacyPtcEvents as i, repairSurfaceOps as l, sessionInsertRow as m, orphanInboxSpliceSeqs as n, repairOrphanInboxSplices as o, sessionConflictRow as p, recomputeReplaceProvenance as r, repairReadView as s, findSurfaceRepairs as t, rowToEvent as u };
532
+ export { toJsonlArtifact as _, repairAssistantSettlement as a, repairRequestHeaders as c, rowToMeta as d, scanRows as f, titleOfEventData as g, syncMeteringRanges as h, renameLegacyPtcEvents as i, repairSurfaceOps as l, sessionInsertRow as m, orphanInboxSpliceSeqs as n, repairOrphanInboxSplices as o, sessionConflictRow as p, recomputeReplaceProvenance as r, repairReadView as s, findSurfaceRepairs as t, rowToEvent as u };