@morlay/session-rdb 0.1.10-alpha.0 → 0.1.10-alpha.3
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/README.md +18 -3
- package/dist/artifact.d.mts +118 -2
- package/dist/artifact.mjs +5 -3
- package/dist/{log-TYgd8Xkr.d.mts → backend-BKtJ5sO5.d.mts} +183 -284
- package/dist/backend-rdb-BDCntVDy.mjs +1341 -0
- package/dist/branch-CG1PUlO6.mjs +833 -0
- package/dist/deletion-SSX7IqU8.mjs +35 -0
- package/dist/deletion.d.mts +1 -1
- package/dist/deletion.mjs +2 -2
- package/dist/export-CfJQjQfr.mjs +123 -0
- package/dist/export.d.mts +1 -1
- package/dist/export.mjs +31 -55
- package/dist/import.d.mts +1 -1
- package/dist/import.mjs +31 -76
- package/dist/index-CUD2ZpTA.d.mts +71 -0
- package/dist/index.d.mts +20 -2
- package/dist/index.mjs +1561 -4
- package/dist/open-YoyD_kah.mjs +561 -0
- package/dist/organize.d.mts +9 -0
- package/dist/organize.mjs +30 -0
- package/dist/route-DpfY0zq-.mjs +114 -0
- package/dist/storage.d.mts +41 -48
- package/dist/storage.mjs +3 -1
- package/dist/testing.d.mts +9 -1
- package/dist/testing.mjs +25 -4
- package/dist/usage-row-BQQM-_t6.mjs +36 -0
- package/dist/usage.d.mts +1 -1
- package/dist/usage.mjs +7 -40
- package/dist/write-guard-CYrY58GM.mjs +174 -0
- package/package.json +32 -36
- package/src/artifact.ts +4 -1
- package/src/backend-rdb.ts +331 -0
- package/src/backend.ts +32 -15
- package/src/{branch.ts → branch/index.ts} +109 -110
- package/src/branch/port.ts +62 -0
- package/src/branch/semantics.ts +69 -0
- package/src/db/open.ts +180 -0
- package/src/db/stats.ts +449 -0
- package/src/deletion-error.ts +17 -0
- package/src/deletion.ts +19 -72
- package/src/entities/index.ts +16 -1
- package/src/entities/v3/event-usage.ts +3 -3
- package/src/entities/v3/events.ts +3 -3
- package/src/entities/v3/index.ts +4 -16
- package/src/entities/v3/persistence-state.ts +3 -3
- package/src/entities/v3/schema-meta.ts +3 -3
- package/src/entities/v3/session-counts.ts +3 -3
- package/src/entities/v3/session-events.ts +3 -3
- package/src/entities/v3/session-projcache-rows.ts +3 -3
- package/src/entities/v3/session-usage.ts +3 -3
- package/src/entities/v3/sessions.ts +3 -3
- package/src/entities/v3/storage-records.ts +17 -0
- package/src/entities/v3/storage-units.ts +3 -3
- package/src/{schema.ts → event-shape.ts} +0 -46
- package/src/export.ts +32 -78
- package/src/handles.ts +349 -0
- package/src/http/route.ts +178 -0
- package/src/import-storages.ts +5 -2
- package/src/import.ts +63 -125
- package/src/index.ts +15 -1666
- package/src/legacy.ts +3 -1
- package/src/live-routing.ts +205 -0
- package/src/log/export.ts +84 -0
- package/src/{log.ts → log/repair.ts} +1 -268
- package/src/log/rows.ts +107 -0
- package/src/log/usage-row.ts +64 -0
- package/src/migrations.ts +196 -0
- package/src/organize.ts +38 -0
- package/src/read-path.ts +190 -0
- package/src/routes.ts +2 -1
- package/src/rows.ts +25 -77
- package/src/session-ops.ts +444 -0
- package/src/session-store.ts +675 -0
- package/src/stats-fold.ts +44 -0
- package/src/storage-takeover/index.ts +2 -5
- package/src/storage-takeover/legacy-domain-migration.ts +118 -0
- package/src/storage-takeover/projection-cache.ts +122 -71
- package/src/storage-takeover/read-model.ts +189 -0
- package/src/storage-takeover/repository.ts +236 -442
- package/src/storage-takeover/storage-backend.ts +44 -159
- package/src/storage-takeover/types.ts +45 -19
- package/src/storage-takeover/workspace-documents.ts +65 -0
- package/src/storage.ts +4 -2
- package/src/testing.ts +21 -1
- package/src/usage.ts +11 -65
- package/src/version.ts +13 -0
- package/src/write-path.ts +286 -0
- package/dist/branch-CoPCRKer.mjs +0 -401
- package/dist/deletion-CWl89TIR.mjs +0 -2249
- package/dist/gc.d.mts +0 -4
- package/dist/gc.mjs +0 -48
- package/dist/log-CcWy46VE.mjs +0 -558
- package/dist/routes-hZjTcX_R.mjs +0 -9
- package/dist/sqlite-BNRd1aqR.mjs +0 -1778
- package/drizzle/postgres/20260908072805_v2_initial/migration.sql +0 -58
- package/drizzle/postgres/20260908072805_v2_initial/snapshot.json +0 -686
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/migration.sql +0 -1
- package/drizzle/postgres/20260908072806_v3_drop_original_seq/snapshot.json +0 -673
- package/drizzle/postgres/20260910120001_v3_storage_tables/migration.sql +0 -65
- package/drizzle/postgres/20260910120001_v3_storage_tables/snapshot.json +0 -1183
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/migration.sql +0 -10
- package/drizzle/postgres/20260910130001_v3_session_title_backfill/snapshot.json +0 -1183
- package/drizzle/postgres/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/postgres/20260918120000_v3_event_usage/snapshot.json +0 -1309
- package/drizzle/postgres/20260921075900_v3_agent_preset/migration.sql +0 -1
- package/drizzle/postgres/20260922090001_v3_pinned_seq/migration.sql +0 -2
- package/drizzle/postgres/20260922093001_v3_last_event_at/migration.sql +0 -10
- package/drizzle/postgres/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/postgres/20261008103001_v3_schedule_tasks/migration.sql +0 -11
- package/drizzle/sqlite/20260908072751_v2_initial/migration.sql +0 -53
- package/drizzle/sqlite/20260908072751_v2_initial/snapshot.json +0 -492
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/migration.sql +0 -6
- package/drizzle/sqlite/20260908072752_v3_drop_original_seq/snapshot.json +0 -513
- package/drizzle/sqlite/20260910120000_v3_storage_tables/migration.sql +0 -66
- package/drizzle/sqlite/20260910120000_v3_storage_tables/snapshot.json +0 -905
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/migration.sql +0 -13
- package/drizzle/sqlite/20260910130000_v3_session_title_backfill/snapshot.json +0 -905
- package/drizzle/sqlite/20260918120000_v3_event_usage/migration.sql +0 -14
- package/drizzle/sqlite/20260918120000_v3_event_usage/snapshot.json +0 -1031
- package/drizzle/sqlite/20260921075855_v3_agent_preset/migration.sql +0 -7
- package/drizzle/sqlite/20260921075855_v3_agent_preset/snapshot.json +0 -1041
- package/drizzle/sqlite/20260922090000_v3_pinned_seq/migration.sql +0 -4
- package/drizzle/sqlite/20260922093000_v3_last_event_at/migration.sql +0 -13
- package/drizzle/sqlite/20260923120000_v3_usage_materialized/migration.sql +0 -48
- package/drizzle/sqlite/20261008103000_v3_schedule_tasks/migration.sql +0 -14
- package/src/adapters/index.ts +0 -12
- package/src/adapters/to-postgres.ts +0 -93
- package/src/adapters/to-sqlite.ts +0 -83
- package/src/adapters/types.ts +0 -44
- package/src/drizzle/postgres-v2.ts +0 -10
- package/src/drizzle/postgres-v3.ts +0 -18
- package/src/drizzle/sqlite-v2.ts +0 -9
- package/src/drizzle/sqlite-v3.ts +0 -18
- package/src/entities/v2/index.ts +0 -20
- package/src/entities/v2/session-events.ts +0 -10
- package/src/entities/v3/schedule-tasks.ts +0 -16
- package/src/entities/v3/workspace-sessions.ts +0 -19
- package/src/entities/v3/workspace-state.ts +0 -12
- package/src/entities/v3/workspaces.ts +0 -14
- package/src/gc.ts +0 -77
- package/src/postgres.ts +0 -1015
- package/src/sqlite.ts +0 -1096
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { Volatile } from "@deepseek-ai/cosmokit";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import {
|
|
5
|
+
SessionAlreadyExistsError,
|
|
6
|
+
SessionPersistence,
|
|
7
|
+
SessionPersistenceNotFoundError,
|
|
8
|
+
SessionPersistenceRevision,
|
|
9
|
+
materializeCreateHeader,
|
|
10
|
+
validateStoredEvents,
|
|
11
|
+
type SessionAccess,
|
|
12
|
+
type SessionHandle,
|
|
13
|
+
type SessionPersistenceCreateOptions,
|
|
14
|
+
type SessionPersistenceListOptions,
|
|
15
|
+
type SessionPersistenceOpenOptions,
|
|
16
|
+
type SessionPersistenceSnapshot,
|
|
17
|
+
type SessionPersistenceStatOptions,
|
|
18
|
+
} from "@deepseek-ai/dsh-session-persistence";
|
|
19
|
+
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
20
|
+
import type { SessionEvent, SessionHeader, SessionId } from "@deepseek-ai/dsh-session";
|
|
21
|
+
import { type Backend } from "./backend.ts";
|
|
22
|
+
import { SessionDeletionError } from "./deletion-error.ts";
|
|
23
|
+
import { WriteGuard } from "./write-guard.ts";
|
|
24
|
+
import { RdbBackendTracker, RdbSessionHandle } from "./handles.ts";
|
|
25
|
+
|
|
26
|
+
import { repairReadView } from "./log/repair.ts";
|
|
27
|
+
import { rowToMeta } from "./log/rows.ts";
|
|
28
|
+
import { DEFAULT_BUSY_TIMEOUT_MS, type JournalMode } from "./version.ts";
|
|
29
|
+
import { openRdbBackend, type BackendConfig } from "./db/open.ts";
|
|
30
|
+
import { SessionBranchRdb } from "./branch/index.ts";
|
|
31
|
+
import type { BranchPort } from "./branch/port.ts";
|
|
32
|
+
import { LiveRouting } from "./live-routing.ts";
|
|
33
|
+
import { createReadPath } from "./read-path.ts";
|
|
34
|
+
import { createWritePath } from "./write-path.ts";
|
|
35
|
+
import { registerSessionImport } from "./import.ts";
|
|
36
|
+
import { registerSessionDeletion } from "./deletion.ts";
|
|
37
|
+
import { registerSessionExport } from "./export.ts";
|
|
38
|
+
import { registerSessionOrganize } from "./organize.ts";
|
|
39
|
+
import { registerSessionRows } from "./rows.ts";
|
|
40
|
+
import { registerSessionUsage } from "./usage.ts";
|
|
41
|
+
import type { UsageAggregate } from "./usage.ts";
|
|
42
|
+
import { SessionQueryRdb } from "./session-query.ts";
|
|
43
|
+
import { installStorageTakeover } from "./storage-takeover/index.ts";
|
|
44
|
+
|
|
45
|
+
export type Config =
|
|
46
|
+
| {
|
|
47
|
+
type: "sqlite";
|
|
48
|
+
path: string;
|
|
49
|
+
journalMode?: JournalMode | undefined;
|
|
50
|
+
busyTimeout?: number | undefined;
|
|
51
|
+
preparedSessionCacheSize?: number | undefined;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
type: "postgres";
|
|
55
|
+
connectionString: string;
|
|
56
|
+
schema?: string | undefined;
|
|
57
|
+
preparedSessionCacheSize?: number | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// schema 解析之后的形状:整段 Config 是 **volatile 稳定引用**(页面可编辑),读它要过 `.get()`。
|
|
61
|
+
export type ResolvedConfig = Volatile<Config>;
|
|
62
|
+
|
|
63
|
+
export { SessionDeletionError, type SessionDeletionErrorCode } from "./deletion-error.ts";
|
|
64
|
+
|
|
65
|
+
// 本地化说明:`description()` 的类型签名只声明 `string`,而 meta 本身接受 `Dict<string>`
|
|
66
|
+
// (`vendor/schemastery/src/index.ts` 的 `mergeDesc` 就是按字典合并的),所以这里只做一次类型放行。
|
|
67
|
+
const localized = (text: { zh: string; en: string }): string => text as unknown as string;
|
|
68
|
+
|
|
69
|
+
// 投影 checkpoint 的写回节流:两个分支共用。
|
|
70
|
+
// 上游 session-query 的**冷会话观察缓存**容量(`preparedSessionCacheSize`):缓存按**条目数**持有完整
|
|
71
|
+
// `Session`(含整份 append-only log),被活跃观察租约钉住的条目不淘汰;一次读取中途转成 live 时会重试 live
|
|
72
|
+
// 路径,但**不会顺手清掉已缓存的 cold 条目**——所以同一个 id 可能同时有两份 log 驻留。上游默认 5;本部署把它
|
|
73
|
+
// 收到最小的 1:本地单用户、rdb 后端,冷读复用的收益不值这份内存放大。上游校验是 >= 1(不能设 0)。
|
|
74
|
+
const preparedSessionCacheSchema = z
|
|
75
|
+
.number()
|
|
76
|
+
.step(1)
|
|
77
|
+
.min(1)
|
|
78
|
+
.default(1)
|
|
79
|
+
.description(
|
|
80
|
+
localized({
|
|
81
|
+
zh: "冷会话观察缓存容量(条数,最小 1):每个条目持一份完整会话日志;本部署默认 1,把同 id 两份日志的驻留压到最小。",
|
|
82
|
+
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.",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export class SessionPersistenceRdb extends SessionPersistence {
|
|
87
|
+
static inject = ["sessions"];
|
|
88
|
+
|
|
89
|
+
// 显式声明「输入 → 解析后」的形状:整段是 volatile,读它要过 `.get()`。union + volatile 的推断形状与手写类型
|
|
90
|
+
// 在可选性 / 索引签名上不完全对齐,所以这里断言一次,类型面由此固定。
|
|
91
|
+
static Config = z
|
|
92
|
+
.union([
|
|
93
|
+
z.object({
|
|
94
|
+
type: z.const("sqlite"),
|
|
95
|
+
path: z
|
|
96
|
+
.string()
|
|
97
|
+
.required()
|
|
98
|
+
.description(
|
|
99
|
+
localized({
|
|
100
|
+
zh: "SQLite 数据库文件路径;`:memory:` 是内存库(进程结束即丢)。",
|
|
101
|
+
en: "SQLite database file path; `:memory:` keeps it in memory and loses it with the process.",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
journalMode: z
|
|
105
|
+
.union(["wal", "delete", "truncate", "persist"] as const)
|
|
106
|
+
.default("wal")
|
|
107
|
+
.description(
|
|
108
|
+
localized({
|
|
109
|
+
zh: "SQLite 日志模式;默认 wal(并发读最好)。",
|
|
110
|
+
en: "SQLite journal mode; `wal` by default (best concurrent reads).",
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
busyTimeout: z
|
|
114
|
+
.number()
|
|
115
|
+
.step(1)
|
|
116
|
+
.min(0)
|
|
117
|
+
.default(DEFAULT_BUSY_TIMEOUT_MS)
|
|
118
|
+
.description(
|
|
119
|
+
localized({
|
|
120
|
+
zh: "写锁冲突时的忙等超时(毫秒)。",
|
|
121
|
+
en: "Busy timeout in ms while a write lock is contended.",
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
preparedSessionCacheSize: preparedSessionCacheSchema,
|
|
125
|
+
}),
|
|
126
|
+
z.object({
|
|
127
|
+
type: z.const("postgres"),
|
|
128
|
+
connectionString: z
|
|
129
|
+
.string()
|
|
130
|
+
.required()
|
|
131
|
+
.description(
|
|
132
|
+
localized({
|
|
133
|
+
zh: "PostgreSQL 连接串(含库名与凭证)。",
|
|
134
|
+
en: "PostgreSQL connection string (database and credentials).",
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
schema: z
|
|
138
|
+
.string()
|
|
139
|
+
.default("public")
|
|
140
|
+
.description(
|
|
141
|
+
localized({
|
|
142
|
+
zh: "库内的 schema 名;默认 public。",
|
|
143
|
+
en: "Schema name inside the database; `public` by default.",
|
|
144
|
+
}),
|
|
145
|
+
),
|
|
146
|
+
preparedSessionCacheSize: preparedSessionCacheSchema,
|
|
147
|
+
}),
|
|
148
|
+
])
|
|
149
|
+
.description(
|
|
150
|
+
localized({
|
|
151
|
+
zh: "会话持久化后端:改这里的连接参数会在这一行重挂时生效(Loader 会重装被改的行)。",
|
|
152
|
+
en: "Session persistence backend: edits apply when this row is remounted (the Loader re-installs changed rows).",
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
.volatile() as unknown as z<Config, ResolvedConfig>;
|
|
156
|
+
|
|
157
|
+
override readonly name = "session-rdb";
|
|
158
|
+
|
|
159
|
+
readonly tracker = new RdbBackendTracker(this.name);
|
|
160
|
+
|
|
161
|
+
/** 打开后的后端;未打开时访问会 fail loud(装配内部、分支端口与测试共用)。 */
|
|
162
|
+
backend!: Backend;
|
|
163
|
+
private storeIdentity!: string;
|
|
164
|
+
/** 后端打开完成(分支端口与 live 路由都先等它)。 */
|
|
165
|
+
readonly ready: Promise<void>;
|
|
166
|
+
/** 写者位点守卫(分支的 rewind 与写路径共用)。 */
|
|
167
|
+
readonly writeGuard = new WriteGuard();
|
|
168
|
+
private readonly live: LiveRouting;
|
|
169
|
+
private readonly readPath: ReturnType<typeof createReadPath>;
|
|
170
|
+
private readonly writePath: ReturnType<typeof createWritePath>;
|
|
171
|
+
|
|
172
|
+
private readonly reuseEventIds = new Map<SessionId, Map<number, string>>();
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
// 本次装配用的配置快照(整段 Config 是 volatile,重挂会给新的)。
|
|
176
|
+
readonly config: Config;
|
|
177
|
+
|
|
178
|
+
constructor(
|
|
179
|
+
ctx: Context,
|
|
180
|
+
config: ResolvedConfig,
|
|
181
|
+
) {
|
|
182
|
+
super(ctx);
|
|
183
|
+
|
|
184
|
+
// 配置就是这一行的 config(cordis.patch.yml / profile patch,或设置页改它);整段 Config 标了 volatile,
|
|
185
|
+
// 生效靠 **Loader 重挂这一行**(settings 写完经 `reconcileProfilePatches` 重装受影响的 entry)——重挂构造
|
|
186
|
+
// 新实例、拿到新快照,所以这里取一次就够,运行期不必反复读引用。
|
|
187
|
+
this.config = config.get();
|
|
188
|
+
this.ready = this.init();
|
|
189
|
+
this.readPath = createReadPath({
|
|
190
|
+
ready: this.ready,
|
|
191
|
+
backend: () => this.backend,
|
|
192
|
+
tracker: this.tracker,
|
|
193
|
+
logger: ctx.logger,
|
|
194
|
+
revisionOf: (row) => this.rowRevision(row),
|
|
195
|
+
});
|
|
196
|
+
this.writePath = createWritePath({
|
|
197
|
+
ready: this.ready,
|
|
198
|
+
backend: () => this.backend,
|
|
199
|
+
tracker: this.tracker,
|
|
200
|
+
writeGuard: this.writeGuard,
|
|
201
|
+
reuseEventIds: this.reuseEventIds,
|
|
202
|
+
logger: ctx.logger,
|
|
203
|
+
readLog: async (id) => {
|
|
204
|
+
const log = await this.readPath.readLog(id, {});
|
|
205
|
+
return log === undefined
|
|
206
|
+
? undefined
|
|
207
|
+
: { meta: log.meta, inheritedEventCount: log.inheritedEventCount, events: log.events };
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
this.live = new LiveRouting(ctx, this);
|
|
211
|
+
this.live.install();
|
|
212
|
+
|
|
213
|
+
new SessionBranchRdb(this.ctx);
|
|
214
|
+
|
|
215
|
+
// 冷会话观察缓存的容量在这一行配置里(默认见 `preparedSessionCacheSchema`);session-query 自己不会再读它。
|
|
216
|
+
const queryConfig =
|
|
217
|
+
this.config.preparedSessionCacheSize === undefined
|
|
218
|
+
? {}
|
|
219
|
+
: { preparedSessionCacheSize: this.config.preparedSessionCacheSize };
|
|
220
|
+
this.ctx.plugin(SessionQueryRdb, queryConfig);
|
|
221
|
+
|
|
222
|
+
registerSessionImport(this.ctx, this);
|
|
223
|
+
|
|
224
|
+
registerSessionDeletion(this.ctx, this);
|
|
225
|
+
|
|
226
|
+
registerSessionExport(this.ctx, this);
|
|
227
|
+
|
|
228
|
+
registerSessionOrganize(this.ctx, this);
|
|
229
|
+
|
|
230
|
+
registerSessionUsage(this.ctx, this);
|
|
231
|
+
|
|
232
|
+
// 路由与 storage 接管都在构造期装配,但 backend 是异步打开的:拿到的是「打开后才有值」的读取口。
|
|
233
|
+
registerSessionRows(this.ctx, () => this.database());
|
|
234
|
+
|
|
235
|
+
installStorageTakeover(this.ctx, {
|
|
236
|
+
repository: () => this.database().then((backend) => backend.storage),
|
|
237
|
+
ready: this.ready,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 打开完成后的 backend(所有 DB 操作的门)。 */
|
|
242
|
+
private async database(): Promise<Backend> {
|
|
243
|
+
await this.ready;
|
|
244
|
+
return this.backend;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async init(): Promise<void> {
|
|
248
|
+
this.backend = await openRdbBackend(backendConfigOf(this.config));
|
|
249
|
+
await this.backend.open();
|
|
250
|
+
this.storeIdentity = this.backend.storeIdentity;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async create(
|
|
254
|
+
header: SessionHeader,
|
|
255
|
+
options?: SessionPersistenceCreateOptions,
|
|
256
|
+
): Promise<SessionHandle> {
|
|
257
|
+
options?.signal?.throwIfAborted();
|
|
258
|
+
const snapshot = materializeCreateHeader(header);
|
|
259
|
+
if (snapshot.isSeeded && options?.inheritedEventCount === undefined) {
|
|
260
|
+
throw new TypeError("seeded session metadata requires an inherited event count");
|
|
261
|
+
}
|
|
262
|
+
const inheritedEventCount = SessionLogOffset(options?.inheritedEventCount ?? 0);
|
|
263
|
+
if (!snapshot.isSeeded && inheritedEventCount !== 0) {
|
|
264
|
+
throw new TypeError("unseeded session metadata inherited event count must be 0");
|
|
265
|
+
}
|
|
266
|
+
await this.ready;
|
|
267
|
+
options?.signal?.throwIfAborted();
|
|
268
|
+
if (
|
|
269
|
+
this.tracker.hasPending(snapshot.id) ||
|
|
270
|
+
(await this.backend.reads.getSession(snapshot.id)) !== undefined
|
|
271
|
+
) {
|
|
272
|
+
throw new SessionAlreadyExistsError(snapshot.id);
|
|
273
|
+
}
|
|
274
|
+
this.tracker.registerCreated(snapshot, inheritedEventCount);
|
|
275
|
+
return this.tracker.adopt(
|
|
276
|
+
new RdbSessionHandle(this, snapshot.id, snapshot, "write", {
|
|
277
|
+
cursor: 0,
|
|
278
|
+
materialized: false,
|
|
279
|
+
inheritedEventCount,
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async open(
|
|
285
|
+
id: SessionId,
|
|
286
|
+
access: SessionAccess,
|
|
287
|
+
options?: SessionPersistenceOpenOptions,
|
|
288
|
+
): Promise<SessionHandle> {
|
|
289
|
+
options?.signal?.throwIfAborted();
|
|
290
|
+
await this.ready;
|
|
291
|
+
options?.signal?.throwIfAborted();
|
|
292
|
+
const pending = this.tracker.pendingOf(id);
|
|
293
|
+
if (access === "read") {
|
|
294
|
+
if (pending !== undefined) {
|
|
295
|
+
return this.tracker.adopt(
|
|
296
|
+
new RdbSessionHandle(this, id, pending.header, "read", {
|
|
297
|
+
cursor: 0,
|
|
298
|
+
materialized: false,
|
|
299
|
+
inheritedEventCount: pending.inheritedEventCount,
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const log = await this.readPath.readLog(id, {}, options?.signal);
|
|
304
|
+
if (log === undefined) throw new SessionPersistenceNotFoundError(id);
|
|
305
|
+
|
|
306
|
+
repairReadView(log.events);
|
|
307
|
+
|
|
308
|
+
validateStoredEvents(log.meta, log.events);
|
|
309
|
+
return this.tracker.adopt(
|
|
310
|
+
new RdbSessionHandle(this, id, log.meta, "read", {
|
|
311
|
+
cursor: log.events.length,
|
|
312
|
+
materialized: true,
|
|
313
|
+
inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
this.tracker.claimWrite(id);
|
|
318
|
+
try {
|
|
319
|
+
if (pending !== undefined) {
|
|
320
|
+
return this.tracker.adopt(
|
|
321
|
+
new RdbSessionHandle(this, id, pending.header, "write", {
|
|
322
|
+
cursor: pending.cursor,
|
|
323
|
+
materialized: false,
|
|
324
|
+
inheritedEventCount: pending.inheritedEventCount,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const log = await this.readPath.readLog(id, {}, options?.signal);
|
|
329
|
+
if (log === undefined) throw new SessionPersistenceNotFoundError(id);
|
|
330
|
+
repairReadView(log.events);
|
|
331
|
+
validateStoredEvents(log.meta, log.events);
|
|
332
|
+
|
|
333
|
+
if (log.migrated && log.events.length !== log.storedCount) {
|
|
334
|
+
await this.writePath.rewriteMigratedLog(id, log);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
|
|
338
|
+
return this.tracker.adopt(
|
|
339
|
+
new RdbSessionHandle(this, id, log.meta, "write", {
|
|
340
|
+
cursor: log.events.length,
|
|
341
|
+
materialized: true,
|
|
342
|
+
inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
|
|
343
|
+
...(log.tornFrom !== undefined ? { tornTruncateTo: log.tornFrom } : {}),
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
346
|
+
} catch (error: unknown) {
|
|
347
|
+
this.tracker.releaseClaim(id);
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
flush(): Promise<void> {
|
|
353
|
+
return this.tracker.flushAll();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async stat(
|
|
357
|
+
id: SessionId,
|
|
358
|
+
options?: SessionPersistenceStatOptions,
|
|
359
|
+
): Promise<SessionPersistenceSnapshot | undefined> {
|
|
360
|
+
options?.signal?.throwIfAborted();
|
|
361
|
+
await this.ready;
|
|
362
|
+
options?.signal?.throwIfAborted();
|
|
363
|
+
const pending = this.tracker.pendingOf(id);
|
|
364
|
+
if (pending !== undefined) {
|
|
365
|
+
return { header: pending.header, revision: pending.revision };
|
|
366
|
+
}
|
|
367
|
+
const row = await this.backend.reads.getSession(id);
|
|
368
|
+
if (row === undefined) return undefined;
|
|
369
|
+
return {
|
|
370
|
+
header: rowToMeta(row),
|
|
371
|
+
revision: this.rowRevision(row),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async list(
|
|
376
|
+
options?: SessionPersistenceListOptions,
|
|
377
|
+
): Promise<readonly SessionPersistenceSnapshot[]> {
|
|
378
|
+
const signal = options?.signal;
|
|
379
|
+
const snapshots: SessionPersistenceSnapshot[] = [];
|
|
380
|
+
const listed = new Set<SessionId>();
|
|
381
|
+
for (const [id, pending] of this.tracker.pendingEntries()) {
|
|
382
|
+
snapshots.push({ header: pending.header, revision: pending.revision });
|
|
383
|
+
listed.add(id);
|
|
384
|
+
}
|
|
385
|
+
signal?.throwIfAborted();
|
|
386
|
+
await this.ready;
|
|
387
|
+
signal?.throwIfAborted();
|
|
388
|
+
const rows = await this.backend.reads.listSessions();
|
|
389
|
+
signal?.throwIfAborted();
|
|
390
|
+
for (const row of rows) {
|
|
391
|
+
if (listed.has(row.fSessionId as SessionId)) continue;
|
|
392
|
+
snapshots.push({ header: rowToMeta(row), revision: this.rowRevision(row) });
|
|
393
|
+
}
|
|
394
|
+
return snapshots;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async deleteSession(id: SessionId, signal?: AbortSignal): Promise<void> {
|
|
398
|
+
signal?.throwIfAborted();
|
|
399
|
+
await this.ready;
|
|
400
|
+
signal?.throwIfAborted();
|
|
401
|
+
const row = await this.backend.reads.getSession(id);
|
|
402
|
+
if (row === undefined) {
|
|
403
|
+
throw new SessionDeletionError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
404
|
+
}
|
|
405
|
+
if (row.fArchivedAt === null) {
|
|
406
|
+
throw new SessionDeletionError(
|
|
407
|
+
`session "${id}" is not archived; only archived sessions can be deleted`,
|
|
408
|
+
"SESSION_NOT_ARCHIVED",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
if (this.tracker.hasPending(id) || this.tracker.hasOpenHandle(id)) {
|
|
412
|
+
throw new SessionDeletionError(
|
|
413
|
+
`session "${id}" is live; stop it before deleting`,
|
|
414
|
+
"SESSION_LIVE",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
await this.unarchiveBeforeDeletion(id);
|
|
418
|
+
const referenced = await this.backend.transaction((tx) => tx.deleteSession(id));
|
|
419
|
+
this.live.forget(id);
|
|
420
|
+
this.reuseEventIds.delete(id);
|
|
421
|
+
await this.writePath.dropSessionStats(id);
|
|
422
|
+
await this.writePath.refreshEventUsageFlags(referenced);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 整理数据:回收孤儿会话与孤儿事件行,然后**整表重算**三张派生统计表,最后 VACUUM。
|
|
427
|
+
*
|
|
428
|
+
* 顺序有依赖:先清孤儿(被删会话独占的事件行才刚成为孤儿),再按事件表重算统计,
|
|
429
|
+
* 否则统计会把刚删掉的引用算回去。
|
|
430
|
+
* @returns 三项计数(孤儿会话 / 孤儿事件行 / 重建涉及的会话数)。
|
|
431
|
+
*/
|
|
432
|
+
async organize(): Promise<{
|
|
433
|
+
orphanSessions: number;
|
|
434
|
+
orphanEvents: number;
|
|
435
|
+
rebuiltSessions: number;
|
|
436
|
+
}> {
|
|
437
|
+
await this.ready;
|
|
438
|
+
const orphanSessions = await this.collectOrphanSessions();
|
|
439
|
+
const orphanEvents = await this.collectOrphans();
|
|
440
|
+
const rebuiltSessions = await this.backend.rebuildAllStats();
|
|
441
|
+
await this.backend.vacuum();
|
|
442
|
+
return { orphanSessions, orphanEvents, rebuiltSessions };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 回收已无桥接行引用的事件行(孤儿),返回删除行数。
|
|
446
|
+
async collectOrphans(): Promise<number> {
|
|
447
|
+
await this.ready;
|
|
448
|
+
return this.backend.collectOrphans();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// GC 通道:回收父已不存在的 subagent 会话(live 的跳过),返回删除的会话数。
|
|
452
|
+
async collectOrphanSessions(): Promise<number> {
|
|
453
|
+
await this.ready;
|
|
454
|
+
const orphans = await this.backend.listOrphanSubagentSessions();
|
|
455
|
+
const deletable = orphans.filter(
|
|
456
|
+
(id) => !this.tracker.hasPending(id) && !this.tracker.hasOpenHandle(id),
|
|
457
|
+
);
|
|
458
|
+
if (deletable.length === 0) return 0;
|
|
459
|
+
const { deleted, eventIds } = await this.backend.transaction((tx) =>
|
|
460
|
+
tx.deleteSessions(deletable),
|
|
461
|
+
);
|
|
462
|
+
for (const id of deletable) {
|
|
463
|
+
this.live.forget(id);
|
|
464
|
+
this.reuseEventIds.delete(id);
|
|
465
|
+
await this.writePath.dropSessionStats(id);
|
|
466
|
+
}
|
|
467
|
+
await this.writePath.refreshEventUsageFlags(eventIds);
|
|
468
|
+
return deleted;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// 用量统计:SQL 聚合的按天 × 模型桶与按会话行(事件行去重、排除孤儿行)。
|
|
472
|
+
async usageReport(sinceMs?: number): Promise<UsageAggregate> {
|
|
473
|
+
await this.ready;
|
|
474
|
+
return this.backend.usageReport(sinceMs);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// GC 通道:VACUUM;调用方需先停止运行中的写路径(见 `registerSessionGc`)。
|
|
478
|
+
async vacuum(): Promise<void> {
|
|
479
|
+
await this.ready;
|
|
480
|
+
await this.backend.vacuum();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 已归档的会话 id(`t_sessions.f_archived_at` 非空)。`session/list` 默认过滤掉它们。
|
|
485
|
+
* 读的是持久化里的归档标记,不依赖 `workspaceRegistry` 服务的就绪时机。
|
|
486
|
+
* @returns 归档过的 Session id 集。
|
|
487
|
+
*/
|
|
488
|
+
async archivedSessionIds(): Promise<ReadonlySet<string>> {
|
|
489
|
+
await this.ready;
|
|
490
|
+
const state = (await this.backend.storage.readDomainGlobal("workspace")) as {
|
|
491
|
+
archivedSessionIds?: readonly string[];
|
|
492
|
+
} | null;
|
|
493
|
+
return new Set((state?.archivedSessionIds ?? []).map(String));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private async unarchiveBeforeDeletion(id: SessionId): Promise<void> {
|
|
497
|
+
const registry = this.ctx.get("workspaceRegistry") as unknown as
|
|
498
|
+
| {
|
|
499
|
+
readonly archivedSessionIds: readonly SessionId[];
|
|
500
|
+
unarchiveSession(sessionId: SessionId): Promise<void>;
|
|
501
|
+
}
|
|
502
|
+
| undefined;
|
|
503
|
+
if (registry === undefined) return;
|
|
504
|
+
if (!registry.archivedSessionIds.includes(id)) return;
|
|
505
|
+
await registry.unarchiveSession(id);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 内部接口(句柄、live 路由、导出、测试)用的入口:实现分别在 `read-path.ts` / `write-path.ts`。
|
|
509
|
+
readLog(
|
|
510
|
+
id: SessionId,
|
|
511
|
+
options: { fromSeq?: number } = {},
|
|
512
|
+
signal?: AbortSignal,
|
|
513
|
+
) {
|
|
514
|
+
return this.readPath.readLog(id, options, signal);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
readRaw(id: SessionId, signal?: AbortSignal) {
|
|
518
|
+
return this.readPath.readRaw(id, signal);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
listSnapshots(signal?: AbortSignal) {
|
|
522
|
+
return this.readPath.listSnapshots(signal);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
readStoredRevision(id: SessionId, signal?: AbortSignal) {
|
|
526
|
+
return this.readPath.readStoredRevision(id, signal);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
materializeEmpty(meta: SessionHeader, inheritedEventCount: SessionLogOffset) {
|
|
530
|
+
return this.writePath.materializeEmpty(meta, inheritedEventCount);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
appendBatch(
|
|
534
|
+
meta: SessionHeader,
|
|
535
|
+
inheritedEventCount: SessionLogOffset,
|
|
536
|
+
events: readonly SessionEvent[],
|
|
537
|
+
tornTruncateTo?: number,
|
|
538
|
+
) {
|
|
539
|
+
return this.writePath.appendBatch(meta, inheritedEventCount, events, tornTruncateTo);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async close(): Promise<void> {
|
|
543
|
+
await this.ready;
|
|
544
|
+
await this.backend.close();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async createAndAppend(
|
|
548
|
+
header: SessionHeader,
|
|
549
|
+
events: readonly SessionEvent[],
|
|
550
|
+
inheritedEventCount?: number,
|
|
551
|
+
): Promise<void> {
|
|
552
|
+
const handle = await this.create(
|
|
553
|
+
header,
|
|
554
|
+
inheritedEventCount === undefined
|
|
555
|
+
? undefined
|
|
556
|
+
: { inheritedEventCount: SessionLogOffset(inheritedEventCount) },
|
|
557
|
+
);
|
|
558
|
+
try {
|
|
559
|
+
if (events.length > 0) await handle.append(events);
|
|
560
|
+
} finally {
|
|
561
|
+
await handle.close();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async load(
|
|
566
|
+
id: SessionId,
|
|
567
|
+
signal?: AbortSignal,
|
|
568
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
|
|
569
|
+
signal?.throwIfAborted();
|
|
570
|
+
await this.ready;
|
|
571
|
+
signal?.throwIfAborted();
|
|
572
|
+
// 一条读取路径:load 的语义是「读一次完整快照」,不经 handle 面再读一遍。
|
|
573
|
+
const log = await this.readPath.readLog(id, {}, signal);
|
|
574
|
+
if (log === undefined) {
|
|
575
|
+
// 未落库的 live 会话没有磁盘视图:与 handle.read 的 detached 口径一致,返回空日志。
|
|
576
|
+
const pending = this.tracker.pendingOf(id);
|
|
577
|
+
if (pending !== undefined) {
|
|
578
|
+
return {
|
|
579
|
+
meta: pending.header,
|
|
580
|
+
inheritedEventCount: SessionLogOffset(pending.inheritedEventCount),
|
|
581
|
+
events: [],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
throw new SessionPersistenceNotFoundError(id);
|
|
585
|
+
}
|
|
586
|
+
repairReadView(log.events);
|
|
587
|
+
validateStoredEvents(log.meta, log.events);
|
|
588
|
+
return {
|
|
589
|
+
meta: log.meta,
|
|
590
|
+
inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
|
|
591
|
+
events: log.events,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
|
|
596
|
+
const handle = await this.open(id, "write");
|
|
597
|
+
try {
|
|
598
|
+
await handle.append(events);
|
|
599
|
+
} finally {
|
|
600
|
+
await handle.close();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async readFrom(
|
|
605
|
+
id: SessionId,
|
|
606
|
+
fromSeq: number,
|
|
607
|
+
signal?: AbortSignal,
|
|
608
|
+
): Promise<{
|
|
609
|
+
meta: SessionHeader;
|
|
610
|
+
inheritedEventCount: number;
|
|
611
|
+
events: readonly SessionEvent[];
|
|
612
|
+
}> {
|
|
613
|
+
const log = await this.readPath.readLog(id, { fromSeq }, signal);
|
|
614
|
+
if (log === undefined) throw new SessionPersistenceNotFoundError(id);
|
|
615
|
+
return {
|
|
616
|
+
meta: log.meta,
|
|
617
|
+
inheritedEventCount: log.inheritedEventCount,
|
|
618
|
+
events: log.events,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void {
|
|
623
|
+
this.reuseEventIds.set(childId, new Map(map));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 丢弃一个还没被消费的复用映射(fork 失败时):留着会让同一个 childId 之后的落写复用父会话的事件行。
|
|
627
|
+
dropReuseEventIds(childId: SessionId): void {
|
|
628
|
+
this.reuseEventIds.delete(childId);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** 分支能力的端口(见 `branch/port.ts`):每个动词都有真实调用方,不是服务方法表的转抄。 */
|
|
632
|
+
branchPort(): BranchPort {
|
|
633
|
+
return {
|
|
634
|
+
ready: this.ready,
|
|
635
|
+
readLog: (id, options, signal) => this.readPath.readLog(id, options ?? {}, signal),
|
|
636
|
+
create: (meta, options) => this.create(meta, options),
|
|
637
|
+
eventRows: (id, fromSequence) => this.backend.reads.getEventRows(id, fromSequence),
|
|
638
|
+
eventTypesBefore: (id, before, limit) => this.backend.reads.getEventTypesBefore(id, before, limit),
|
|
639
|
+
sessionRow: (id) => this.backend.reads.getSession(id),
|
|
640
|
+
eventTypeAt: (id, sequence) => this.backend.reads.getEventTypeAt(id, sequence),
|
|
641
|
+
transaction: (fn) => this.backend.transaction(fn),
|
|
642
|
+
confirmHead: (id, sequence) => this.writeGuard.confirmHead(id, sequence),
|
|
643
|
+
readStoredRevision: (id) => this.readPath.readStoredRevision(id),
|
|
644
|
+
registerReuseEventIds: (childId, map) => this.registerReuseEventIds(childId, map),
|
|
645
|
+
dropReuseEventIds: (childId) => this.dropReuseEventIds(childId),
|
|
646
|
+
resetWriterAfterRewind: (id, cursor, inheritedEventCount) => {
|
|
647
|
+
this.tracker.writerOf(id)?.resetAfterRewind(cursor, inheritedEventCount);
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private rowRevision(row: import("./backend.ts").SessionRow): SessionPersistenceRevision {
|
|
653
|
+
return SessionPersistenceRevision(
|
|
654
|
+
`${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** 装配层配置 → 打开后端所需的最小形状(两方言共用)。 */
|
|
660
|
+
function backendConfigOf(config: Config): BackendConfig {
|
|
661
|
+
return config.type === "sqlite"
|
|
662
|
+
? {
|
|
663
|
+
type: "sqlite",
|
|
664
|
+
path: config.path,
|
|
665
|
+
journalMode: config.journalMode ?? "wal",
|
|
666
|
+
busyTimeout: config.busyTimeout ?? DEFAULT_BUSY_TIMEOUT_MS,
|
|
667
|
+
}
|
|
668
|
+
: {
|
|
669
|
+
type: "postgres",
|
|
670
|
+
connectionString: config.connectionString,
|
|
671
|
+
...(config.schema === undefined ? {} : { schema: config.schema }),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export default SessionPersistenceRdb;
|