@morlay/session-rdb 0.1.9 → 0.1.10-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.
package/src/branch.ts CHANGED
@@ -153,7 +153,19 @@ function resetSurfaceManager(surfaceManager: SurfaceManagerLike): void {
153
153
  surfaceManager._pendingPlan = undefined;
154
154
  }
155
155
 
156
- export function truncateLiveSession(session: Session, newLength: number): void {
156
+ /**
157
+ * 会话**当前内存 log** 的快照(复制一份)。用于"需要完整序列"的操作(首次落库的 seed、rewind / fork 的历史),
158
+ * 不走已弃用的同步历史读 `snapshotEvents()`——那三个读口上游已声明「新调用禁止」,而这里要的是当前内存里
159
+ * 那份完整日志,不是按需分页的历史。
160
+ * @param session - 会话。
161
+ * @returns 事件副本(调用方可以自由遍历)。
162
+ */
163
+ export function liveEventsOf(session: Session): readonly SessionEvent[] {
164
+ return [...(session as unknown as { log: readonly SessionEvent[] }).log];
165
+ }
166
+
167
+ /** @returns 截断后日志里最后一个 `turn/start` 的 turn 号(没有则 0)。 */
168
+ export function truncateLiveSession(session: Session, newLength: number): number {
157
169
  const s = session as unknown as {
158
170
  log: SessionEvent[];
159
171
  eventsSnapshot?: unknown;
@@ -166,6 +178,9 @@ export function truncateLiveSession(session: Session, newLength: number): void {
166
178
  derivedGeneration: number;
167
179
  surfaceManager: SurfaceManagerLike;
168
180
  };
181
+ // 截断后 agent 的 turn 游标要跟着落在保留前缀的最后一个 turn 上:这里直接读**要保留的那段**(同一个数组,
182
+ // 不经过已弃用的同步历史读 `snapshotEvents`,也不依赖 agent 侧的投影)。
183
+ const lastTurn = s.log.slice(0, newLength).findLast((event) => event.type === "turn/start")?.data.turn ?? 0;
169
184
  s.log.length = newLength;
170
185
  s.eventsSnapshot = undefined;
171
186
  s.headerFold = undefined;
@@ -176,6 +191,7 @@ export function truncateLiveSession(session: Session, newLength: number): void {
176
191
  s.derivedNodes = 0;
177
192
  s.derivedGeneration = 0;
178
193
  resetSurfaceManager(s.surfaceManager);
194
+ return lastTurn;
179
195
  }
180
196
 
181
197
  export function replaceLiveSessionLog(session: Session, events: readonly SessionEvent[]): void {
@@ -407,7 +423,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
407
423
  internals.writeGuard.confirmHead(id, denseBoundary);
408
424
 
409
425
  if (live !== undefined) {
410
- truncateLiveSession(live, keepLength);
426
+ const lastTurn = truncateLiveSession(live, keepLength);
411
427
 
412
428
  this.live.resetProjections?.(live);
413
429
 
@@ -416,8 +432,6 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
416
432
  if (agent !== undefined) {
417
433
  agent.requestHeaderLogged = false;
418
434
 
419
- const lastTurn =
420
- live.snapshotEvents().findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
421
435
  const phase = (agent as unknown as { phase?: { lastTurn?: number } }).phase;
422
436
  if (phase !== undefined) phase.lastTurn = lastTurn;
423
437
  }
package/src/index.ts CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  } from "./schema.ts";
58
58
  import { SqliteBackend } from "./sqlite.ts";
59
59
  import { PostgresBackend } from "./postgres.ts";
60
- import { SessionBranchRdb } from "./branch.ts";
60
+ import { SessionBranchRdb, liveEventsOf } from "./branch.ts";
61
61
  import { balanceRewindPrefix } from "@morlay/session-branch";
62
62
  import { registerSessionImport } from "./import.ts";
63
63
  import { registerSessionDeletion } from "./deletion.ts";
@@ -93,7 +93,7 @@ const DEFAULT_PROJECTION_WRITE_INTERVAL_MS = 5000;
93
93
  export { SCHEMA_VERSION } from "./schema.ts";
94
94
  export { SESSION_ROWS_PATH } from "./rows.ts";
95
95
  export type { SessionRowsItem, SessionRowsValue } from "./rows.ts";
96
- export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
96
+ export { SessionBranchRdb, SessionBranchRdbProvider, liveEventsOf, locateTurnEnd } from "./branch.ts";
97
97
 
98
98
  export interface SessionPersistenceRdbInternals {
99
99
  readonly backend: Backend;
@@ -134,12 +134,14 @@ export type Config =
134
134
  journalMode?: JournalMode | undefined;
135
135
  busyTimeout?: number | undefined;
136
136
  projectionCache?: ProjectionCacheOptions | undefined;
137
+ preparedSessionCacheSize?: number | undefined;
137
138
  }
138
139
  | {
139
140
  type: "postgres";
140
141
  connectionString: string;
141
142
  schema?: string | undefined;
142
143
  projectionCache?: ProjectionCacheOptions | undefined;
144
+ preparedSessionCacheSize?: number | undefined;
143
145
  };
144
146
 
145
147
  // schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。
@@ -493,6 +495,22 @@ class RdbSessionHandle implements SessionHandle {
493
495
  const localized = (text: { zh: string; en: string }): string => text as unknown as string;
494
496
 
495
497
  // 投影 checkpoint 的写回节流:两个分支共用。
498
+ // 上游 session-query 的**冷会话观察缓存**容量(`preparedSessionCacheSize`):缓存按**条目数**持有完整
499
+ // `Session`(含整份 append-only log),被活跃观察租约钉住的条目不淘汰;一次读取中途转成 live 时会重试 live
500
+ // 路径,但**不会顺手清掉已缓存的 cold 条目**——所以同一个 id 可能同时有两份 log 驻留。上游默认 5;本部署把它
501
+ // 收到最小的 1:本地单用户、rdb 后端,冷读复用的收益不值这份内存放大。上游校验是 >= 1(不能设 0)。
502
+ const preparedSessionCacheSchema = z
503
+ .number()
504
+ .step(1)
505
+ .min(1)
506
+ .default(1)
507
+ .description(
508
+ localized({
509
+ zh: "冷会话观察缓存容量(条数,最小 1):每个条目持一份完整会话日志;本部署默认 1,把同 id 两份日志的驻留压到最小。",
510
+ en: "Cold prepared-Session observation cache size (entries, minimum 1): each entry holds one complete session log. This deployment defaults to 1, minimizing duplicate per-id residency.",
511
+ }),
512
+ );
513
+
496
514
  const projectionCacheSchema = z
497
515
  .object({
498
516
  writeEveryEvents: z
@@ -560,6 +578,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
560
578
  }),
561
579
  ),
562
580
  projectionCache: projectionCacheSchema,
581
+ preparedSessionCacheSize: preparedSessionCacheSchema,
563
582
  }),
564
583
  z.object({
565
584
  type: z.const("postgres"),
@@ -582,6 +601,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
582
601
  }),
583
602
  ),
584
603
  projectionCache: projectionCacheSchema,
604
+ preparedSessionCacheSize: preparedSessionCacheSchema,
585
605
  }),
