@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
@@ -7,8 +7,14 @@
7
7
  * 在介质事务内完成(`host.writeAtomically`),不留部分应用的中间态。
8
8
  */
9
9
 
10
- import { and, eq, gte, isNotNull, notInArray, sql } from "drizzle-orm";
11
- import { SessionId, SessionLogOffset, SessionSeq } from "@deepseek-ai/dsh-session";
10
+ import { randomUUID } from "node:crypto";
11
+ import { and, eq, gte, inArray, isNotNull, lt, notInArray, sql } from "drizzle-orm";
12
+ import {
13
+ SESSION_FORMAT_VERSION,
14
+ SessionId,
15
+ SessionLogOffset,
16
+ SessionSeq,
17
+ } from "@deepseek-ai/dsh-session";
12
18
  import type { SessionSeqCursor } from "@deepseek-ai/dsh-session";
13
19
  import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
14
20
  import type {
@@ -92,9 +98,7 @@ function identityOfSession(row: SessionIdentityRow): CheckpointIdentity {
92
98
  createdAt: row.fCreatedAt,
93
99
  ...(row.fCwd === null ? {} : { cwd: row.fCwd }),
94
100
  isSeeded: row.fSeedLength !== null,
95
- ...(row.fSeedLength === null
96
- ? {}
97
- : { inheritedEventCount: SessionLogOffset(row.fSeedLength) }),
101
+ ...(row.fSeedLength === null ? {} : { inheritedEventCount: SessionLogOffset(row.fSeedLength) }),
98
102
  };
99
103
  }
100
104
 
@@ -305,20 +309,38 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
305
309
  .update(tSessions)
306
310
  .set({ fArchivedAt: null })
307
311
  .where(
308
- and(
309
- isNotNull(tSessions.fArchivedAt),
310
- notInArray(tSessions.fSessionId, archived),
311
- ),
312
+ and(isNotNull(tSessions.fArchivedAt), notInArray(tSessions.fSessionId, archived)),
312
313
  ),
313
314
  );
314
315
  const stamp = Date.now();
