@morlay/session-rdb 0.0.10 → 0.0.12

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/log.ts ADDED
@@ -0,0 +1,370 @@
1
+ import type { SessionEvent, SessionHeader, SessionId, SurfaceOp } from "@deepseek-ai/dsh-session";
2
+ import { SessionSeq, encodeSeqRanges, packChunkRuns } from "@deepseek-ai/dsh-session";
3
+ import type { SessionStorageMetadata } from "@deepseek-ai/dsh-session-persistence";
4
+ import type { EventRow, SessionRow } from "./backend.ts";
5
+
6
+ export function rowToMeta(row: SessionRow): SessionHeader {
7
+ if (!Number.isSafeInteger(row.fCreatedAt) || row.fCreatedAt < 0) {
8
+ throw new Error("stored session createdAt must be a non-negative safe integer");
9
+ }
10
+ return {
11
+ version: row.fVersion,
12
+ id: row.fSessionId as SessionId,
13
+ createdAt: row.fCreatedAt,
14
+ ...(row.fCwd !== null ? { cwd: row.fCwd } : {}),
15
+ ...(row.fParentSession !== null ? { parentSession: row.fParentSession as SessionId } : {}),
16
+ isSeeded: row.fSeedLength !== null,
17
+ ...(row.fOrigin !== null ? { origin: row.fOrigin as "subagent" } : {}),
18
+ ...(row.fDelegationDepth === null ? {} : { delegationDepth: row.fDelegationDepth }),
19
+ };
20
+ }
21
+
22
+ export function sessionInsertRow(
23
+ storage: SessionStorageMetadata,
24
+ incarnation: string,
25
+ ): {
26
+ fSessionId: string;
27
+ fHeadEventId: string;
28
+ fHeadSequence: number;
29
+ fVersion: number;
30
+ fCreatedAt: number;
31
+ fCwd: string | null;
32
+ fParentSession: string | null;
33
+ fSeedLength: number | null;
34
+ fOrigin: string | null;
35
+ fDelegationDepth: number | null;
36
+ fIncarnation: string;
37
+ fRevision: number;
38
+ } {
39
+ const meta = storage.meta;
40
+ return {
41
+ fSessionId: meta.id,
42
+ fHeadEventId: "",
43
+ fHeadSequence: -1,
44
+ fVersion: meta.version,
45
+ fCreatedAt: meta.createdAt,
46
+ fCwd: meta.cwd ?? null,
47
+ fParentSession: meta.parentSession ?? null,
48
+ fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
49
+ fOrigin: meta.origin ?? null,
50
+ fDelegationDepth: meta.delegationDepth ?? null,
51
+ fIncarnation: incarnation,
52
+ fRevision: 0,
53
+ };
54
+ }
55
+
56
+ export function sessionConflictRow(storage: SessionStorageMetadata): {
57
+ fVersion: number;
58
+ fCreatedAt: number;
59
+ fCwd: string | null;
60
+ fParentSession: string | null;
61
+ fSeedLength: number | null;
62
+ fOrigin: string | null;
63
+ fDelegationDepth: number | null;
64
+ } {
65
+ const meta = storage.meta;
66
+ return {
67
+ fVersion: meta.version,
68
+ fCreatedAt: meta.createdAt,
69
+ fCwd: meta.cwd ?? null,
70
+ fParentSession: meta.parentSession ?? null,
71
+ fSeedLength: meta.isSeeded ? storage.inheritedEventCount : null,
72
+ fOrigin: meta.origin ?? null,
73
+ fDelegationDepth: meta.delegationDepth ?? null,
74
+ };
75
+ }
76
+
77
+ export function remapSurfaceOp(op: SurfaceOp, remap: (seq: number) => number): SurfaceOp {
78
+ if (op === "append") return op;
79
+ return {
80
+ op: "replace",
81
+ start: SessionSeq(remap(op.start)),
82
+ end: SessionSeq(remap(op.end)),
83
+ };
84
+ }
85
+
86
+ export function remapShadowedRange(
87
+ range: { start: number; end: number },
88
+ remap: (seq: number) => number,
89
+ ): { start: number; end: number } {
90
+ return { start: remap(range.start), end: remap(range.end) };
91
+ }
92
+
93
+ export function rowToEvent(row: EventRow, seqMap: ReadonlyMap<number, number>): SessionEvent {
94
+ const remap = (seq: number): number => seqMap.get(seq) ?? seq;
95
+ const surfaceOp =
96
+ row.fSurfaceOp !== null
97
+ ? remapSurfaceOp(JSON.parse(row.fSurfaceOp) as SurfaceOp, remap)
98
+ : undefined;
99
+ const data = JSON.parse(row.fData) as SessionEvent["data"];
100
+ // compaction 事件的 shadowedRange 是插件合并字段,经结构化视图重映射。
101
+ if (row.fType === "compaction/summary" || row.fType === "compaction/prune") {
102
+ const metering = data as unknown as { shadowedRange?: { start: number; end: number } };
103
+ if (metering.shadowedRange !== undefined) {
104
+ metering.shadowedRange = remapShadowedRange(metering.shadowedRange, remap);
105
+ }
106
+ }
107
+ return {
108
+ type: row.fType as SessionEvent["type"],
109
+ seq: row.fSequence,
110
+ time: row.fCreatedAt,
111
+ data,
112
+ ...(surfaceOp === undefined ? {} : { surfaceOp }),
113
+ } as SessionEvent;
114
+ }
115
+
116
+ export function buildSeqMap(
117
+ rows: readonly Pick<EventRow, "fSequence" | "fOriginalSeq">[],
118
+ ): Map<number, number> {
119
+ const map = new Map<number, number>();
120
+ for (const row of rows) {
121
+ if (!map.has(row.fOriginalSeq)) map.set(row.fOriginalSeq, row.fSequence);
122
+ }
123
+ return map;
124
+ }
125
+
126
+ const SURFACE_EVENT_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
127
+
128
+ export function recomputeReplaceProvenance(events: SessionEvent[]): void {
129
+ for (const event of events) {
130
+ const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: number[] };
131
+ const op = raw.surfaceOp;
132
+ if (typeof op !== "object" || op === null || (op as { op?: string }).op !== "replace") {
133
+ continue;
134
+ }
135
+ const { start, end } = op as { start: number; end: number };
136
+ const refs: number[] = [];
137
+ for (const candidate of events) {
138
+ if (
139
+ candidate.seq >= start &&
140
+ candidate.seq <= end &&
141
+ SURFACE_EVENT_TYPES.has(candidate.type)
142
+ ) {
143
+ refs.push(candidate.seq);
144
+ }
145
+ }
146
+ raw.sourceEventSeqs = refs;
147
+ }
148
+ }
149
+
150
+ function isEventSeqLike(value: unknown): value is number {
151
+ return (
152
+ typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
153
+ );
154
+ }
155
+
156
+ function isDeepEqualJson(a: unknown, b: unknown): boolean {
157
+ if (a === b) return true;
158
+ if (Array.isArray(a) || Array.isArray(b)) {
159
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
160
+ return a.every((item, i) => isDeepEqualJson(item, b[i]));
161
+ }
162
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
163
+ const aKeys = Object.keys(a as Record<string, unknown>);
164
+ const bRecord = b as Record<string, unknown>;
165
+ if (aKeys.length !== Object.keys(bRecord).length) return false;
166
+ return aKeys.every(
167
+ (key) =>
168
+ Object.hasOwn(bRecord, key) &&
169
+ isDeepEqualJson((a as Record<string, unknown>)[key], bRecord[key]),
170
+ );
171
+ }
172
+
173
+ function toolResultRewriteContentOnly(original: SessionEvent, replacement: SessionEvent): boolean {
174
+ const originalData = original.data as Record<string, unknown>;
175
+ const replacementData = replacement.data as Record<string, unknown>;
176
+ const originalMessage = originalData["message"] as { content?: unknown } | undefined;
177
+ const replacementMessage = replacementData["message"] as { content?: unknown } | undefined;
178
+ const originalContent = Array.isArray(originalMessage?.content)
179
+ ? originalMessage.content
180
+ : undefined;
181
+ const replacementContent = Array.isArray(replacementMessage?.content)
182
+ ? replacementMessage.content
183
+ : undefined;
184
+ if (originalContent === undefined || replacementContent === undefined) return false;
185
+ const originalRest = {
186
+ ...originalData,
187
+ message: {
188
+ ...originalMessage,
189
+ content: [{ ...(originalContent[0] as Record<string, unknown>), content: null }],
190
+ },
191
+ };
192
+ const replacementRest = {
193
+ ...replacementData,
194
+ message: {
195
+ ...replacementMessage,
196
+ content: [{ ...(replacementContent[0] as Record<string, unknown>), content: null }],
197
+ },
198
+ };
199
+ return isDeepEqualJson(originalRest, replacementRest);
200
+ }
201
+
202
+ export function findSurfaceRepairs(events: readonly SessionEvent[]): {
203
+ degradeToAppend: Set<number>;
204
+ addAppendMarker: Set<number>;
205
+ clearSurfaceOp: Set<number>;
206
+ } {
207
+ const nodes: number[] = [];
208
+ const degradeToAppend = new Set<number>();
209
+ const addAppendMarker = new Set<number>();
210
+ const clearSurfaceOp = new Set<number>();
211
+ for (const event of events) {
212
+ const raw = event as SessionEvent & { surfaceOp?: unknown };
213
+ const op = raw.surfaceOp;
214
+ if (op === undefined) {
215
+ if (SURFACE_EVENT_TYPES.has(event.type)) {
216
+ addAppendMarker.add(event.seq);
217
+ nodes.push(event.seq);
218
+ }
219
+ continue;
220
+ }
221
+ if (op === "append") {
222
+ if (SURFACE_EVENT_TYPES.has(event.type)) nodes.push(event.seq);
223
+ else clearSurfaceOp.add(event.seq);
224
+ continue;
225
+ }
226
+ if (!SURFACE_EVENT_TYPES.has(event.type)) {
227
+ clearSurfaceOp.add(event.seq);
228
+ continue;
229
+ }
230
+ const replace =
231
+ typeof op === "object" && op !== null && !Array.isArray(op)
232
+ ? (op as Record<string, unknown>)
233
+ : undefined;
234
+ const start = replace?.["start"];
235
+ const end = replace?.["end"];
236
+ const shapeOk =
237
+ replace !== undefined &&
238
+ replace["op"] === "replace" &&
239
+ isEventSeqLike(start) &&
240
+ isEventSeqLike(end);
241
+ const startIdx = shapeOk ? nodes.indexOf(start as number) : -1;
242
+ const endIdx = shapeOk ? nodes.indexOf(end as number) : -1;
243
+ const rangeOk = shapeOk && startIdx !== -1 && endIdx !== -1 && startIdx <= endIdx;
244
+ let rewriteOk = true;
245
+ if (rangeOk && event.type === "tool/result") {
246
+ const shadowed = nodes.slice(startIdx, endIdx + 1);
247
+ if (shadowed.length !== 1) {
248
+ rewriteOk = false;
249
+ } else {
250
+ const original = events[shadowed[0]!];
251
+ rewriteOk =
252
+ original?.type === "tool/result" && toolResultRewriteContentOnly(original, event);
253
+ }
254
+ }
255
+ if (!rangeOk || !rewriteOk) {
256
+ degradeToAppend.add(event.seq);
257
+ nodes.push(event.seq);
258
+ continue;
259
+ }
260
+ nodes.splice(startIdx, endIdx - startIdx + 1, event.seq);
261
+ }
262
+ return { degradeToAppend, addAppendMarker, clearSurfaceOp };
263
+ }
264
+
265
+ export function repairSurfaceOps(events: SessionEvent[]): void {
266
+ const repairs = findSurfaceRepairs(events);
267
+ if (
268
+ repairs.degradeToAppend.size === 0 &&
269
+ repairs.addAppendMarker.size === 0 &&
270
+ repairs.clearSurfaceOp.size === 0
271
+ ) {
272
+ return;
273
+ }
274
+ for (const event of events) {
275
+ const raw = event as SessionEvent & { surfaceOp?: unknown };
276
+ if (repairs.degradeToAppend.has(event.seq)) {
277
+ raw.surfaceOp = "append";
278
+ } else if (repairs.addAppendMarker.has(event.seq)) {
279
+ raw.surfaceOp = "append";
280
+ } else if (repairs.clearSurfaceOp.has(event.seq)) {
281
+ delete raw.surfaceOp;
282
+ }
283
+ }
284
+ }
285
+
286
+ export function scanRows(
287
+ rows: readonly EventRow[],
288
+ base = 0,
289
+ seqMap: ReadonlyMap<number, number> = new Map(),
290
+ ): { preserved: SessionEvent[]; tornFrom?: number } {
291
+ // Pass 1:解析每行 data;JSON 非法的行是洞(seq/type 列即使在 data 损坏
292
+ // 时也存在)。
293
+ interface Parsed {
294
+ ok: boolean;
295
+ event?: SessionEvent;
296
+ }
297
+ const parsed: Parsed[] = rows.map((row) => {
298
+ try {
299
+ return { ok: true, event: rowToEvent(row, seqMap) };
300
+ } catch {
301
+ return { ok: false };
302
+ }
303
+ });
304
+
305
+ // 最后一个合法 turn/end 的索引——洞在闭合轮内一律是已提交损坏。
306
+ let lastTurnEnd = -1;
307
+ for (let i = parsed.length - 1; i >= 0; i--) {
308
+ if (parsed[i]?.ok && rows[i]?.fType === "turn/end") {
309
+ lastTurnEnd = i;
310
+ break;
311
+ }
312
+ }
313
+
314
+ // 保留连续前缀(含完整的中断轮);最后一个已提交边界之前的洞抛错,
315
+ // 之后的洞停止(torn tail)。
316
+ const preserved: SessionEvent[] = [];
317
+ for (let i = 0; i < rows.length; i++) {
318
+ const p = parsed[i];
319
+ if (!p?.ok || p.event === undefined) {
320
+ if (i <= lastTurnEnd)
321
+ throw new Error(
322
+ `corrupt session log: unparsable committed event at seq ${rows[i]?.fSequence}`,
323
+ );
324
+ break; // 最后一个 turn/end 之后的 torn tail 片段——停止、容忍
325
+ }
326
+ if (p.event.seq !== base + i) {
327
+ if (i <= lastTurnEnd)
328
+ throw new Error(
329
+ `corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`,
330
+ );
331
+ break; // 最后一个 turn/end 之后的 seq 空洞——torn tail,停止
332
+ }
333
+ preserved.push(p.event);
334
+ }
335
+
336
+ // 保留前缀之外的行是未提交的 torn tail;其首个 seq 是物理删除起点。
337
+ return preserved.length < rows.length
338
+ ? { preserved, tornFrom: base + preserved.length }
339
+ : { preserved };
340
+ }
341
+
342
+ function toStorageRecord(record: import("@deepseek-ai/dsh-session").StorageRecord): unknown {
343
+ const withSeqs = record as import("@deepseek-ai/dsh-session").StorageRecord & {
344
+ sourceEventSeqs?: unknown;
345
+ };
346
+ if (withSeqs.sourceEventSeqs === undefined) return record;
347
+ return { ...record, sourceEventSeqs: encodeSeqRanges(withSeqs.sourceEventSeqs as never) };
348
+ }
349
+
350
+ export function toJsonlArtifact(
351
+ meta: SessionHeader,
352
+ inheritedEventCount: number,
353
+ events: readonly SessionEvent[],
354
+ ): string {
355
+ const header = {
356
+ type: "session",
357
+ version: meta.version,
358
+ id: meta.id,
359
+ createdAt: meta.createdAt,
360
+ ...(meta.cwd === undefined ? {} : { cwd: meta.cwd }),
361
+ ...(meta.parentSession === undefined ? {} : { parentSession: meta.parentSession }),
362
+ ...(meta.isSeeded ? { seedLength: inheritedEventCount } : {}),
363
+ ...(meta.origin === undefined ? {} : { origin: meta.origin }),
364
+ delegationDepth: meta.delegationDepth ?? 0,
365
+ ...(meta.agentPreset === undefined ? {} : { agentPreset: meta.agentPreset }),
366
+ };
367
+ const lines = [JSON.stringify(header)];
368
+ for (const record of packChunkRuns(events)) lines.push(JSON.stringify(toStorageRecord(record)));
369
+ return lines.join("\n");
370
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { eventDimensions } from "./schema.ts";
3
+
4
+ export const SCHEMA_VERSION_V2 = 2;
5
+
6
+ export function migrateSqliteV1ToV2(db: DatabaseSync): number {
7
+ const { user_version: onDisk } = db.prepare("PRAGMA user_version").get() as {
8
+ user_version: number;
9
+ };
10
+ if (onDisk === SCHEMA_VERSION_V2) return 0;
11
+ if (onDisk !== 1) {
12
+ throw new Error(`unsupported schema version ${onDisk} (expected 1)`);
13
+ }
14
+
15
+ // 1. 建 v2 表(临时名)。
16
+ db.exec(`
17
+ CREATE TABLE t_events_v2 (
18
+ f_id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ f_event_id TEXT NOT NULL UNIQUE,
20
+ f_parent_id TEXT NOT NULL DEFAULT '',
21
+ f_type TEXT NOT NULL DEFAULT '',
22
+ f_kind TEXT NOT NULL DEFAULT '',
23
+ f_role TEXT NOT NULL DEFAULT '',
24
+ f_name TEXT NOT NULL DEFAULT '',
25
+ f_action_id TEXT NOT NULL DEFAULT '',
26
+ f_encoding TEXT NOT NULL DEFAULT '',
27
+ f_data TEXT NOT NULL,
28
+ f_created_at INTEGER NOT NULL DEFAULT 0
29
+ ) STRICT;
30
+ CREATE TABLE t_session_events_v2 (
31
+ f_id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ f_session_id TEXT NOT NULL REFERENCES t_sessions(f_session_id) ON DELETE CASCADE,
33
+ f_event_id TEXT NOT NULL REFERENCES t_events_v2(f_event_id) ON DELETE CASCADE,
34
+ f_sequence INTEGER NOT NULL,
35
+ f_original_seq INTEGER NOT NULL,
36
+ f_surface_op TEXT,
37
+ UNIQUE (f_session_id, f_sequence)
38
+ ) STRICT;
39
+ CREATE INDEX idx_events_v2_kind ON t_events_v2(f_kind);
40
+ CREATE INDEX idx_events_v2_role ON t_events_v2(f_role);
41
+ CREATE INDEX idx_events_v2_name ON t_events_v2(f_name);
42
+ CREATE INDEX idx_events_v2_action_id ON t_events_v2(f_action_id);
43
+ CREATE INDEX idx_session_events_v2_event_id ON t_session_events_v2(f_event_id);
44
+ `);
45
+
46
+ // 2. 搬运 t_events:f_type = 旧 f_kind,维度列重算。
47
+ const oldEvents = db
48
+ .prepare(
49
+ `SELECT f_event_id, f_parent_id, f_kind, f_data, f_created_at FROM t_events ORDER BY f_id`,
50
+ )
51
+ .all() as Array<{
52
+ f_event_id: string;
53
+ f_parent_id: string;
54
+ f_kind: string;
55
+ f_data: string;
56
+ f_created_at: number;
57
+ }>;
58
+ const insertEvent = db.prepare(`
59
+ INSERT INTO t_events_v2
60
+ (f_event_id, f_parent_id, f_type, f_kind, f_role, f_name, f_action_id, f_encoding, f_data, f_created_at)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'json', ?, ?)
62
+ `);
63
+ for (const row of oldEvents) {
64
+ let data: unknown;
65
+ try {
66
+ data = JSON.parse(row.f_data);
67
+ } catch {
68
+ throw new Error(`t_events row ${row.f_event_id} has unparsable f_data; aborting`);
69
+ }
70
+ const dims = eventDimensions({
71
+ type: row.f_kind,
72
+ seq: 0,
73
+ time: row.f_created_at,
74
+ data,
75
+ } as never);
76
+ insertEvent.run(
77
+ row.f_event_id,
78
+ row.f_parent_id,
79
+ row.f_kind,
80
+ dims.kind,
81
+ dims.role,
82
+ dims.name,
83
+ dims.actionId,
84
+ row.f_data,
85
+ row.f_created_at,
86
+ );
87
+ }
88
+
89
+ // 3. 搬运 t_session_events:f_original_seq / f_surface_op 从旧 t_events 取。
90
+ const oldBridges = db
91
+ .prepare(
92
+ `SELECT se.f_session_id, se.f_event_id, se.f_sequence
93
+ FROM t_session_events se ORDER BY se.f_id`,
94
+ )
95
+ .all() as Array<{ f_session_id: string; f_event_id: string; f_sequence: number }>;
96
+ const oldEventMeta = new Map(
97
+ (
98
+ db.prepare(`SELECT f_event_id, f_original_seq, f_surface_op FROM t_events`).all() as Array<{
99
+ f_event_id: string;
100
+ f_original_seq: number;
101
+ f_surface_op: string | null;
102
+ }>
103
+ ).map((row) => [row.f_event_id, row]),
104
+ );
105
+ const insertBridge = db.prepare(`
106
+ INSERT INTO t_session_events_v2
107
+ (f_session_id, f_event_id, f_sequence, f_original_seq, f_surface_op)
108
+ VALUES (?, ?, ?, ?, ?)
109
+ `);
110
+ for (const row of oldBridges) {
111
+ const meta = oldEventMeta.get(row.f_event_id);
112
+ if (meta === undefined) {
113
+ throw new Error(
114
+ `bridge row ${row.f_session_id}:${row.f_sequence} references missing event ${row.f_event_id}`,
115
+ );
116
+ }
117
+ insertBridge.run(
118
+ row.f_session_id,
119
+ row.f_event_id,
120
+ row.f_sequence,
121
+ meta.f_original_seq,
122
+ meta.f_surface_op,
123
+ );
124
+ }
125
+
126
+ // 4. 删旧表、重命名新表。
127
+ db.exec(`
128
+ DROP TABLE t_session_events;
129
+ DROP TABLE t_events;
130
+ ALTER TABLE t_events_v2 RENAME TO t_events;
131
+ ALTER TABLE t_session_events_v2 RENAME TO t_session_events;
132
+ `);
133
+
134
+ // 5. user_version = 2。
135
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION_V2}`);
136
+ return oldEvents.length;
137
+ }