@morlay/session-rdb 0.1.5 → 0.1.7

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 (37) hide show
  1. package/README.md +4 -7
  2. package/dist/artifact.d.mts +2 -1
  3. package/dist/{src-BmogreEz.mjs → deletion-ODZFcbZm.mjs} +134 -90
  4. package/dist/deletion.d.mts +1 -1
  5. package/dist/deletion.mjs +1 -1
  6. package/dist/export.d.mts +1 -1
  7. package/dist/gc.d.mts +1 -1
  8. package/dist/import.d.mts +2 -2
  9. package/dist/import.mjs +2 -2
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.mjs +3 -3
  12. package/dist/log-C0q5svm7.d.mts +594 -0
  13. package/dist/{sqlite-YCr2-Fov.mjs → sqlite-DjOnMRh2.mjs} +73 -2
  14. package/dist/storage.d.mts +2 -2
  15. package/dist/storage.mjs +1 -2
  16. package/dist/testing.d.mts +2 -2
  17. package/dist/testing.mjs +95 -14669
  18. package/dist/usage.d.mts +1 -1
  19. package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +11 -0
  20. package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +14 -0
  21. package/package.json +62 -41
  22. package/src/branch.ts +1 -1
  23. package/src/entities/v3/index.ts +4 -0
  24. package/src/entities/v3/schedule-tasks.ts +16 -0
  25. package/src/index.ts +2 -1
  26. package/src/schema.ts +2 -0
  27. package/src/sqlite.ts +5 -2
  28. package/src/storage-takeover/repository.ts +61 -0
  29. package/src/storage-takeover/storage-backend.ts +120 -42
  30. package/src/storage-takeover/types.ts +9 -0
  31. package/dist/dist-DHer1cql.mjs +0 -1531
  32. package/dist/invariant.d.mts +0 -6
  33. package/dist/invariant.mjs +0 -8
  34. package/dist/log-DYyxRh7j.d.mts +0 -6078
  35. package/dist/rolldown-runtime-EAsCxrDo.mjs +0 -14
  36. package/src/invariant.ts +0 -13
  37. package/dist/{branch-aiXPmnKC.mjs → branch-DbFW3rJX.mjs} +1 -1
package/dist/usage.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { B as UsageTotals, F as UsageAggregate, G as emptyTotals, H as addActivityTotals, I as UsageBucket, It as SESSION_USAGE_PATH, J as resolveUsageSince, K as localDayKey, L as UsageRangeKey, M as COUNTED_EVENT_TYPES, N as SessionUsageReport, P as UsageActivityTotals, R as UsageSessionRow, U as addTokenTotals, V as addActivityCount, W as addTotals, q as registerSessionUsage, z as UsageTokenTotals } from "./log-DYyxRh7j.mjs";
1
+ import { B as UsageTotals, F as UsageAggregate, G as emptyTotals, H as addActivityTotals, I as UsageBucket, J as resolveUsageSince, K as localDayKey, L as UsageRangeKey, Lt as SESSION_USAGE_PATH, M as COUNTED_EVENT_TYPES, N as SessionUsageReport, P as UsageActivityTotals, R as UsageSessionRow, U as addTokenTotals, V as addActivityCount, W as addTotals, q as registerSessionUsage, z as UsageTokenTotals } from "./log-C0q5svm7.mjs";
2
2
  export { COUNTED_EVENT_TYPES, SESSION_USAGE_PATH, SessionUsageReport, UsageActivityTotals, UsageAggregate, UsageBucket, UsageRangeKey, UsageSessionRow, UsageTokenTotals, UsageTotals, addActivityCount, addActivityTotals, addTokenTotals, addTotals, emptyTotals, localDayKey, registerSessionUsage, resolveUsageSince };
