@morlay/session-rdb 0.0.11 → 0.0.13
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 +0 -16
- package/dist/artifact.d.mts +64 -0
- package/dist/artifact.mjs +3 -0
- package/dist/import-D2vUnUZe.mjs +438 -0
- package/dist/import.d.mts +24 -0
- package/dist/import.mjs +2 -0
- package/dist/index-uUvn6gYp.d.mts +111 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +466 -0
- package/dist/invariant.d.mts +7 -0
- package/dist/invariant.mjs +8 -0
- package/dist/magic-string.es-BWtG0AyA.mjs +1017 -0
- package/dist/schema-B-8G4lWc.mjs +852 -0
- package/dist/schema-CFXZURX7.d.mts +116 -0
- package/dist/sqlite-DIXY-dFZ.mjs +356 -0
- package/dist/storage.d.mts +44 -0
- package/dist/storage.mjs +3 -0
- package/dist/testing.d.mts +31 -0
- package/dist/testing.mjs +16051 -0
- package/package.json +39 -22
- package/src/adapters/ddl.ts +77 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/to-postgres.ts +91 -0
- package/src/adapters/to-sqlite.ts +79 -0
- package/src/artifact.ts +4 -0
- package/src/backend.ts +112 -0
- package/src/branch.ts +508 -0
- package/src/entities/events.ts +27 -0
- package/src/entities/index.ts +30 -0
- package/src/entities/persistence-state.ts +10 -0
- package/src/entities/schema-meta.ts +9 -0
- package/src/entities/session-events.ts +29 -0
- package/src/entities/sessions.ts +20 -0
- package/src/entities/types.ts +48 -0
- package/src/import.ts +270 -0
- package/src/index.ts +547 -0
- package/src/invariant.ts +15 -0
- package/src/log.ts +456 -0
- package/src/migrate.ts +137 -0
- package/src/postgres.ts +369 -0
- package/src/schema.ts +234 -0
- package/src/sqlite.ts +412 -0
- package/src/storage.ts +5 -0
- package/src/testing/contract.ts +562 -0
- package/src/testing/coordinator-contract.ts +1723 -0
- package/src/testing/helpers.ts +20 -0
- package/src/testing.ts +12 -0
- package/src/write-guard.ts +27 -0
- package/lib/index.d.mts +0 -558
- package/lib/index.d.mts.map +0 -1
- package/lib/index.mjs +0 -2176
- package/lib/index.mjs.map +0 -1
- package/lib/invariant.d.mts +0 -15
- package/lib/invariant.d.mts.map +0 -1
- package/lib/invariant.mjs +0 -21
- package/lib/invariant.mjs.map +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import type { SettingsProvider } from "@deepseek-ai/dsh-settings";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { Pool } from "pg";
|
|
6
|
+
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
|
|
7
|
+
import {
|
|
8
|
+
SessionPersistence,
|
|
9
|
+
SessionPersistenceRevision,
|
|
10
|
+
PersistenceCoordinator,
|
|
11
|
+
type PersistenceBackend,
|
|
12
|
+
type SessionLocation,
|
|
13
|
+
type SessionPersistenceSnapshot,
|
|
14
|
+
type SessionStorageMetadata,
|
|
15
|
+
type StoredPrefix,
|
|
16
|
+
type StoredSuffix,
|
|
17
|
+
} from "@deepseek-ai/dsh-session-persistence";
|
|
18
|
+
import {
|
|
19
|
+
SessionLogOffset,
|
|
20
|
+
type SessionEvent,
|
|
21
|
+
type SurfaceEventType,
|
|
22
|
+
type SessionId,
|
|
23
|
+
type SessionHeader,
|
|
24
|
+
} from "@deepseek-ai/dsh-session";
|
|
25
|
+
import { type Backend, type BackendTx, type EventInsert, type EventRow } from "./backend.ts";
|
|
26
|
+
import { WriteGuard } from "./write-guard.ts";
|
|
27
|
+
import {
|
|
28
|
+
buildSeqMap,
|
|
29
|
+
recomputeReplaceProvenance,
|
|
30
|
+
repairOrphanInboxSplices,
|
|
31
|
+
repairSurfaceOps,
|
|
32
|
+
rowToMeta,
|
|
33
|
+
scanRows,
|
|
34
|
+
toJsonlArtifact,
|
|
35
|
+
} from "./log.ts";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_BUSY_TIMEOUT_MS,
|
|
38
|
+
eventDimensions,
|
|
39
|
+
EVENT_ENCODING,
|
|
40
|
+
isPersistedEvent,
|
|
41
|
+
type JournalMode,
|
|
42
|
+
} from "./schema.ts";
|
|
43
|
+
import { SqliteBackend } from "./sqlite.ts";
|
|
44
|
+
import { PostgresBackend } from "./postgres.ts";
|
|
45
|
+
import { SessionBranchRdb } from "./branch.ts";
|
|
46
|
+
import { registerSessionImport } from "./import.ts";
|
|
47
|
+
|
|
48
|
+
export { SCHEMA_VERSION, EPHEMERAL_EVENT_TYPES } from "./schema.ts";
|
|
49
|
+
export { SessionBranchRdb, SessionBranchRdbProvider, locateTurnEnd } from "./branch.ts";
|
|
50
|
+
|
|
51
|
+
export interface SessionPersistenceRdbInternals {
|
|
52
|
+
readonly backend: Backend;
|
|
53
|
+
readonly writeGuard: WriteGuard;
|
|
54
|
+
create(meta: SessionHeader, inheritedEventCount?: number): Promise<void>;
|
|
55
|
+
append(id: SessionId, events: readonly SessionEvent[]): Promise<void>;
|
|
56
|
+
load(id: SessionId): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection>;
|
|
57
|
+
inspect(
|
|
58
|
+
id: SessionId,
|
|
59
|
+
signal?: AbortSignal,
|
|
60
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection>;
|
|
61
|
+
readFrom(
|
|
62
|
+
id: SessionId,
|
|
63
|
+
fromSeq: number,
|
|
64
|
+
signal?: AbortSignal,
|
|
65
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionEventSuffix>;
|
|
66
|
+
listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>;
|
|
67
|
+
readStoredRevision(
|
|
68
|
+
id: SessionId,
|
|
69
|
+
signal?: AbortSignal,
|
|
70
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionPersistenceRevision | undefined>;
|
|
71
|
+
|
|
72
|
+
registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type Config =
|
|
76
|
+
| {
|
|
77
|
+
type: "sqlite";
|
|
78
|
+
|
|
79
|
+
path: string;
|
|
80
|
+
|
|
81
|
+
journalMode?: JournalMode;
|
|
82
|
+
|
|
83
|
+
busyTimeout?: number;
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
type: "postgres";
|
|
87
|
+
|
|
88
|
+
connectionString: string;
|
|
89
|
+
|
|
90
|
+
schema?: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export class SessionPersistenceRdb
|
|
94
|
+
extends SessionPersistence
|
|
95
|
+
implements PersistenceBackend<number>
|
|
96
|
+
{
|
|
97
|
+
static inject = ["sessions", "settings"];
|
|
98
|
+
|
|
99
|
+
static Config: z<Config> = z.union([
|
|
100
|
+
z.object({
|
|
101
|
+
type: z.const("sqlite"),
|
|
102
|
+
path: z.string().required(),
|
|
103
|
+
journalMode: z.union(["wal", "delete", "truncate", "persist"] as const).default("wal"),
|
|
104
|
+
busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS),
|
|
105
|
+
}),
|
|
106
|
+
z.object({
|
|
107
|
+
type: z.const("postgres"),
|
|
108
|
+
connectionString: z.string().required(),
|
|
109
|
+
schema: z.string().default("public"),
|
|
110
|
+
}),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
static readonly settingsNs = "session-rdb";
|
|
114
|
+
|
|
115
|
+
override readonly name = "session-rdb";
|
|
116
|
+
|
|
117
|
+
override readonly supportsRawArtifacts = true;
|
|
118
|
+
|
|
119
|
+
override async readRaw(
|
|
120
|
+
id: SessionId,
|
|
121
|
+
signal?: AbortSignal,
|
|
122
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionRawArtifact | undefined> {
|
|
123
|
+
signal?.throwIfAborted();
|
|
124
|
+
await this.ready;
|
|
125
|
+
signal?.throwIfAborted();
|
|
126
|
+
const log = await this.readLog(id, {}, signal);
|
|
127
|
+
if (log === undefined) return undefined;
|
|
128
|
+
repairSurfaceOps(log.events);
|
|
129
|
+
recomputeReplaceProvenance(log.events);
|
|
130
|
+
const inheritedEventCount = Math.min(log.inheritedEventCount, log.events.length);
|
|
131
|
+
return {
|
|
132
|
+
meta: log.meta,
|
|
133
|
+
inheritedEventCount: SessionLogOffset(inheritedEventCount),
|
|
134
|
+
filename: "session.jsonl",
|
|
135
|
+
content: toJsonlArtifact(log.meta, inheritedEventCount, log.events),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private readonly backend: Backend;
|
|
140
|
+
private storeIdentity!: string;
|
|
141
|
+
private readonly ready: Promise<void>;
|
|
142
|
+
private readonly coordinator: PersistenceCoordinator<number>;
|
|
143
|
+
|
|
144
|
+
private readonly writeGuard = new WriteGuard();
|
|
145
|
+
|
|
146
|
+
private readonly reuseEventIds = new Map<SessionId, Map<number, string>>();
|
|
147
|
+
|
|
148
|
+
constructor(
|
|
149
|
+
ctx: Context,
|
|
150
|
+
public config: Config,
|
|
151
|
+
|
|
152
|
+
injectedBackend?: Backend,
|
|
153
|
+
) {
|
|
154
|
+
// settings.yaml 的 `session-rdb` namespace 覆盖 cordis 层 entry config;
|
|
155
|
+
// settings 服务缺失时(纯 cordis 装配/测试)退化为 entry config。
|
|
156
|
+
let resolved: Config = config;
|
|
157
|
+
const settings = ctx.reflect.get("settings") as unknown as SettingsProvider | undefined;
|
|
158
|
+
if (settings !== undefined) {
|
|
159
|
+
const scope = settings.register(
|
|
160
|
+
SessionPersistenceRdb.settingsNs,
|
|
161
|
+
SessionPersistenceRdb.Config,
|
|
162
|
+
{ base: config },
|
|
163
|
+
);
|
|
164
|
+
resolved = scope.get();
|
|
165
|
+
scope.watch(() => {
|
|
166
|
+
// 后端在构造时建成,settings 变更后需重启 dsh 生效。
|
|
167
|
+
ctx.logger.warn("session-rdb: settings changed; restart to apply the new configuration");
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
super(ctx);
|
|
171
|
+
// 异步打开连接,避免阻塞插件 apply;存储钩子统一 await 同一个 readiness。
|
|
172
|
+
this.config = resolved;
|
|
173
|
+
this.backend = injectedBackend ?? createBackend(resolved);
|
|
174
|
+
this.ready = this.init();
|
|
175
|
+
this.coordinator = new PersistenceCoordinator<number>(this.ctx, this);
|
|
176
|
+
// 分支 provider 服务(rewind / forkFrom / timeline),随 fiber 卸载自动回滚。
|
|
177
|
+
new SessionBranchRdb(this.ctx);
|
|
178
|
+
// 导入端点:webServer + connection 就绪后注册 `/api/session.import`。
|
|
179
|
+
registerSessionImport(this.ctx, this);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async init(): Promise<void> {
|
|
183
|
+
await this.backend.open();
|
|
184
|
+
this.storeIdentity = this.backend.storeIdentity;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- SessionPersistence service surface (delegated to the coordinator) ---
|
|
188
|
+
|
|
189
|
+
locate(_meta: SessionHeader): SessionLocation | undefined {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
create(meta: SessionHeader, inheritedEventCount?: number): Promise<void> {
|
|
194
|
+
return this.coordinator.create(
|
|
195
|
+
meta,
|
|
196
|
+
inheritedEventCount === undefined ? undefined : SessionLogOffset(inheritedEventCount),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
|
|
201
|
+
return this.coordinator.append(id, events);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
load(id: SessionId): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
|
|
205
|
+
return this.coordinator.load(id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
inspect(
|
|
209
|
+
id: SessionId,
|
|
210
|
+
signal?: AbortSignal,
|
|
211
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionInspection> {
|
|
212
|
+
return this.coordinator.inspect(id, signal);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
readFrom(
|
|
216
|
+
id: SessionId,
|
|
217
|
+
fromSeq: number,
|
|
218
|
+
signal?: AbortSignal,
|
|
219
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").SessionEventSuffix> {
|
|
220
|
+
// 校验委托给 coordinator;这里只做品牌转换,避免 cordis proxy 下同步 throw 逃逸。
|
|
221
|
+
return this.coordinator.readFrom(id, fromSeq as SessionLogOffset, signal);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
override borrowSession(
|
|
225
|
+
id: SessionId,
|
|
226
|
+
signal?: AbortSignal,
|
|
227
|
+
): Promise<import("@deepseek-ai/dsh-session-persistence").BorrowedSessionSource> {
|
|
228
|
+
return this.coordinator.borrowSession(id, signal);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- PersistenceBackend hooks (the storage primitives) ---
|
|
232
|
+
|
|
233
|
+
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
|
|
234
|
+
return this.readPrefix(id, signal);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async loadStoredFrom(
|
|
238
|
+
id: SessionId,
|
|
239
|
+
fromSeq: number,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): Promise<StoredSuffix | undefined> {
|
|
242
|
+
const log = await this.readLog(id, { fromSeq }, signal);
|
|
243
|
+
if (log === undefined) return undefined;
|
|
244
|
+
return {
|
|
245
|
+
meta: log.meta,
|
|
246
|
+
inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
|
|
247
|
+
events: log.events,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async readPrefix(
|
|
252
|
+
id: SessionId,
|
|
253
|
+
signal?: AbortSignal,
|
|
254
|
+
): Promise<StoredPrefix<number> | undefined> {
|
|
255
|
+
const log = await this.readLog(id, {}, signal);
|
|
256
|
+
if (log === undefined) {
|
|
257
|
+
// 已确认缺席:本实例读过的新会话,后续对已出现行的 append 必须拒绝。
|
|
258
|
+
this.writeGuard.confirmHead(id, -1);
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
// 确认 head 是最后一个保留 seq(torn tail 由 commitRepair 删除并重确认)。
|
|
262
|
+
this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
|
|
263
|
+
// replace 的 provenance 读取时重计算(sourceEventSeqs 不落库)。
|
|
264
|
+
recomputeReplaceProvenance(log.events);
|
|
265
|
+
// 孤儿 inbox splice(引用已被截断排队消息的插入/消费)会使上游 Inbox
|
|
266
|
+
// 增量重放失败、整个会话不可 resume——读取时改写为 no-op 修复。
|
|
267
|
+
repairOrphanInboxSplices(log.events);
|
|
268
|
+
return {
|
|
269
|
+
meta: log.meta,
|
|
270
|
+
inheritedEventCount: SessionLogOffset(log.inheritedEventCount),
|
|
271
|
+
events: log.events,
|
|
272
|
+
// revision 表示必须与 readStoredRevision 的表示一致(见 listSnapshots)。
|
|
273
|
+
revision: SessionPersistenceRevision(
|
|
274
|
+
`${this.storeIdentity}:incarnation:${log.incarnation}:revision:${log.revision}`,
|
|
275
|
+
),
|
|
276
|
+
...(log.tornFrom !== undefined ? { tornMarker: log.tornFrom } : {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async readStoredRevision(
|
|
281
|
+
id: SessionId,
|
|
282
|
+
signal?: AbortSignal,
|
|
283
|
+
): Promise<SessionPersistenceRevision | undefined> {
|
|
284
|
+
signal?.throwIfAborted();
|
|
285
|
+
await this.ready;
|
|
286
|
+
signal?.throwIfAborted();
|
|
287
|
+
const row = await this.backend.getSession(id);
|
|
288
|
+
if (row === undefined) return undefined;
|
|
289
|
+
return SessionPersistenceRevision(
|
|
290
|
+
`${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async readLog(
|
|
295
|
+
id: SessionId,
|
|
296
|
+
options: { fromSeq?: number } = {},
|
|
297
|
+
signal?: AbortSignal,
|
|
298
|
+
): Promise<
|
|
299
|
+
| {
|
|
300
|
+
meta: SessionHeader;
|
|
301
|
+
|
|
302
|
+
inheritedEventCount: number;
|
|
303
|
+
events: SessionEvent[];
|
|
304
|
+
tornFrom?: number;
|
|
305
|
+
|
|
306
|
+
incarnation: string;
|
|
307
|
+
|
|
308
|
+
revision: number;
|
|
309
|
+
}
|
|
310
|
+
| undefined
|
|
311
|
+
> {
|
|
312
|
+
signal?.throwIfAborted();
|
|
313
|
+
await this.ready;
|
|
314
|
+
signal?.throwIfAborted();
|
|
315
|
+
const row = await this.backend.getSession(id);
|
|
316
|
+
if (row === undefined) return undefined;
|
|
317
|
+
const meta = rowToMeta(row);
|
|
318
|
+
let eventRows: EventRow[];
|
|
319
|
+
let seqMap: ReadonlyMap<number, number>;
|
|
320
|
+
if (options.fromSeq === undefined) {
|
|
321
|
+
// 全量读:seq map 由同一批行构建(无额外查询)。
|
|
322
|
+
eventRows = await this.backend.getEventRows(id);
|
|
323
|
+
seqMap = buildSeqMap(eventRows);
|
|
324
|
+
} else {
|
|
325
|
+
// 后缀读:坐标重映射需要每一行的上游 seq,另读轻量两列映射。
|
|
326
|
+
eventRows = await this.backend.getEventRows(id, options.fromSeq);
|
|
327
|
+
const seqRows = await this.backend.getSeqMapRows(id);
|
|
328
|
+
seqMap = buildSeqMap(seqRows);
|
|
329
|
+
}
|
|
330
|
+
signal?.throwIfAborted();
|
|
331
|
+
const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0, seqMap);
|
|
332
|
+
return {
|
|
333
|
+
meta,
|
|
334
|
+
inheritedEventCount: row.fSeedLength ?? 0,
|
|
335
|
+
events: preserved,
|
|
336
|
+
incarnation: row.fIncarnation,
|
|
337
|
+
revision: row.fRevision,
|
|
338
|
+
...(tornFrom !== undefined ? { tornFrom } : {}),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async appendBatch(
|
|
343
|
+
storage: SessionStorageMetadata,
|
|
344
|
+
events: readonly SessionEvent[],
|
|
345
|
+
_isMaterialized: boolean,
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
await this.ready;
|
|
348
|
+
const persisted = events.filter(isPersistedEvent);
|
|
349
|
+
if (persisted.length === 0) return;
|
|
350
|
+
const meta = storage.meta;
|
|
351
|
+
// fork 派生会话的 seed 复用源会话事件行(不复制);消费后清除。
|
|
352
|
+
const reuse = this.reuseEventIds.get(meta.id);
|
|
353
|
+
if (reuse !== undefined) this.reuseEventIds.delete(meta.id);
|
|
354
|
+
let confirmedHead = -1;
|
|
355
|
+
await this.backend.transaction(async (tx) => {
|
|
356
|
+
await tx.upsertSession(storage, randomUUID());
|
|
357
|
+
const head = await tx.getHead(meta.id);
|
|
358
|
+
// 重编号前拒绝第二个写入者:多实例共享数据库时,第二个写入者经陈旧
|
|
359
|
+
// 视图 append 会把事件静默重编号到对方尾部、损坏 log。磁盘 head 必须
|
|
360
|
+
// 等于本实例确认过的最后一个 head。
|
|
361
|
+
this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
|
|
362
|
+
const { headEventId, headSequence } = await appendEventTail(
|
|
363
|
+
tx,
|
|
364
|
+
meta,
|
|
365
|
+
persisted,
|
|
366
|
+
{ parentId: head.fHeadEventId, nextSeq: head.fHeadSequence + 1 },
|
|
367
|
+
reuse,
|
|
368
|
+
);
|
|
369
|
+
await tx.updateHead(meta.id, headEventId, headSequence);
|
|
370
|
+
await tx.bumpRevision(meta.id);
|
|
371
|
+
confirmedHead = headSequence;
|
|
372
|
+
});
|
|
373
|
+
// 提交后才确认新 head:回滚不得留下本实例实际未写的已确认 head。
|
|
374
|
+
this.writeGuard.confirmHead(meta.id, confirmedHead);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async commitRepair(
|
|
378
|
+
storage: SessionStorageMetadata,
|
|
379
|
+
tornMarker: number | undefined,
|
|
380
|
+
closers: readonly SessionEvent[],
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
await this.ready;
|
|
383
|
+
const meta = storage.meta;
|
|
384
|
+
const persistedClosers = closers.filter(isPersistedEvent);
|
|
385
|
+
if (tornMarker === undefined && persistedClosers.length === 0) return;
|
|
386
|
+
await this.backend.transaction(async (tx) => {
|
|
387
|
+
if (tornMarker !== undefined) {
|
|
388
|
+
await tx.deleteBridgeTail(meta.id, tornMarker);
|
|
389
|
+
// head 游标回退到最后一个幸存事件(torn tail 从 seq 0 开始时为初始态)。
|
|
390
|
+
const prev = await tx.getPrevBridge(meta.id, tornMarker - 1);
|
|
391
|
+
if (prev === undefined) {
|
|
392
|
+
await tx.updateHead(meta.id, "", -1);
|
|
393
|
+
} else {
|
|
394
|
+
await tx.updateHead(meta.id, prev.fEventId, prev.fSequence);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (persistedClosers.length > 0) {
|
|
398
|
+
// 锚定实际尾行:head 游标可能滞后于行(手工 torn tail 不更新游标)。
|
|
399
|
+
const last = await tx.getLastBridge(meta.id);
|
|
400
|
+
const { headEventId, headSequence } = await appendEventTail(tx, meta, persistedClosers, {
|
|
401
|
+
parentId: last?.fEventId ?? "",
|
|
402
|
+
nextSeq: (last?.fSequence ?? -1) + 1,
|
|
403
|
+
});
|
|
404
|
+
await tx.updateHead(meta.id, headEventId, headSequence);
|
|
405
|
+
}
|
|
406
|
+
await tx.bumpRevision(meta.id);
|
|
407
|
+
});
|
|
408
|
+
// 修复后重确认 head:截断会回退它、closers 会推进它,下一次 append 不得
|
|
409
|
+
// 基于陈旧确认被拒绝(或更糟,被静默重编号)。
|
|
410
|
+
const row = await this.backend.getSession(meta.id);
|
|
411
|
+
this.writeGuard.confirmHead(meta.id, row?.fHeadSequence ?? -1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
|
415
|
+
signal?.throwIfAborted();
|
|
416
|
+
await this.ready;
|
|
417
|
+
signal?.throwIfAborted();
|
|
418
|
+
const rows = await this.backend.listSessions();
|
|
419
|
+
signal?.throwIfAborted();
|
|
420
|
+
return rows.map(rowToMeta);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async listSnapshots(
|
|
424
|
+
signal?: AbortSignal,
|
|
425
|
+
): Promise<Array<SessionPersistenceSnapshot & { inheritedEventCount: number }>> {
|
|
426
|
+
signal?.throwIfAborted();
|
|
427
|
+
await this.ready;
|
|
428
|
+
signal?.throwIfAborted();
|
|
429
|
+
const rows = await this.backend.listSessions();
|
|
430
|
+
signal?.throwIfAborted();
|
|
431
|
+
return rows.map((row) => ({
|
|
432
|
+
header: rowToMeta(row),
|
|
433
|
+
revision: SessionPersistenceRevision(
|
|
434
|
+
`${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`,
|
|
435
|
+
),
|
|
436
|
+
inheritedEventCount: row.fSeedLength ?? 0,
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async close(): Promise<void> {
|
|
441
|
+
await this.ready;
|
|
442
|
+
await this.backend.close();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
registerReuseEventIds(childId: SessionId, map: ReadonlyMap<number, string>): void {
|
|
446
|
+
this.reuseEventIds.set(childId, new Map(map));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
internals(): SessionPersistenceRdbInternals {
|
|
450
|
+
return {
|
|
451
|
+
backend: this.backend,
|
|
452
|
+
writeGuard: this.writeGuard,
|
|
453
|
+
create: (meta, inheritedEventCount) => this.create(meta, inheritedEventCount),
|
|
454
|
+
append: (id, events) => this.append(id, events),
|
|
455
|
+
load: (id) => this.load(id),
|
|
456
|
+
inspect: (id, signal) => this.inspect(id, signal),
|
|
457
|
+
readFrom: (id, fromSeq, signal) => this.readFrom(id, fromSeq, signal),
|
|
458
|
+
listSnapshots: (signal) => this.listSnapshots(signal),
|
|
459
|
+
readStoredRevision: (id, signal) => this.readStoredRevision(id, signal),
|
|
460
|
+
registerReuseEventIds: (childId, map) => this.registerReuseEventIds(childId, map),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function createBackend(config: Config): Backend {
|
|
466
|
+
if (config.type === "sqlite") {
|
|
467
|
+
return new SqliteBackend({
|
|
468
|
+
path: config.path,
|
|
469
|
+
journalMode: config.journalMode ?? "wal",
|
|
470
|
+
busyTimeout: config.busyTimeout ?? DEFAULT_BUSY_TIMEOUT_MS,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const pool = new Pool({ connectionString: config.connectionString });
|
|
474
|
+
// node-postgres 要求 Pool 必须监听 error:未监听的 idle client error 会
|
|
475
|
+
// 以 uncaughtException 崩溃进程;池级错误在下次查询处可见,这里只消费。
|
|
476
|
+
pool.on("error", () => {});
|
|
477
|
+
const db = drizzlePg({ client: pool });
|
|
478
|
+
const identityBase = [
|
|
479
|
+
"postgres",
|
|
480
|
+
pool.options.host ?? "localhost",
|
|
481
|
+
String(pool.options.port ?? 5432),
|
|
482
|
+
pool.options.database ?? "",
|
|
483
|
+
config.schema ?? "public",
|
|
484
|
+
].join(":");
|
|
485
|
+
return new PostgresBackend(db, {
|
|
486
|
+
identityBase,
|
|
487
|
+
schema: config.schema ?? "public",
|
|
488
|
+
close: () => pool.end(),
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function appendEventTail(
|
|
493
|
+
tx: BackendTx,
|
|
494
|
+
meta: SessionHeader,
|
|
495
|
+
events: readonly SessionEvent[],
|
|
496
|
+
anchor: { parentId: string; nextSeq: number },
|
|
497
|
+
reuse?: ReadonlyMap<number, string>,
|
|
498
|
+
): Promise<{ headEventId: string; headSequence: number }> {
|
|
499
|
+
let parentId = anchor.parentId;
|
|
500
|
+
let nextSeq = anchor.nextSeq;
|
|
501
|
+
// 两个批次一次性多行 INSERT(N 事件 2 条语句,而非 2N)。
|
|
502
|
+
const eventRows: EventInsert[] = [];
|
|
503
|
+
const bridgeRows: Array<{
|
|
504
|
+
fSessionId: SessionId;
|
|
505
|
+
fEventId: string;
|
|
506
|
+
fSequence: number;
|
|
507
|
+
fOriginalSeq: number;
|
|
508
|
+
fSurfaceOp: string | null;
|
|
509
|
+
}> = [];
|
|
510
|
+
for (const event of events) {
|
|
511
|
+
const reusedId = reuse?.get(event.seq);
|
|
512
|
+
const eventId = reusedId ?? randomUUID();
|
|
513
|
+
if (reusedId === undefined) {
|
|
514
|
+
const { kind, role, name, actionId } = eventDimensions(event);
|
|
515
|
+
eventRows.push({
|
|
516
|
+
fEventId: eventId,
|
|
517
|
+
fParentId: parentId,
|
|
518
|
+
fType: event.type,
|
|
519
|
+
fKind: kind,
|
|
520
|
+
fRole: role,
|
|
521
|
+
fName: name,
|
|
522
|
+
fActionId: actionId,
|
|
523
|
+
fEncoding: EVENT_ENCODING,
|
|
524
|
+
fData: JSON.stringify(event.data),
|
|
525
|
+
fCreatedAt: event.time,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
const surfaceOp =
|
|
529
|
+
(event as SessionEvent<SurfaceEventType>).surfaceOp === undefined
|
|
530
|
+
? null
|
|
531
|
+
: JSON.stringify((event as SessionEvent<SurfaceEventType>).surfaceOp);
|
|
532
|
+
bridgeRows.push({
|
|
533
|
+
fSessionId: meta.id,
|
|
534
|
+
fEventId: eventId,
|
|
535
|
+
fSequence: nextSeq,
|
|
536
|
+
fOriginalSeq: event.seq,
|
|
537
|
+
fSurfaceOp: surfaceOp,
|
|
538
|
+
});
|
|
539
|
+
parentId = eventId;
|
|
540
|
+
nextSeq++;
|
|
541
|
+
}
|
|
542
|
+
if (eventRows.length > 0) await tx.insertEvents(eventRows);
|
|
543
|
+
await tx.insertBridges(bridgeRows);
|
|
544
|
+
return { headEventId: parentId, headSequence: nextSeq - 1 };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export default SessionPersistenceRdb;
|
package/src/invariant.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* jscpd:ignore-start */
|
|
2
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
3
|
+
import type { InvariantInstaller } from "@deepseek-ai/dsh-invariants";
|
|
4
|
+
|
|
5
|
+
const PACKAGE_NAME = "@morlay/session-rdb";
|
|
6
|
+
|
|
7
|
+
export const name = "session-rdb-invariant";
|
|
8
|
+
|
|
9
|
+
export const inject = ["invariants"];
|
|
10
|
+
|
|
11
|
+
const install: InvariantInstaller = () => {};
|
|
12
|
+
|
|
13
|
+
export const apply = (ctx: Context): Promise<() => void> =>
|
|
14
|
+
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
15
|
+
/* jscpd:ignore-end */
|