315
316
  for (const sessionId of archived) {
316
- // 首次归档时间保留:重复写入同一集合不刷新标记。
317
+ // 归档标记落在会话行上(f_archived_at);live 但从未物化的会话还没有
318
+ // 行——补一行骨架(head=-1、无 cwd/seed),否则标记无处可写,重启后
319
+ // 归档集由介质重算时会静默丢失。真正物化走 upsertSession,其冲突列
320
+ // 不含 f_archived_at,所以标记在物化后仍保留。
317
321
  await runQuery(
318
322
  db
319
- .update(tSessions)
320
- .set({ fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` })
321
- .where(eq(tSessions.fSessionId, sessionId)),
323
+ .insert(tSessions)
324
+ .values({
325
+ fSessionId: sessionId,
326
+ fHeadEventId: "",
327
+ fHeadSequence: -1,
328
+ fVersion: SESSION_FORMAT_VERSION,
329
+ fCreatedAt: stamp,
330
+ fCwd: null,
331
+ fParentSession: null,
332
+ fSeedLength: null,
333
+ fOrigin: null,
334
+ fDelegationDepth: null,
335
+ fIncarnation: randomUUID(),
336
+ fRevision: 0,
337
+ fArchivedAt: stamp,
338
+ })
339
+ .onConflictDoUpdate({
340
+ target: tSessions.fSessionId,
341
+ // 首次归档时间保留:重复写入同一集合不刷新标记。
342
+ set: { fArchivedAt: sql`COALESCE(${tSessions.fArchivedAt}, ${stamp})` },
343
+ }),
322
344
  );
323
345
  }
324
346
  });
@@ -353,10 +375,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
353
375
  return [...entries.values()].filter((entry) => Object.keys(entry.rows).length > 0);
354
376
  },
355
377
 
356
- async putProjcache(
357
- sessionId: string,
358
- rows: ProjectionCheckpoint,
359
- ): Promise<void> {
378
+ async putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void> {
360
379
  await host.writeAtomically(async () => {
361
380
  const db = await dbx();
362
381
  await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
@@ -371,9 +390,35 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
371
390
  });
372
391
  },
373
392
 
374
- async deleteProjcache(sessionId: string): Promise<void> {
393
+ async pruneStaleProjcache(): Promise<number> {
375
394
  const db = await dbx();
376
- await runQuery(db.delete(tProjcacheRows).where(eq(tProjcacheRows.fSessionId, sessionId)));
395
+ const stale = await allRows<{ fSessionId: string }>(
396
+ db
397
+ .select({ fSessionId: tProjcacheRows.fSessionId })
398
+ .from(tProjcacheRows)
399
+ .where(
400
+ and(
401
+ lt(tProjcacheRows.fSeq, 0),
402
+ inArray(
403
+ tProjcacheRows.fSessionId,
404
+ db
405
+ .select({ fSessionId: tSessions.fSessionId })
406
+ .from(tSessions)
407
+ .where(gte(tSessions.fHeadSequence, 0)),
408
+ ),
409
+ ),
410
+ ),
411
+ );
412
+ if (stale.length === 0) return 0;
413
+ await runQuery(
414
+ db.delete(tProjcacheRows).where(
415
+ inArray(
416
+ tProjcacheRows.fSessionId,
417
+ stale.map((row) => row.fSessionId),
418
+ ),
419
+ ),
420
+ );
421
+ return stale.length;
377
422
  },
378
423
 
379
424
  // 同步驱动(SQLite)才有:读路径直接查介质,进程内不维护 checkpoint 镜像。
@@ -412,7 +457,8 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
412
457
  .from(tSessions)
413
458
  .where(eq(tSessions.fSessionId, sessionId))
414
459
  .get() as { fTitle: string | null; fTitleSeq: number | null } | undefined;
415
- if (row === undefined || row.fTitle === null || row.fTitleSeq === null) return undefined;
460
+ if (row === undefined || row.fTitle === null || row.fTitleSeq === null)
461
+ return undefined;
416
462
  return { title: row.fTitle, seq: row.fTitleSeq };
417
463
  },
418
464
  }),
@@ -25,8 +25,8 @@ const WORKSPACE_TABLE = "workspaces";
25
25
  export class RdbStorageBackend implements StorageBackend {
26
26
  readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
27
27
 
28
- /** 已打开(或打开中)的 unit 名;重复 open 是调用方 bug。 */
29
- private readonly open = new Set<string>();
28
+ /** 已打开(或打开中)的 unit;重复 open 是调用方 bug。 */
29
+ private readonly open = new Map<string, WorkspaceKvUnit>();
30
30
  private closed = false;
31
31
 
32
32
  /**
@@ -47,46 +47,73 @@ export class RdbStorageBackend implements StorageBackend {
47
47
  `(table '${WORKSPACE_TABLE}' plus a global slot)`,
48
48
  );
49
49
  }
50
+ // 本后端是 single 布局的专用表实现:per-record 布局(及其 compatibleVersions
51
+ // 读语义)无法表达,宁可 fail loud 也不静默按 single 读。
52
+ if (descriptor.layout !== undefined && descriptor.layout !== "single") {
53
+ throw new Error(
54
+ `rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
55
+ `declares '${descriptor.layout}')`,
56
+ );
57
+ }
50
58
  if (this.open.has(descriptor.name)) {
51
59
  throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
52
60
  }
53
- const stored = await this.repository.readUnitVersion(descriptor.name);
54
- if (stored === undefined) {
55
- await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
56
- } else if (stored !== descriptor.version) {
57
- throw new StorageError(
58
- "version-mismatch",
59
- `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
60
- `incompatible with descriptor version ${descriptor.version}`,
61
- );
61
+ // 名字在同步段预留:并发第二个 open 必须在任何 await 之前被拒。
62
+ const unit = new WorkspaceKvUnit(this.repository, descriptor);
63
+ this.open.set(descriptor.name, unit);
64
+ try {
65
+ const stored = await this.repository.readUnitVersion(descriptor.name);
66
+ if (stored === undefined) {
67
+ await this.repository.insertUnitVersion(descriptor.name, descriptor.version);
68
+ } else if (stored !== descriptor.version) {
69
+ throw new StorageError(
70
+ "version-mismatch",
71
+ `kv unit '${descriptor.name}' is stamped version ${stored} on the medium, ` +
72
+ `incompatible with descriptor version ${descriptor.version}`,
73
+ );
74
+ }
75
+ } catch (error) {
76
+ this.open.delete(descriptor.name);
77
+ throw error;
62
78
  }
63
- this.open.add(descriptor.name);
64
- return new WorkspaceKvUnit(this.repository, descriptor, () => {
79
+ unit.onClose(() => {
65
80
  this.open.delete(descriptor.name);
66
81
  });
82
+ return unit;
67
83
  }
68
84
 
69
85
  /**
70
- * Release the backend. The medium (the session database) belongs to the
71
- * owning session-rdb plugin, so closing open units here does not close it.
72
- * @returns resolution after the name table is cleared.
86
+ * Release the backend. New writes are rejected immediately; already-queued
87
+ * unit writes drain first (upstream backend contract). The medium (the
88
+ * session database) belongs to the owning session-rdb plugin, so closing
89
+ * open units here does not close it.
90
+ * @returns resolution after every open unit drained.
73
91
  */
74
92
  async close(): Promise<void> {
75
93
  this.closed = true;
94
+ const units = [...this.open.values()];
76
95
  this.open.clear();
96
+ await Promise.all(units.map((unit) => unit.close()));
77
97
  }
78
98
  }
79
99
 
80
100
  /** workspace 域的 KV unit:每个原语一条 SQL 语句,值形状与上游记录一致。 */
81
101
  class WorkspaceKvUnit implements KvUnit {
82
102
  private closed = false;
103
+ /** 在途写操作:close 先拒绝新写,再 drain 它们(不丢已受理的写)。 */
104
+ private readonly inflight = new Set<Promise<void>>();
105
+ private onClosed: (() => void) | undefined;
83
106
 
84
107
  constructor(
85
108
  private readonly repository: StorageRepository,
86
109
  private readonly descriptor: KvUnitDescriptor,
87
- private readonly onClose: () => void,
88
110
  ) {}
89
111
 
112
+ /** Install the name-release callback after the backend registered this unit. */
113
+ onClose(release: () => void): void {
114
+ this.onClosed = release;
115
+ }
116
+
90
117
  async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
91
118
  this.assertOpen();
92
119
  const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
@@ -100,24 +127,41 @@ class WorkspaceKvUnit implements KvUnit {
100
127
  async putRecord(table: string, key: string, value: unknown): Promise<void> {
101
128
  this.assertOpen();
102
129
  this.assertTable(table);
103
- await this.repository.putWorkspace(key, workspaceRecordOf(value));
130
+ await this.track(this.repository.putWorkspace(key, workspaceRecordOf(value)));
104
131
  }
105
132
 
106
133
  async deleteRecord(table: string, key: string): Promise<void> {
107
134
  this.assertOpen();
108
135
  this.assertTable(table);
109
- await this.repository.deleteWorkspace(key);
136
+ await this.track(this.repository.deleteWorkspace(key));
110
137
  }
111
138
 
112
139
  async setGlobal(value: unknown): Promise<void> {
113
140
  this.assertOpen();
114
- await this.repository.writeWorkspaceState(workspaceStateOf(value));
141
+ await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
115
142
  }
116
143
 
117
144
  async close(): Promise<void> {
118
145
  if (this.closed) return;
119
146
  this.closed = true;
120
- this.onClose();
147
+ while (this.inflight.size > 0) {
148
+ await Promise.allSettled(this.inflight);
149
+ }
150
+ this.onClosed?.();
151
+ }
152
+
153
+ /** Register one accepted write so close can drain it. */
154
+ private async track(operation: Promise<unknown>): Promise<void> {
155
+ const settled = operation.then(
156
+ () => undefined,
157
+ () => undefined,
158
+ );
159
+ this.inflight.add(settled);
160
+ try {
161
+ await operation;
162
+ } finally {
163
+ this.inflight.delete(settled);
164
+ }
121
165
  }
122
166
 
123
167
  private assertOpen(): void {
@@ -11,7 +11,10 @@
11
11
 
12
12
  import type { SessionId } from "@deepseek-ai/dsh-session";
13
13
  import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
14
- import type { ProjectionCheckpoint, ProjectionCheckpointRow } from "@deepseek-ai/dsh-session-projection";
14
+ import type {
15
+ ProjectionCheckpoint,
16
+ ProjectionCheckpointRow,
17
+ } from "@deepseek-ai/dsh-session-projection";
15
18
  import type { WorkspaceDomainState, WorkspaceRecord } from "@deepseek-ai/dsh-workspace";
16
19
 
17
20
  export type {
@@ -77,6 +80,11 @@ export interface StorageRepository {
77
80
  * @param rows - 每个投影 key 一行。
78
81
  */
79
82
  putProjcache(sessionId: string, rows: ProjectionCheckpoint): Promise<void>;
80
- /** 删除一个会话的 checkpoint(缺失为 no-op)。 */
81
- deleteProjcache(sessionId: string): Promise<void>;
83
+ /**
84
+ * 清理陈旧 checkpoint 行:水位为负却已有事件的会话(旧版写入路径的产物,
85
+ * 会把有对话的会话误判为空白)。缓存是派生数据,删除只让该会话回到
86
+ * 「未知即可见」并等待下一次强制点重写。
87
+ * @returns 删除的行数。
88
+ */
89
+ pruneStaleProjcache(): Promise<number>;
82
90
  }
package/src/testing.ts CHANGED
@@ -5,3 +5,6 @@ export type { ContractBackend } from "./testing/contract.ts";
5
5
  export { appendLog, meta, oneTurnLog, runPersistenceContract } from "./testing/contract.ts";
6
6
  export type { CoordinatorFixture } from "./testing/coordinator-contract.ts";
7
7
  export { runCoordinatorContract } from "./testing/coordinator-contract.ts";
8
+ // rewind 落到 live 会话上的那一件事:截断内存 log 并重置派生 surface / 折叠
9
+ // 缓存。跨包测试要构造「rewind 之后」的会话态时用它,不必拉起 RDB 装配。
10
+ export { truncateLiveSession } from "./branch.ts";