@@ -0,0 +1,11 @@
1
+ -- 见 SQLite 侧同名迁移的说明:官方 `schedule` 域的 `tasks` 表落进本库。
2
+ CREATE TABLE "t_schedule_tasks" (
3
+ "f_id" text PRIMARY KEY,
4
+ "f_session_id" text NOT NULL,
5
+ "f_status" text NOT NULL,
6
+ "f_record" text NOT NULL,
7
+ "f_last_delivery" text,
8
+ "f_delivery_history" text
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE INDEX "idx_schedule_tasks_session_id" ON "t_schedule_tasks" USING btree ("f_session_id");
@@ -0,0 +1,14 @@
1
+ -- 官方 `schedule` 域(`dsh-schedule` 的 `tasks` 表)落进本库:web-app 在每份 profile 里挂那一行,
2
+ -- 而 `storage-domain` 的 backend 路由指向 rdb——一个提醒一行,归属会话与状态是列,记录与投递回执存 JSON。
3
+ -- 手写迁移(生成器产不出可用 diff,见 `.agents/debts/20260921-drizzle生成器与实体定义对不上.md`):
4
+ -- 本文件只含这次的真实改动,没有需要说明的略过段。
5
+ CREATE TABLE `t_schedule_tasks` (
6
+ `f_id` text PRIMARY KEY,
7
+ `f_session_id` text NOT NULL,
8
+ `f_status` text NOT NULL,
9
+ `f_record` text NOT NULL,
10
+ `f_last_delivery` text,
11
+ `f_delivery_history` text
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE INDEX `idx_schedule_tasks_session_id` ON `t_schedule_tasks` (`f_session_id`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morlay/session-rdb",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",
@@ -25,60 +25,81 @@
25
25
  ],
26
26
  "type": "module",
27
27
  "exports": {
28
- ".": "./dist/index.mjs",
29
- "./artifact": "./dist/artifact.mjs",
30
- "./deletion": "./dist/deletion.mjs",
31
- "./export": "./dist/export.mjs",
32
- "./gc": "./dist/gc.mjs",
33
- "./import": "./dist/import.mjs",
34
- "./storage": "./dist/storage.mjs",
35
- "./testing": "./dist/testing.mjs",
36
- "./usage": "./dist/usage.mjs",
37
- "./invariant": "./dist/invariant.mjs",
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "default": "./dist/index.mjs"
31
+ },
32
+ "./artifact": {
33
+ "types": "./dist/artifact.d.mts",
34
+ "default": "./dist/artifact.mjs"
35
+ },
36
+ "./deletion": {
37
+ "types": "./dist/deletion.d.mts",
38
+ "default": "./dist/deletion.mjs"
39
+ },
40
+ "./export": {
41
+ "types": "./dist/export.d.mts",
42
+ "default": "./dist/export.mjs"
43
+ },
44
+ "./gc": {
45
+ "types": "./dist/gc.d.mts",
46
+ "default": "./dist/gc.mjs"
47
+ },
48
+ "./import": {
49
+ "types": "./dist/import.d.mts",
50
+ "default": "./dist/import.mjs"
51
+ },
52
+ "./storage": {
53
+ "types": "./dist/storage.d.mts",
54
+ "default": "./dist/storage.mjs"
55
+ },
56
+ "./testing": {
57
+ "types": "./dist/testing.d.mts",
58
+ "default": "./dist/testing.mjs"
59
+ },
60
+ "./usage": {
61
+ "types": "./dist/usage.d.mts",
62
+ "default": "./dist/usage.mjs"
63
+ },
38
64
  "./package.json": "./package.json",
39
65
  "./locale/*.json": "./locale/*.json"
40
66
  },
41
67
  "dependencies": {
42
- "@morlay/session-branch": "0.1.5",
68
+ "@morlay/session-branch": "0.1.7",
43
69
  "drizzle-orm": "^1.0.0-rc.5-ab785fc",
44
70
  "fflate": "^0.8.2",
45
71
  "pg": "^8.23.0"
46
72
  },
47
73
  "devDependencies": {
48
- "@deepseek-ai/cosmokit": "1.8.5",
49
- "@deepseek-ai/dsh-compaction": "0.2.0-rc.2",
50
- "@deepseek-ai/dsh-session-projection": "0.2.0-rc.2",
51
- "@deepseek-ai/dsh-session-projection-cache": "0.2.0-rc.2",
52
- "@deepseek-ai/dsh-session-turn-outline": "0.2.0-rc.2",
53
- "@deepseek-ai/dsh-storage": "0.2.0-rc.2",
54
- "@deepseek-ai/dsh-storage-domain": "0.2.0-rc.2",
55
- "@deepseek-ai/dsh-token-meter": "0.2.0-rc.2",
56
- "@deepseek-ai/dsh-workspace": "0.2.0-rc.2",
74
+ "@deepseek-ai/cosmokit": "^1.8.6",
75
+ "@deepseek-ai/dsh-agent-loop-testkit": "^0.2.1-rc.1",
76
+ "@deepseek-ai/dsh-compaction": "^0.2.1-rc.1",
77
+ "@deepseek-ai/dsh-session-turn-outline": "^0.2.1-rc.1",
78
+ "@deepseek-ai/dsh-storage": "^0.2.1-rc.1",
79
+ "@deepseek-ai/dsh-storage-domain": "^0.2.1-rc.1",
80
+ "@deepseek-ai/dsh-token-meter": "^0.2.1-rc.1",
57
81
  "@types/pg": "^8.23.1",
58
82
  "drizzle-kit": "1.0.0-rc.5-ab785fc"
59
83
  },
60
84
  "peerDependencies": {
61
- "@deepseek-ai/cordis": "^4.0.4",
62
- "@deepseek-ai/cosmokit": "^1.8.5",
63
- "@deepseek-ai/dsh-invariants": "^0.2.0-rc.2",
64
- "@deepseek-ai/dsh-llm": "^0.2.0-rc.2",
65
- "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
66
- "@deepseek-ai/dsh-session": "^0.2.0-rc.2",
67
- "@deepseek-ai/dsh-session-format": "^0.2.0-rc.2",
68
- "@deepseek-ai/dsh-session-format-catalog": "^0.2.0-rc.2",
69
- "@deepseek-ai/dsh-session-persistence": "^0.2.0-rc.2",
70
- "@deepseek-ai/dsh-session-query": "^0.2.0-rc.2",
71
- "@deepseek-ai/dsh-storage": "^0.2.0-rc.2",
72
- "@deepseek-ai/schemastery": "^3.18.4"
85
+ "@deepseek-ai/cordis": "^4.0.5",
86
+ "@deepseek-ai/cosmokit": "^1.8.6",
87
+ "@deepseek-ai/dsh-llm": "^0.2.1-rc.1",
88
+ "@deepseek-ai/dsh-schedule": "^0.2.1-rc.1",
89
+ "@deepseek-ai/dsh-scope": "^0.2.1-rc.1",
90
+ "@deepseek-ai/dsh-session": "^0.2.1-rc.1",
91
+ "@deepseek-ai/dsh-session-format": "^0.2.1-rc.1",
92
+ "@deepseek-ai/dsh-session-format-catalog": "^0.2.1-rc.1",
93
+ "@deepseek-ai/dsh-session-persistence": "^0.2.1-rc.1",
94
+ "@deepseek-ai/dsh-session-projection": "^0.2.1-rc.1",
95
+ "@deepseek-ai/dsh-session-projection-cache": "^0.2.1-rc.1",
96
+ "@deepseek-ai/dsh-session-query": "^0.2.1-rc.1",
97
+ "@deepseek-ai/dsh-storage": "^0.2.1-rc.1",
98
+ "@deepseek-ai/dsh-workspace": "^0.2.1-rc.1",
99
+ "@deepseek-ai/schemastery": "^3.18.5"
73
100
  },
74
- "inlinedDependencies": {
75
- "@jridgewell/sourcemap-codec": "1.6.0",
76
- "chai": "6.2.2",
77
- "expect-type": "1.4.0",
78
- "magic-string": "1.4.1",
79
- "tinybench": "6.1.4",
80
- "vitest": "5.0.0",
81
- "zod": "4.6.5"
101
+ "engines": {
102
+ "dsh": ">= 0.2.1-rc.1 < 0.2.2"
82
103
  },
83
104
  "scripts": {
84
105
  "build": "pnpm exec tsdown",
package/src/branch.ts CHANGED
@@ -312,7 +312,7 @@ export class SessionBranchRdbProvider implements SessionBranchProvider {
312
312
  throw error;
313
313
  }
314
314
  // fork 不动统计:继承前缀的消耗是父会话真实发生的那一次,子会话只记自己新产生的
315
- // (写路径旁路累加,见 ADR-统计只记真实发生的用量)。
315
+ // (写路径旁路累加,见 ADR-fork不继承统计)。
316
316
  return childId;
317
317
  }
318
318
 
@@ -7,6 +7,7 @@ import { storageUnits } from "./storage-units.ts";
7
7
  import { workspaces } from "./workspaces.ts";
8
8
  import { workspaceSessions } from "./workspace-sessions.ts";
9
9
  import { workspaceState } from "./workspace-state.ts";
10
+ import { scheduleTasks } from "./schedule-tasks.ts";
10
11
  import { sessionProjcacheRows } from "./session-projcache-rows.ts";
11
12
  import { eventUsage } from "./event-usage.ts";
12
13
  import { sessionUsage } from "./session-usage.ts";
@@ -21,6 +22,7 @@ export { storageUnits };
21
22
  export { workspaces };
22
23
  export { workspaceSessions };
23
24
  export { workspaceState };
25
+ export { scheduleTasks };
24
26
  export { sessionProjcacheRows };
25
27
  export { eventUsage };
26
28
  export { sessionUsage };
@@ -36,6 +38,7 @@ export const sqliteTableDefs = [
36
38
  workspaces,
37
39
  workspaceSessions,
38
40
  workspaceState,
41
+ scheduleTasks,
39
42
  sessionProjcacheRows,
40
43
  eventUsage,
41
44
  sessionUsage,
@@ -52,6 +55,7 @@ export const postgresTableDefs = [
52
55
  workspaces,
53
56
  workspaceSessions,
54
57
  workspaceState,
58
+ scheduleTasks,
55
59
  sessionProjcacheRows,
56
60
  eventUsage,
57
61
  sessionUsage,
@@ -0,0 +1,16 @@
1
+ import type { TableDef } from "../../adapters/types.ts";
2
+
3
+ // 官方 `schedule` 域的 `tasks` 表:一个提醒一行(key = `ScheduleId`)。归属会话与状态是可查的列,
4
+ // 记录本体与投递回执按上游契约原样存 JSON(它们只有上游 `dsh-schedule` 自己消费,拆列没有第二个读法)。
5
+ export const scheduleTasks: TableDef = {
6
+ name: "t_schedule_tasks",
7
+ columns: {
8
+ f_id: { type: "text", primaryKey: true },
9
+ f_session_id: { type: "text", notNull: true },
10
+ f_status: { type: "text", notNull: true },
11
+ f_record: { type: "text", notNull: true },
12
+ f_last_delivery: { type: "text" },
13
+ f_delivery_history: { type: "text" },
14
+ },
15
+ indexes: { idx_schedule_tasks_session_id: { columns: ["f_session_id"] } },
16
+ };
package/src/index.ts CHANGED
@@ -1099,7 +1099,8 @@ export class SessionPersistenceRdb extends SessionPersistence {
1099
1099
  }
1100
1100
  }
1101
1101
 
1102
- // 重算一个会话的两张汇总表(fork 之后;best-effort,同 `recordSessionStats`)。
1102
+ // 重算一个会话的两张汇总表(旧日志重写后;best-effort,同 `recordSessionStats`)——
1103
+ // fork 与 rewind 都不动统计。
1103
1104
  async rebuildSessionStats(id: SessionId): Promise<void> {
1104
1105
  try {
1105
1106
  await this.backend.rebuildSessionStats(id);
package/src/schema.ts CHANGED
@@ -28,6 +28,8 @@ export const tWorkspaceSessions = sqliteTables["t_workspace_sessions"]!;
28
28
 
29
29
  export const tWorkspaceState = sqliteTables["t_workspace_state"]!;
30
30
 
31
+ export const tScheduleTasks = sqliteTables["t_schedule_tasks"]!;
32
+
31
33
  export const tSessionProjcacheRows = sqliteTables["t_session_projcache_row"]!;
32
34
 
33
35
  export const tEventUsage = sqliteTables["t_event_usage"]!;
package/src/sqlite.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  tEventUsage,
39
39
  tEvents,
40
40
  tPersistenceState,
41
+ tScheduleTasks,
41
42
  tSessionEvents,
42
43
  tSessionProjcacheRows,
43
44
  tSessions,
@@ -430,6 +431,7 @@ export class SqliteBackend implements Backend {
430
431
  t_workspaces: tWorkspaces,
431
432
  t_workspace_sessions: tWorkspaceSessions,
432
433
  t_workspace_state: tWorkspaceState,
434
+ t_schedule_tasks: tScheduleTasks,
433
435
  t_session_projcache_row: tSessionProjcacheRows,
434
436
  },
435
437
  });
@@ -824,7 +826,8 @@ export class SqliteBackend implements Backend {
824
826
  }
825
827
 
826
828
  // 用量聚合:只读三张派生统计表——事件级用量行给去重口径的总量与「天 × 模型」桶,`t_session_usage` /
827
- // `t_session_counts` 给按会话的行(含 fork 继承前缀);归属、本地日与「是否被引用」都是物化列,不再现算、也不
829
+ // `t_session_counts` 给按会话的行(**含 fork 继承前缀**,见 ADR-fork不继承统计);归属、本地日与「是否被引用」
830
+ // 都是物化列,不再现算、也不
828
831
  // 回连事件表。事件级按毫秒过滤、汇总表按本地日过滤,`sinceMs` 对齐本地零点时两者等价。
829
832
  async usageReport(sinceMs?: number): Promise<UsageAggregate> {
830
833
  const usageSince = sinceMs === undefined ? "" : " AND f_created_at >= ?";
@@ -999,7 +1002,7 @@ export class SqliteBackend implements Backend {
999
1002
  }
1000
1003
  }
1001
1004
 
1002
- // 按会话重算两张汇总表(rewind / fork 之后):先删该会话的行,再从事件表重算。
1005
+ // 按会话重算两张汇总表(旧日志重写后):先删该会话的行,再从事件表重算——rewind 与 fork 不走这里。
1003
1006
  async rebuildSessionStats(id: SessionId): Promise<void> {
1004
1007
  this.db.$client.prepare("DELETE FROM t_session_usage WHERE f_session_id = ?").run(id);
1005
1008
  this.db.$client
@@ -12,6 +12,7 @@ import type {
12
12
  CheckpointIdentity,
13
13
  ProjectionCheckpoint,
14
14
  ProjectionCheckpointRow,
15
+ ScheduleTask,
15
16
  StorageRepository,
16
17
  StoredProjcacheEntry,
17
18
  WorkspaceDomainState,
@@ -89,6 +90,39 @@ function pendingMutationOf(row: {
89
90
  return { operation, workspaceId: (row.fPendingWorkspaceId ?? "") as WorkspaceId };
90
91
  }
91
92
 
93
+ interface ScheduleTaskRowRecord {
94
+ fId: string;
95
+ fSessionId: string;
96
+ fStatus: string;
97
+ fRecord: string;
98
+ fLastDelivery: string | null;
99
+ fDeliveryHistory: string | null;
100
+ }
101
+
102
+ // 行的读写映射:形状校验在 KV 那一层做(`storage-backend.ts` 的 `scheduleTaskOf`),这里只按列拆装——
103
+ // 归属会话与状态是列,其余三段保持上游契约的 JSON 原样(读回时 `dsh-schedule` 的域 schema 还会再校验)。
104
+ function taskFromRow(row: ScheduleTaskRowRecord): ScheduleTask {
105
+ return {
106
+ sessionId: row.fSessionId as SessionId,
107
+ record: JSON.parse(row.fRecord),
108
+ status: row.fStatus === "inactive" ? "inactive" : "active",
109
+ ...(row.fLastDelivery === null ? {} : { lastDelivery: JSON.parse(row.fLastDelivery) }),
110
+ ...(row.fDeliveryHistory === null ? {} : { deliveryHistory: JSON.parse(row.fDeliveryHistory) }),
111
+ } as ScheduleTask;
112
+ }
113
+
114
+ function rowFromTask(id: string, task: ScheduleTask): ScheduleTaskRowRecord {
115
+ return {
116
+ fId: id,
117
+ fSessionId: task.sessionId,
118
+ fStatus: task.status === "inactive" ? "inactive" : "active",
119
+ fRecord: JSON.stringify(task.record),
120
+ fLastDelivery: task.lastDelivery === undefined ? null : JSON.stringify(task.lastDelivery),
121
+ fDeliveryHistory:
122
+ task.deliveryHistory === undefined ? null : JSON.stringify(task.deliveryHistory),
123
+ };
124
+ }
125
+
92
126
  export function createStorageRepository(host: StorageRepositoryHost): StorageRepository {
93
127
  const tables = host.tables as Record<string, any>;
94
128
  const tUnits = tables["t_storage_units"]!;
@@ -96,6 +130,7 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
96
130
  const tWorkspaces = tables["t_workspaces"]!;
97
131
  const tWorkspaceSessions = tables["t_workspace_sessions"]!;
98
132
  const tWorkspaceState = tables["t_workspace_state"]!;
133
+ const tScheduleTasks = tables["t_schedule_tasks"]!;
99
134
  const tProjcacheRows = tables["t_session_projcache_row"]!;
100
135
 
101
136
  const sessionIdentity = {
@@ -338,6 +373,32 @@ export function createStorageRepository(host: StorageRepositoryHost): StorageRep
338
373
  });
339
374
  },
340
375
 
376
+ async listScheduleTasks(): Promise<Array<{ id: string; task: ScheduleTask }>> {
377
+ const db = await dbx();
378
+ const rows = await allRows<ScheduleTaskRowRecord>(db.select().from(tScheduleTasks));
379
+ return rows.map((row) => ({ id: row.fId, task: taskFromRow(row) }));
380
+ },
381
+
382
+ async putScheduleTask(id: string, task: ScheduleTask): Promise<void> {
383
+ await host.writeAtomically(async () => {
384
+ const db = await dbx();
385
+ const values = rowFromTask(id, task);
386
+ await runQuery(
387
+ db
388
+ .insert(tScheduleTasks)
389
+ .values(values)
390
+ .onConflictDoUpdate({ target: tScheduleTasks.fId, set: values }),
391
+ );
392
+ });
393
+ },
394
+
395
+ async deleteScheduleTask(id: string): Promise<void> {
396
+ await host.writeAtomically(async () => {
397
+ const db = await dbx();
398
+ await runQuery(db.delete(tScheduleTasks).where(eq(tScheduleTasks.fId, id)));
399
+ });
400
+ },
401
+
341
402
  async loadProjcache(): Promise<StoredProjcacheEntry[]> {
342
403
  const db = await dbx();
343
404
  const rows = await allRows<ProjcacheRowRecord>(db.select().from(tProjcacheRows));
@@ -1,36 +1,35 @@
1
1
  import { StorageError } from "@deepseek-ai/dsh-storage";
2
2
  import type { SessionId } from "@deepseek-ai/dsh-session";
3
+ import type { ScheduleTask } from "@deepseek-ai/dsh-schedule";
3
4
  import type { WorkspaceId } from "@deepseek-ai/dsh-workspace";
4
5
  import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from "@deepseek-ai/dsh-storage";
5
6
  import type { StorageRepository, WorkspaceRecord, WorkspaceDomainState } from "./types.ts";
6
7
 
7
8
  export const RDB_STORAGE_BACKEND = "rdb";
8
9
 
10
+ // 本后端服务哪些域:一个域一个显式映射(表结构显式维护,未知域名 fail loud——见
11
+ // `.agents/adrs/20260917-接管storages到rdb语义表.md`)。官方那几行谁读哪个域是上游的事,
12
+ // 这里只回答"这个域的数据落在哪张表"。
9
13
  const WORKSPACE_UNIT = "workspace";
10
14
  const WORKSPACE_TABLE = "workspaces";
15
+ const SCHEDULE_UNIT = "schedule";
16
+ const SCHEDULE_TABLE = "tasks";
17
+
18
+ // 本后端对 unit 的内部约定:关闭时把域名让回后端(上游 `KvUnit` 没有这个入口,它归"谁能再开一次"这件事)。
19
+ interface ReleasableKvUnit extends KvUnit {
20
+ onClose(release: () => void): void;
21
+ }
11
22
 
12
23
  export class RdbStorageBackend implements StorageBackend {
13
24
  readonly kv: KvFacet = { open: (descriptor) => this.openUnit(descriptor) };
14
25
 
15
- private readonly open = new Map<string, WorkspaceKvUnit>();
26
+ private readonly open = new Map<string, ReleasableKvUnit>();
16
27
  private closed = false;
17
28
 
18
29
  constructor(private readonly repository: StorageRepository) {}
19
30
 
20
31
  private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
21
32
  if (this.closed) throw new StorageError("closed", "rdb storage backend is closed");
22
- if (descriptor.name !== WORKSPACE_UNIT) {
23
- throw new Error(
24
- `rdb storage backend serves only the '${WORKSPACE_UNIT}' domain (requested '${descriptor.name}')`,
25
- );
26
- }
27
- if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) {
28
- throw new Error(
29
- `rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape ` +
30
- `(table '${WORKSPACE_TABLE}' plus a global slot)`,
31
- );
32
- }
33
-
34
33
  if (descriptor.layout !== undefined && descriptor.layout !== "single") {
35
34
  throw new Error(
36
35
  `rdb storage backend serves only the 'single' layout (domain '${descriptor.name}' ` +
@@ -41,7 +40,7 @@ export class RdbStorageBackend implements StorageBackend {
41
40
  throw new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`);
42
41
  }
43
42
 
44
- const unit = new WorkspaceKvUnit(this.repository, descriptor);
43
+ const unit = this.unitFor(descriptor);
45
44
  this.open.set(descriptor.name, unit);
46
45
  try {
47
46
  const stored = await this.repository.readUnitVersion(descriptor.name);
@@ -64,6 +63,31 @@ export class RdbStorageBackend implements StorageBackend {
64
63
  return unit;
65
64
  }
66
65
 
66
+ private unitFor(descriptor: KvUnitDescriptor): ReleasableKvUnit {
67
+ if (descriptor.name === WORKSPACE_UNIT) {
68
+ if (!descriptor.tables.includes(WORKSPACE_TABLE) || descriptor.hasGlobal !== true) {
69
+ throw new Error(
70
+ `rdb storage backend expects the '${WORKSPACE_UNIT}' domain shape ` +
71
+ `(table '${WORKSPACE_TABLE}' plus a global slot)`,
72
+ );
73
+ }
74
+ return new WorkspaceKvUnit(this.repository, descriptor);
75
+ }
76
+ if (descriptor.name === SCHEDULE_UNIT) {
77
+ if (!descriptor.tables.includes(SCHEDULE_TABLE) || descriptor.hasGlobal !== false) {
78
+ throw new Error(
79
+ `rdb storage backend expects the '${SCHEDULE_UNIT}' domain shape ` +
80
+ `(table '${SCHEDULE_TABLE}' without a global slot)`,
81
+ );
82
+ }
83
+ return new ScheduleKvUnit(this.repository, descriptor);
84
+ }
85
+ throw new Error(
86
+ `rdb storage backend serves only the '${WORKSPACE_UNIT}' and '${SCHEDULE_UNIT}' domains ` +
87
+ `(requested '${descriptor.name}')`,
88
+ );
89
+ }
90
+
67
91
  async close(): Promise<void> {
68
92
  this.closed = true;
69
93
  const units = [...this.open.values()];
@@ -72,21 +96,65 @@ export class RdbStorageBackend implements StorageBackend {
72
96
  }
73
97
  }
74
98
 
75
- class WorkspaceKvUnit implements KvUnit {
99
+ // 一个 KV unit 的公共形态:关闭后拒写、关闭前把已接受的写排干、关闭回调把名字让回给后端。
100
+ // 域自己的读写在子类里(`unitFor` 的返回类型就地校验它们合得上 `KvUnit`)。
101
+ abstract class TrackedKvUnit {
76
102
  private closed = false;
77
103
 
78
104
  private readonly inflight = new Set<Promise<void>>();
105
+
79
106
  private onClosed: (() => void) | undefined;
80
107
 
81
108
  constructor(
82
- private readonly repository: StorageRepository,
83
- private readonly descriptor: KvUnitDescriptor,
109
+ protected readonly repository: StorageRepository,
110
+ protected readonly descriptor: KvUnitDescriptor,
84
111
  ) {}
85
112
 
113
+ // 这个 unit 认的表名(由各自的域决定)。
114
+ protected abstract readonly tables: readonly string[];
115
+
86
116
  onClose(release: () => void): void {
87
117
  this.onClosed = release;
88
118
  }
89
119
 
120
+ async close(): Promise<void> {
121
+ if (this.closed) return;
122
+ this.closed = true;
123
+ while (this.inflight.size > 0) {
124
+ await Promise.allSettled(this.inflight);
125
+ }
126
+ this.onClosed?.();
127
+ }
128
+
129
+ protected async track(operation: Promise<unknown>): Promise<void> {
130
+ const settled = operation.then(
131
+ () => undefined,
132
+ () => undefined,
133
+ );
134
+ this.inflight.add(settled);
135
+ try {
136
+ await operation;
137
+ } finally {
138
+ this.inflight.delete(settled);
139
+ }
140
+ }
141
+
142
+ protected assertOpen(): void {
143
+ if (this.closed) {
144
+ throw new StorageError("closed", `kv unit '${this.descriptor.name}' is closed`);
145
+ }
146
+ }
147
+
148
+ protected assertTable(table: string): void {
149
+ if (!this.tables.includes(table)) {
150
+ throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
151
+ }
152
+ }
153
+ }
154
+
155
+ class WorkspaceKvUnit extends TrackedKvUnit {
156
+ protected readonly tables = [WORKSPACE_TABLE];
157
+
90
158
  async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
91
159
  this.assertOpen();
92
160
  const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
@@ -113,39 +181,35 @@ class WorkspaceKvUnit implements KvUnit {
113
181
  this.assertOpen();
114
182
  await this.track(this.repository.writeWorkspaceState(workspaceStateOf(value)));
115
183
  }
184
+ }
116
185
 
117
- async close(): Promise<void> {
118
- if (this.closed) return;
119
- this.closed = true;
120
- while (this.inflight.size > 0) {
121
- await Promise.allSettled(this.inflight);
186
+ class ScheduleKvUnit extends TrackedKvUnit {
187
+ protected readonly tables = [SCHEDULE_TABLE];
188
+
189
+ async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
190
+ this.assertOpen();
191
+ const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
192
+ for (const { id, task } of await this.repository.listScheduleTasks()) {
193
+ records[id] = task;
122
194
  }
123
- this.onClosed?.();
195
+ // 这个域没有 global 槽(`scheduleDomain` 不声明):`null` = "从未写过",域层不会读它。
196
+ return { tables: { [SCHEDULE_TABLE]: records }, global: null };
124
197
  }
125
198
 
126
- private async track(operation: Promise<unknown>): Promise<void> {
127
- const settled = operation.then(
128
- () => undefined,
129
- () => undefined,
130
- );
131
- this.inflight.add(settled);
132
- try {
133
- await operation;
134
- } finally {
135
- this.inflight.delete(settled);
136
- }
199
+ async putRecord(table: string, key: string, value: unknown): Promise<void> {
200
+ this.assertOpen();
201
+ this.assertTable(table);
202
+ await this.track(this.repository.putScheduleTask(key, scheduleTaskOf(value)));
137
203
  }
138
204
 
139
- private assertOpen(): void {
140
- if (this.closed) {
141
- throw new StorageError("closed", `kv unit '${this.descriptor.name}' is closed`);
142
- }
205
+ async deleteRecord(table: string, key: string): Promise<void> {
206
+ this.assertOpen();
207
+ this.assertTable(table);
208
+ await this.track(this.repository.deleteScheduleTask(key));
143
209
  }
144
210
 
145
- private assertTable(table: string): void {
146
- if (table !== WORKSPACE_TABLE) {
147
- throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`);
148
- }
211
+ setGlobal(_value: unknown): Promise<void> {
212
+ throw new Error(`unit '${this.descriptor.name}' declares no global slot`);
149
213
  }
150
214
  }
151
215
 
@@ -191,3 +255,17 @@ function workspaceStateOf(value: unknown): WorkspaceDomainState {
191
255
  ...(state.pendingMutation === undefined ? {} : { pendingMutation: state.pendingMutation }),
192
256
  };
193
257
  }
258
+
259
+ function scheduleTaskOf(value: unknown): ScheduleTask {
260
+ const task = value as Partial<ScheduleTask> | null;
261
+ if (
262
+ typeof task !== "object" ||
263
+ task === null ||
264
+ typeof task.sessionId !== "string" ||
265
+ typeof task.record !== "object" ||
266
+ task.record === null
267
+ ) {
268
+ throw new TypeError("schedule task does not match the stored shape");
269
+ }
270
+ return task as ScheduleTask;
271
+ }
@@ -1,4 +1,5 @@
1
1
  import type { SessionId } from "@deepseek-ai/dsh-session";
2
+ import type { ScheduleTask } from "@deepseek-ai/dsh-schedule";
2
3
  import type { CheckpointIdentity } from "@deepseek-ai/dsh-session-projection-cache";
3
4
  import type {
4
5
  ProjectionCheckpoint,
@@ -10,6 +11,7 @@ export type {
10
11
  CheckpointIdentity,
11
12
  ProjectionCheckpoint,
12
13
  ProjectionCheckpointRow,
14
+ ScheduleTask,
13
15
  WorkspaceDomainState,
14
16
  WorkspaceRecord,
15
17
  };
@@ -35,6 +37,13 @@ export interface StorageRepository {
35
37
 
36
38
  writeWorkspaceState(state: WorkspaceDomainState): Promise<void>;
37
39
 
40
+ // 官方 `schedule` 域的 `tasks` 表:一个提醒一行(key 是 `ScheduleId`)。
41
+ listScheduleTasks(): Promise<Array<{ id: string; task: ScheduleTask }>>;
42
+
43
+ putScheduleTask(id: string, task: ScheduleTask): Promise<void>;
44
+
45
+ deleteScheduleTask(id: string): Promise<void>;
46
+
38
47
  loadProjcache(): Promise<StoredProjcacheEntry[]>;
39
48
 
40
49
  // 不经 await、直接读的路径(供同步的 `cachedSnapshot` 消费);宿主提供不了则缺省。