586
606
  ])
587
607
  .description(
@@ -628,7 +648,12 @@ export class SessionPersistenceRdb extends SessionPersistence {
628
648
 
629
649
  new SessionBranchRdb(this.ctx);
630
650
 
631
- this.ctx.plugin(SessionQueryRdb, {});
651
+ // 冷会话观察缓存的容量在这一行配置里(默认见 `preparedSessionCacheSchema`);session-query 自己不会再读它。
652
+ const queryConfig =
653
+ this.config.preparedSessionCacheSize === undefined
654
+ ? {}
655
+ : { preparedSessionCacheSize: this.config.preparedSessionCacheSize };
656
+ this.ctx.plugin(SessionQueryRdb, queryConfig);
632
657
 
633
658
  registerSessionImport(this.ctx, this);
634
659
 
@@ -875,6 +900,17 @@ export class SessionPersistenceRdb extends SessionPersistence {
875
900
  await this.backend.vacuum();
876
901
  }
877
902
 
903
+ /**
904
+ * 已归档的会话 id(`t_sessions.f_archived_at` 非空)。`session/list` 默认过滤掉它们。
905
+ * 读的是持久化里的归档标记,不依赖 `workspaceRegistry` 服务的就绪时机。
906
+ * @returns 归档过的 Session id 集。
907
+ */
908
+ async archivedSessionIds(): Promise<ReadonlySet<string>> {
909
+ await this.ready;
910
+ const state = await this.backend.storage.readWorkspaceState();
911
+ return new Set((state?.archivedSessionIds ?? []).map(String));
912
+ }
913
+
878
914
  private async unarchiveBeforeDeletion(id: SessionId): Promise<void> {
879
915
  const registry = this.ctx.get("workspaceRegistry") as unknown as
880
916
  | {
@@ -1440,7 +1476,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
1440
1476
  handle = (await this.create(session.header, {
1441
1477
  inheritedEventCount: session.inheritedEventCount,
1442
1478
  })) as RdbSessionHandle;
1443
- const seed = session.snapshotEvents();
1479
+ const seed = liveEventsOf(session);
1444
1480
  if (seed.length > 0) await handle.append(seed);
1445
1481
  } else {
1446
1482
  if (stored.meta.cwd !== session.header.cwd) {
@@ -1456,7 +1492,7 @@ export class SessionPersistenceRdb extends SessionPersistence {
1456
1492
  assertVersion(stored.meta);
1457
1493
 
1458
1494
  repairReadView(stored.events);
1459
- const seed = session.snapshotEvents();
1495
+ const seed = liveEventsOf(session);
1460
1496
  if (!seedCoversPrefix(seed, stored.events)) {
1461
1497
  throw new Error(
1462
1498
  `session "${id}" already has a persisted log on disk that does not match this live session (id collision)`,
package/src/log.ts CHANGED
@@ -108,12 +108,12 @@ export function rowToEvent(row: EventRow): SessionEvent {
108
108
  typeof (record as Record<string, unknown>)["time"] === "number" &&
109
109
  "data" in (record as Record<string, unknown>);
110
110
  if (full) {
111
- return {
112
- ...(record as SessionEvent),
113
- seq: row.fSequence,
114
- time: row.fCreatedAt,
115
- ...(surfaceOp === undefined ? {} : { surfaceOp }),
116
- } as SessionEvent;
111
+ // 直接改写出参而不是 `{...record}`:冷读一页就是几万次,spread 在这里是纯开销。
112
+ const event = record as SessionEvent & { seq: number; time: number; surfaceOp?: unknown };
113
+ event.seq = row.fSequence as SessionEvent["seq"];
114
+ event.time = row.fCreatedAt as SessionEvent["time"];
115
+ if (surfaceOp !== undefined) event.surfaceOp = surfaceOp;
116
+ return event as SessionEvent;
117
117
  }
118
118
  return {
119
119
  type: row.fType as SessionEvent["type"],
@@ -648,44 +648,40 @@ export function scanRows(
648
648
  rows: readonly EventRow[],
649
649
  base = 0,
650
650
  ): { preserved: SessionEvent[]; tornFrom?: number } {
651
- interface Parsed {
652
- ok: boolean;
653
- event?: SessionEvent;
654
- }
655
- const parsed: Parsed[] = rows.map((row) => {
656
- try {
657
- return { ok: true, event: rowToEvent(row) };
658
- } catch {
659
- return { ok: false };
660
- }
661
- });
662
-
651
+ // 已提交区(最后一个 `turn/end` 及之前)必须完整:坏行/断号只有在它之后才允许当作撕裂尾部丢掉。
652
+ // 倒序只为找到这个边界,先按行类型筛(绝大多数行直接跳过),再解析候选行确认它本身可读。
663
653
  let lastTurnEnd = -1;
664
- for (let i = parsed.length - 1; i >= 0; i--) {
665
- if (parsed[i]?.ok && rows[i]?.fType === "turn/end") {
654
+ for (let i = rows.length - 1; i >= 0; i--) {
655
+ if (rows[i]?.fType !== "turn/end") continue;
656
+ try {
657
+ rowToEvent(rows[i] as EventRow);
666
658
  lastTurnEnd = i;
667
659
  break;
660
+ } catch {
661
+ /* 这个 turn/end 自身坏掉:继续往前找上一个可读的边界。 */
668
662
  }
669
663
  }
670
664
 
671
665
  const preserved: SessionEvent[] = [];
672
666
  for (let i = 0; i < rows.length; i++) {
673
- const p = parsed[i];
674
- if (!p?.ok || p.event === undefined) {
667
+ let event: SessionEvent;
668
+ try {
669
+ event = rowToEvent(rows[i] as EventRow);
670
+ } catch {
675
671
  if (i <= lastTurnEnd)
676
672
  throw new Error(
677
673
  `corrupt session log: unparsable committed event at seq ${rows[i]?.fSequence}`,
678
674
  );
679
675
  break;
680
676
  }
681
- if (p.event.seq !== base + i) {
677
+ if (event.seq !== base + i) {
682
678
  if (i <= lastTurnEnd)
683
679
  throw new Error(
684
- `corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`,
680
+ `corrupt session log: seq gap in committed region (expected ${base + i}, got ${event.seq})`,
685
681
  );
686
682
  break;
687
683
  }
688
- preserved.push(p.event);
684
+ preserved.push(event);
689
685
  }
690
686
 
691
687
  return preserved.length < rows.length
@@ -7,25 +7,38 @@ import {
7
7
  } from "@deepseek-ai/dsh-session-query";
8
8
 
9
9
  export class SessionQueryRdb extends SessionQueryEngine {
10
+ /**
11
+ * 本次装配用的冷会话观察缓存容量(由 `session-rdb` 行的 `preparedSessionCacheSize` 透传;undefined 表示沿用
12
+ * 上游默认)。它是**按条目数**持有完整 Session 的缓存:条目多一条就多一份完整 append-only log 驻留。
13
+ */
14
+ readonly preparedSessionCacheSize: number | undefined;
15
+
10
16
  constructor(ctx: Context, config: Config = {}) {
11
17
  super(ctx, config);
18
+ this.preparedSessionCacheSize = config.preparedSessionCacheSize;
12
19
  }
13
20
 
14
21
  // 官方列表(`session/list`)**默认不含已归档**:归档是「收起来」的会话,上游 client 用 workspace registry
15
22
  // 的归档集做「只看归档」的派生;完整语料走管理面路由 `SESSION_ROWS_PATH`。
16
23
  override async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
17
24
  const records = await super.listSessions(signal);
18
- const archived = this.archivedSessionIds();
25
+ const archived = await this.archivedSessionIds();
19
26
  if (archived.size === 0) return records;
20
27
  return records.filter((record) => !archived.has(String(record.header.id)));
21
28
  }
22
29
 
23
- // 归档集合来自 workspace registry;服务未就绪时保守返回空集(宁可多给,不可漏掉可见会话)。
24
- private archivedSessionIds(): ReadonlySet<string> {
30
+ // 归档集合:优先读 workspace registry(它和页面上的归档动作同源);registry 不在(headless / 启动早期 /
31
+ // 测试装配)就退回持久化里的 `f_archived_at`——`session/list` 默认**不含**已归档是这一层的语义,不能因为
32
+ // 一个可选服务没到就把归档会话又列出来。
33
+ private async archivedSessionIds(): Promise<ReadonlySet<string>> {
25
34
  const registry = this.ctx.get("workspaceRegistry" as never) as unknown as
26
35
  | { archivedSessionIds?: readonly string[] }
27
36
  | undefined;
28
- return new Set<string>(registry?.archivedSessionIds ?? []);
37
+ if (registry !== undefined) return new Set<string>(registry.archivedSessionIds ?? []);
38
+ const persistence = this.ctx.get("sessionPersistence") as unknown as
39
+ | { archivedSessionIds?: () => Promise<ReadonlySet<string>> }
40
+ | undefined;
41
+ return (await persistence?.archivedSessionIds?.()) ?? new Set<string>();
29
42
  }
30
43
 
31
44
  override async searchSessions(): Promise<never> {
package/src/sqlite.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdir, open, readdir, stat } from "node:fs/promises";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { DatabaseSync } from "node:sqlite";
6
- import { and, desc, eq, gte, inArray, lt, notInArray, sql } from "drizzle-orm";
6
+ import { and, desc, eq, inArray, lt, notInArray, sql } from "drizzle-orm";
7
7
  import { drizzle, type NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
8
8
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
9
9
  import type { SessionId } from "@deepseek-ai/dsh-session";
@@ -341,6 +341,10 @@ function configureDatabase(
341
341
  db.exec("PRAGMA foreign_keys = ON");
342
342
 
343
343
  db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
344
+ // 冷读一次要顺序扫整份日志(大会话 30k+ 事件、几十 MiB 的 `f_data`):把页缓存与 mmap 窗口调大,
345
+ // 省掉的是 SQLite 与内核页缓存之间的往返。`:memory:` 上这两个 PRAGMA 无害。
346
+ db.exec("PRAGMA cache_size = -65536");
347
+ db.exec("PRAGMA mmap_size = 268435456");
344
348
  const dbx = drizzle({ client: db });
345
349
 
346
350
  dbx.transaction(
@@ -500,13 +504,19 @@ export class SqliteBackend implements Backend {
500
504
  }
501
505
 
502
506
  async getEventRows(id: SessionId, fromSequence?: number): Promise<EventRow[]> {
503
- const scoped =
504
- fromSequence === undefined
505
- ? this.eventRows().where(eq(tSessionEvents.fSessionId, id))
506
- : this.eventRows().where(
507
- and(eq(tSessionEvents.fSessionId, id), gte(tSessionEvents.fSequence, fromSequence)),
508
- );
509
- return scoped.orderBy(tSessionEvents.fSequence).all() as unknown as EventRow[];
507
+ // 裸 prepared 语句而不是 drizzle 的 `.select().all()`:冷读要一次拉整份日志(大会话 30k+ 行、几十 MiB 的
508
+ // `f_data`),drizzle 的逐行对象映射在这条路径上是纯开销。列集合与 `eventRows()` 完全一致。
509
+ const sql =
510
+ `select se.f_event_id as fEventId, se.f_sequence as fSequence, e.f_type as fType,
511
+ e.f_kind as fKind, e.f_role as fRole, e.f_name as fName, e.f_action_id as fActionId,
512
+ e.f_created_at as fCreatedAt, e.f_data as fData, se.f_surface_op as fSurfaceOp
513
+ from t_session_events se join t_events e on se.f_event_id = e.f_event_id
514
+ where se.f_session_id = ?${fromSequence === undefined ? "" : " and se.f_sequence >= ?"}
515
+ order by se.f_sequence`;
516
+ const statement = this.db.$client.prepare(sql);
517
+ const rows =
518
+ fromSequence === undefined ? statement.all(id) : statement.all(id, fromSequence);
519
+ return rows as unknown as EventRow[];
510
520
  }
511
521
 
512
522
  // 只取类型:rewind 的边界 / 窗口探测不该把整个事件 JSON(f_data)拖回来。
@@ -9,9 +9,22 @@ import type {
9
9
  } from "@deepseek-ai/dsh-session";
10
10
  import type {
11
11
  ProjectionCheckpoint,
12
+ ProjectionKeySelection,
12
13
  ProjectionSnapshot,
13
14
  SessionProjectionMap,
14
15
  } from "@deepseek-ai/dsh-session-projection";
16
+
17
+ // 上游的 keys 选择面既接受数组也接受 `ReadonlySet`(`sessionProjections.summaryKeys()` 给的是 Set)。
18
+ // 只看一个键时两种都要认——之前只按数组写了 `.includes`,Set 上直接抛 `keys.includes is not a function`,
19
+ // 列表行于是整列投影都没有(会话标题显示"未命名")。
20
+ function selects(
21
+ keys: ProjectionKeySelection | undefined,
22
+ key: Extract<keyof SessionProjectionMap, string>,
23
+ ): boolean {
24
+ if (keys === undefined) return true;
25
+ if ("has" in keys) return keys.has(key);
26
+ return (keys as readonly string[]).includes(key);
27
+ }
15
28
  import type {
16
29
  StorageRepository,
17
30
  StoredProjcacheEntry,
@@ -105,7 +118,7 @@ export class SessionProjectionCacheRdb extends Service {
105
118
  // inherited cut(那个只有写路径与 hydration 用得上)。
106
119
  cachedSnapshot(
107
120
  meta: SessionHeader,
108
- keys?: readonly Extract<keyof SessionProjectionMap, string>[],
121
+ keys?: ProjectionKeySelection,
109
122
  ): ProjectionSnapshot | undefined {
110
123
  const record = this.recordForLifecycle(meta);
111
124
  const snapshot = record === undefined ? undefined : this.viewRecord(record, keys);
@@ -121,12 +134,10 @@ export class SessionProjectionCacheRdb extends Service {
121
134
 
122
135
  private withDirectTitle(
123
136
  id: SessionId,
124
- keys: readonly Extract<keyof SessionProjectionMap, string>[] | undefined,
137
+ keys: ProjectionKeySelection | undefined,
125
138
  snapshot: ProjectionSnapshot | undefined,
126
139
  ): ProjectionSnapshot | undefined {
127
- if (keys !== undefined && !(keys as readonly string[]).includes(PREDECESSOR_TITLE_KEY)) {
128
- return snapshot;
129
- }
140
+ if (!selects(keys, PREDECESSOR_TITLE_KEY)) return snapshot;
130
141
  if (snapshot?.values[PREDECESSOR_TITLE_KEY] !== undefined) return snapshot;
131
142
  const direct = this.repository.readSessionTitleDirect?.(id);
132
143
  if (direct === undefined) return snapshot;
@@ -150,7 +161,7 @@ export class SessionProjectionCacheRdb extends Service {
150
161
 
151
162
  private viewRecord(
152
163
  record: StoredProjcacheEntry,
153
- keys?: readonly Extract<keyof SessionProjectionMap, string>[],
164
+ keys?: ProjectionKeySelection,
154
165
  ): ProjectionSnapshot | undefined {
155
166
  const values = this.ctx.sessionProjections.viewCheckpoint(
156
167
  record.rows as unknown as ProjectionCheckpoint,
@@ -232,6 +232,17 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
232
232
  });
233
233
  },
234
234
 
235
+ async deleteWorkspaces(ids: readonly string[]): Promise<void> {
236
+ if (ids.length === 0) return;
237
+ await host.writeAtomically(async () => {
238
+ const db = await dbx();
239
+ await runQuery(
240
+ db.delete(tWorkspaceSessions).where(inArray(tWorkspaceSessions.fWorkspaceId, [...ids])),
241
+ );
242
+ await runQuery(db.delete(tWorkspaces).where(inArray(tWorkspaces.fWorkspaceId, [...ids])));
243
+ });
244
+ },
245
+
235
246
  async readWorkspaceState(): Promise<WorkspaceDomainState | null> {
236
247
  const db = await dbx();
237
248
  const row = await oneRow<{
@@ -399,6 +410,14 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
399
410
  });
400
411
  },
401
412
 
413
+ async deleteScheduleTasks(ids: readonly string[]): Promise<void> {
414
+ if (ids.length === 0) return;
415
+ await host.writeAtomically(async () => {
416
+ const db = await dbx();
417
+ await runQuery(db.delete(tScheduleTasks).where(inArray(tScheduleTasks.fId, [...ids])));
418
+ });
419
+ },
420
+
402
421
  async loadProjcache(): Promise<StoredProjcacheEntry[]> {
403
422
  const db = await dbx();
404
423
  const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
@@ -177,6 +177,13 @@ class WorkspaceKvUnit extends TrackedKvUnit {
177
177
  await this.track(this.repository.deleteWorkspace(key));
178
178
  }
179
179
 
180
+ // 上游 `KvUnit.deleteRecords`:行存储一次批量做完(不让调用方逐条重试)。
181
+ async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
182
+ this.assertOpen();
183
+ this.assertTable(table);
184
+ await this.track(this.repository.deleteWorkspaces([...new Set(keys)]));
185
+ }
186
+
180
187
  async setGlobal(value: unknown): Promise<void> {
181
188
  this.assertOpen();
182
189
  await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
@@ -208,6 +215,12 @@ class ScheduleKvUnit extends TrackedKvUnit {
208
215
  await this.track(this.repository.deleteScheduleTask(key));
209
216
  }
210
217
 
218
+ async deleteRecords(table: string, keys: readonly string[]): Promise<void> {
219
+ this.assertOpen();
220
+ this.assertTable(table);
221
+ await this.track(this.repository.deleteScheduleTasks([...new Set(keys)]));
222
+ }
223
+
211
224
  setGlobal(_value: unknown): Promise<void> {
212
225
  throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
213
226
  }
@@ -33,6 +33,9 @@ export interface StorageRepository {
33
33
 
34
34
  deleteWorkspace(id: string): Promise<void>;
35
35
 
36
+ // 一次批量(上游 `KvUnit.deleteRecords` 的契约:行存储要么一次做完、要么不做)。
37
+ deleteWorkspaces(ids: readonly string[]): Promise<void>;
38
+
36
39
  readWorkspaceState(): Promise<WorkspaceDomainState | null>;
37
40
 
38
41
  writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
@@ -44,6 +47,8 @@ export interface StorageRepository {
44
47
 
45
48
  deleteScheduleTask(id: string): Promise<void>;
46
49
 
50
+ deleteScheduleTasks(ids: readonly string[]): Promise<void>;
51
+
47
52
  loadProjcache(): Promise<StoredProjcacheEntry[]>;
48
53
 
49
54
  // 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。