@morlay/session-rdb 0.0.11 → 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/README.md +0 -16
- package/dist/index.d.mts +203 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2030 -0
- package/dist/index.mjs.map +1 -0
- package/dist/invariant.d.mts +8 -0
- package/dist/invariant.d.mts.map +1 -0
- package/dist/invariant.mjs +10 -0
- package/dist/invariant.mjs.map +1 -0
- package/package.json +22 -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/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 +543 -0
- package/src/invariant.ts +15 -0
- package/src/log.ts +370 -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/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/lib/index.mjs
DELETED
|
@@ -1,2176 +0,0 @@
|
|
|
1
|
-
import "@deepseek-ai/cordis";
|
|
2
|
-
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { Pool } from "pg";
|
|
6
|
-
import { drizzle } from "drizzle-orm/node-postgres";
|
|
7
|
-
import { PersistenceCoordinator, SessionPersistence, SessionPersistenceRevision } from "@deepseek-ai/dsh-session-persistence";
|
|
8
|
-
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
|
9
|
-
import { check, index, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
|
10
|
-
import { bigint, check as check$1, index as index$1, integer as integer$1, pgTable, serial, text as text$1, unique as unique$1 } from "drizzle-orm/pg-core";
|
|
11
|
-
import { statSync } from "node:fs";
|
|
12
|
-
import { mkdir, open } from "node:fs/promises";
|
|
13
|
-
import { dirname, resolve } from "node:path";
|
|
14
|
-
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import { drizzle as drizzle$1 } from "drizzle-orm/node-sqlite";
|
|
16
|
-
import { SESSION_FORMAT_VERSION } from "@deepseek-ai/dsh-session";
|
|
17
|
-
import { SessionBranch, SessionBranchError, balanceRewindPrefix, buildTimeline } from "@morlay/session-branch";
|
|
18
|
-
//#region src/log.ts
|
|
19
|
-
/**
|
|
20
|
-
* Reconstruct the {@link SessionHeader} from a `t_sessions` row.
|
|
21
|
-
* @param row - the `t_sessions` table row.
|
|
22
|
-
* @returns the header, `NULL` columns mapped to omitted optional fields.
|
|
23
|
-
*/
|
|
24
|
-
function rowToMeta(row) {
|
|
25
|
-
if (!Number.isSafeInteger(row.fCreatedAt) || row.fCreatedAt < 0) throw new Error("stored session createdAt must be a non-negative safe integer");
|
|
26
|
-
return {
|
|
27
|
-
version: row.fVersion,
|
|
28
|
-
id: row.fSessionId,
|
|
29
|
-
createdAt: row.fCreatedAt,
|
|
30
|
-
...row.fCwd !== null ? { cwd: row.fCwd } : {},
|
|
31
|
-
...row.fParentSession !== null ? { parentSession: row.fParentSession } : {},
|
|
32
|
-
...row.fSeedLength !== null ? { seedLength: row.fSeedLength } : {},
|
|
33
|
-
...row.fOrigin !== null ? { origin: row.fOrigin } : {},
|
|
34
|
-
...row.fDelegationDepth === null ? {} : { delegationDepth: row.fDelegationDepth }
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* `t_sessions` 的 INSERT 列值:`SessionHeader` 的持久化字段 + 初始 head 游标
|
|
39
|
-
* (空事件 id、seq -1)+ materialization identity(`f_incarnation`)+ revision 0。
|
|
40
|
-
* 方言无关的纯映射——SQLite 与 PostgreSQL 后端的 `upsertSession` 共用,列名
|
|
41
|
-
* 与 `src/entities/sessions.ts` 对齐(改一处即两方言生效)。`f_id` serial 由
|
|
42
|
-
* 数据库生成,不在映射内。
|
|
43
|
-
*/
|
|
44
|
-
function sessionInsertRow(meta, incarnation) {
|
|
45
|
-
return {
|
|
46
|
-
fSessionId: meta.id,
|
|
47
|
-
fHeadEventId: "",
|
|
48
|
-
fHeadSequence: -1,
|
|
49
|
-
fVersion: meta.version,
|
|
50
|
-
fCreatedAt: meta.createdAt,
|
|
51
|
-
fCwd: meta.cwd ?? null,
|
|
52
|
-
fParentSession: meta.parentSession ?? null,
|
|
53
|
-
fSeedLength: meta.seedLength ?? null,
|
|
54
|
-
fOrigin: meta.origin ?? null,
|
|
55
|
-
fDelegationDepth: meta.delegationDepth ?? null,
|
|
56
|
-
fIncarnation: incarnation,
|
|
57
|
-
fRevision: 0
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* `t_sessions` 的 ON CONFLICT 更新列值:只刷新 header 列,保留 head 游标
|
|
62
|
-
* (`f_head_event_id`/`f_head_sequence`)与 materialization identity
|
|
63
|
-
* (`f_incarnation`/`f_revision`)。方言无关,两后端共用(见
|
|
64
|
-
* {@link sessionInsertRow})。
|
|
65
|
-
*/
|
|
66
|
-
function sessionConflictRow(meta) {
|
|
67
|
-
return {
|
|
68
|
-
fVersion: meta.version,
|
|
69
|
-
fCreatedAt: meta.createdAt,
|
|
70
|
-
fCwd: meta.cwd ?? null,
|
|
71
|
-
fParentSession: meta.parentSession ?? null,
|
|
72
|
-
fSeedLength: meta.seedLength ?? null,
|
|
73
|
-
fOrigin: meta.origin ?? null,
|
|
74
|
-
fDelegationDepth: meta.delegationDepth ?? null
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Remap a stored {@link SurfaceOp} from upstream seqs to persisted seqs. An
|
|
79
|
-
* `append` op carries no seqs; a positional `replace`'s `start`/`end` name
|
|
80
|
-
* surface nodes by UPSTREAM seq and must follow {@link SessionEvent.sourceEventSeqs}
|
|
81
|
-
* through the same upstream→persisted map when delta filtering re-numbered the
|
|
82
|
-
* log — otherwise the replacement range is looked up against DENSE seqs and the
|
|
83
|
-
* surface fold rejects the log ("start seq N not found in surface").
|
|
84
|
-
* @param op - the stored surface op.
|
|
85
|
-
* @param remap - upstream→persisted seq mapping (identity when absent).
|
|
86
|
-
* @returns the remapped surface op.
|
|
87
|
-
*/
|
|
88
|
-
function remapSurfaceOp(op, remap) {
|
|
89
|
-
if (op === "append") return op;
|
|
90
|
-
return {
|
|
91
|
-
op: "replace",
|
|
92
|
-
start: remap(op.start),
|
|
93
|
-
end: remap(op.end)
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* The compact metering events (`compaction/summary`, `compaction/prune`) carry the
|
|
98
|
-
* token-meter's shadow-price claim in `data.shadowedRange`: the inclusive
|
|
99
|
-
* surface-node seqs of the range the IMMEDIATELY following surface `replace`
|
|
100
|
-
* shadows. The range names surface nodes by UPSTREAM seq, so it must follow
|
|
101
|
-
* the replace's `surfaceOp` through the same upstream→persisted map — the
|
|
102
|
-
* fold compares claim and replacement ranges for exact equality, and an
|
|
103
|
-
* un-remapped claim (upstream) next to a remapped replacement range (dense)
|
|
104
|
-
* makes replay fail loud ("token surface: replace ... has no adjacent shadow
|
|
105
|
-
* price").
|
|
106
|
-
* @param range - the stored shadowed range (upstream seqs).
|
|
107
|
-
* @param remap - upstream→persisted seq mapping (identity when absent).
|
|
108
|
-
* @returns the remapped shadowed range.
|
|
109
|
-
*/
|
|
110
|
-
function remapShadowedRange(range, remap) {
|
|
111
|
-
return {
|
|
112
|
-
start: remap(range.start),
|
|
113
|
-
end: remap(range.end)
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Reconstruct a {@link SessionEvent} from a joined row. The emitted event
|
|
118
|
-
* carries the DENSE persisted seq (`row.fSequence`); `sourceEventSeqs` entries,
|
|
119
|
-
* a positional `replace` {@link SurfaceOp}'s range, and the compact metering
|
|
120
|
-
* events' `shadowedRange` are resolved onto persisted seqs through `seqMap`
|
|
121
|
-
* when the log was delta-filtered.
|
|
122
|
-
*
|
|
123
|
-
* Coordinate resolution is "the stored f_sequence wins, when it is a valid
|
|
124
|
-
* earlier reference": after resume the upstream live log IS the dense log,
|
|
125
|
-
* so events written since (checkpoint replaces, provenance) cite DENSE seqs.
|
|
126
|
-
* A cited seq that exists in the dense space, precedes this event, and is
|
|
127
|
-
* either a SURFACE node or has no upstream counterpart is kept as-is. A seq
|
|
128
|
-
* outside the dense space (or a dense seq that is a non-surface event with an
|
|
129
|
-
* upstream counterpart — an upstream-cited seq that happens to collide with a
|
|
130
|
-
* later dense seq) is treated as an UPSTREAM reference and remapped through
|
|
131
|
-
* the upstream→persisted map, again only when the result precedes this event.
|
|
132
|
-
* Anything else is unresolvable and dropped (never kept verbatim — after
|
|
133
|
-
* rewind re-uses the upstream space, keeping an upstream value would mix
|
|
134
|
-
* coordinates and fail the Session seed validation).
|
|
135
|
-
* @param row - the joined `t_session_events` + `t_events` row.
|
|
136
|
-
* @param seqMap - upstream→persisted seq map (optional).
|
|
137
|
-
* @param denseTypeMap - every persisted f_sequence → event type for the
|
|
138
|
-
* session (optional; always passed together with `seqMap`).
|
|
139
|
-
* @returns the reconstructed event; throws when a JSON column fails to parse
|
|
140
|
-
* ({@link scanRows} treats that as a hole, not corruption, in the tail).
|
|
141
|
-
*/
|
|
142
|
-
function rowToEvent(row, seqMap, denseTypeMap) {
|
|
143
|
-
const remap = (seq) => resolveProvenanceSeq(seq, row.fSequence, seqMap, denseTypeMap) ?? seq;
|
|
144
|
-
const strictRemap = (seq) => resolveProvenanceSeq(seq, row.fSequence, seqMap, denseTypeMap);
|
|
145
|
-
const surfaceFields = {
|
|
146
|
-
...row.fSourceEventSeqs !== null ? (() => {
|
|
147
|
-
const refs = remapProvenance(JSON.parse(row.fSourceEventSeqs), strictRemap);
|
|
148
|
-
return refs.length > 0 ? { sourceEventSeqs: refs } : {};
|
|
149
|
-
})() : {},
|
|
150
|
-
...row.fSurfaceOp !== null ? { surfaceOp: remapSurfaceOp(JSON.parse(row.fSurfaceOp), remap) } : {}
|
|
151
|
-
};
|
|
152
|
-
const data = JSON.parse(row.fData);
|
|
153
|
-
if (row.fKind === "compaction/summary" || row.fKind === "compaction/prune") {
|
|
154
|
-
const metering = data;
|
|
155
|
-
if (metering.shadowedRange !== void 0) metering.shadowedRange = remapShadowedRange(metering.shadowedRange, remap);
|
|
156
|
-
}
|
|
157
|
-
return {
|
|
158
|
-
type: row.fKind,
|
|
159
|
-
seq: row.fSequence,
|
|
160
|
-
time: row.fCreatedAt,
|
|
161
|
-
data,
|
|
162
|
-
...surfaceFields
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Resolve one cited seq onto the DENSE persisted seq space.
|
|
167
|
-
*
|
|
168
|
-
* "The stored f_sequence wins, when it is a valid earlier reference": after
|
|
169
|
-
* resume the upstream live log IS the dense log, so events written since
|
|
170
|
-
* (checkpoint replaces, provenance) cite DENSE seqs. A cited seq that exists
|
|
171
|
-
* in the dense space, precedes `eventSeq`, and is either a SURFACE node or has
|
|
172
|
-
* no upstream counterpart is kept as-is. A seq outside the dense space (or a
|
|
173
|
-
* dense seq that is a non-surface event with an upstream counterpart — an
|
|
174
|
-
* upstream-cited seq that happens to collide with a later dense seq) is
|
|
175
|
-
* treated as an UPSTREAM reference and remapped through the upstream→persisted
|
|
176
|
-
* map, again only when the result precedes `eventSeq`. Anything else is
|
|
177
|
-
* unresolvable (`undefined`): provenance entries are dropped rather than kept
|
|
178
|
-
* verbatim, because after rewind re-uses the upstream space an upstream value
|
|
179
|
-
* would mix coordinates and fail the Session seed validation.
|
|
180
|
-
* @param seq - the cited seq (upstream or dense).
|
|
181
|
-
* @param eventSeq - the citing event's dense seq.
|
|
182
|
-
* @param seqMap - upstream→persisted seq map (optional).
|
|
183
|
-
* @param denseTypeMap - every persisted f_sequence → event type (optional;
|
|
184
|
-
* always passed together with `seqMap`).
|
|
185
|
-
* @returns the dense seq to cite, or undefined when unresolvable.
|
|
186
|
-
*/
|
|
187
|
-
function resolveProvenanceSeq(seq, eventSeq, seqMap, denseTypeMap) {
|
|
188
|
-
if (seqMap === void 0 && denseTypeMap === void 0) return seq;
|
|
189
|
-
const kind = denseTypeMap?.get(seq);
|
|
190
|
-
if (kind !== void 0 && seq < eventSeq && (SURFACE_EVENT_TYPES.has(kind) || !seqMap?.has(seq))) return seq;
|
|
191
|
-
const dense = seqMap?.get(seq);
|
|
192
|
-
if (dense !== void 0 && dense < eventSeq) return dense;
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Build the upstream→persisted seq map for one session's persisted events
|
|
196
|
-
* (only meaningful when delta filtering re-numbered the log).
|
|
197
|
-
*
|
|
198
|
-
* A session re-opened by resume (or forked) persists the seed segment and the
|
|
199
|
-
* new segment in ONE log: the seed rows keep the PARENT session's upstream seqs
|
|
200
|
-
* while the resumed rows carry the child session's own upstream seqs, which
|
|
201
|
-
* renumber from the seed boundary and therefore OVERLAP the parent space. The
|
|
202
|
-
* FIRST mapping wins so a seed-segment event's provenance reference resolves to
|
|
203
|
-
* the seed-space row it actually derived from (rows are ordered by persisted
|
|
204
|
-
* seq, so the seed segment always precedes the resumed one); the resumed
|
|
205
|
-
* segment's references are unique within their own space unless they point at a
|
|
206
|
-
* shared value, which only the parent could have produced first.
|
|
207
|
-
* @param rows - one session's seq rows (upstream + persisted), ordered by seq
|
|
208
|
-
* ascending (only the two seq columns are needed).
|
|
209
|
-
* @returns map from `f_original_seq` to `f_sequence` (first occurrence wins).
|
|
210
|
-
*/
|
|
211
|
-
function buildSeqMap(rows) {
|
|
212
|
-
const map = /* @__PURE__ */ new Map();
|
|
213
|
-
for (const row of rows) if (!map.has(row.fOriginalSeq)) map.set(row.fOriginalSeq, row.fSequence);
|
|
214
|
-
return map;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* Normalize a stored provenance list into the persisted seq space.
|
|
218
|
-
*
|
|
219
|
-
* `sourceEventSeqs` references events by UPSTREAM seq; on read each entry is
|
|
220
|
-
* remapped to the DENSE persisted seq through the upstream→persisted map. An
|
|
221
|
-
* entry whose event never got a persisted row (a dropped delta) or no longer
|
|
222
|
-
* has one cannot be remapped. Keeping such an entry verbatim would mix
|
|
223
|
-
* upstream and dense coordinates in one list — after rewind re-uses the
|
|
224
|
-
* upstream seq space, a stale reference can even collide with a live dense
|
|
225
|
-
* seq — which the upstream Session seed validation rejects (duplicates /
|
|
226
|
-
* non-monotonic / "must reference earlier events"). Unmappable entries are
|
|
227
|
-
* dropped. Because the upstream space is reused, the remapped entries can
|
|
228
|
-
* also arrive out of order or duplicated; the result is sorted and
|
|
229
|
-
* de-duplicated so it is strictly increasing, as the upstream provenance
|
|
230
|
-
* contract requires. The output's SET (the only thing surface-replace
|
|
231
|
-
* shadowing checks) is unchanged by normalization.
|
|
232
|
-
* @param refs - the event's `sourceEventSeqs` (upstream seqs).
|
|
233
|
-
* @param remap - upstream→persisted seq mapping; undefined for an entry
|
|
234
|
-
* whose event has no persisted row.
|
|
235
|
-
* @returns the normalized, strictly increasing persisted seqs (possibly empty).
|
|
236
|
-
*/
|
|
237
|
-
function remapProvenance(refs, remap) {
|
|
238
|
-
const mapped = [];
|
|
239
|
-
for (const ref of refs) {
|
|
240
|
-
const dense = remap(ref);
|
|
241
|
-
if (dense !== void 0) mapped.push(dense);
|
|
242
|
-
}
|
|
243
|
-
mapped.sort((a, b) => a - b);
|
|
244
|
-
const result = [];
|
|
245
|
-
for (const seq of mapped) if (result.length === 0 || result[result.length - 1] !== seq) result.push(seq);
|
|
246
|
-
return result;
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* The event types that produce model-visible surface nodes (mirror of the
|
|
250
|
-
* upstream `SURFACE_EVENT_TYPES`); only these can appear in a replace's
|
|
251
|
-
* shadowed range, so only their seqs may be merged into provenance.
|
|
252
|
-
*/
|
|
253
|
-
const SURFACE_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
254
|
-
"user/message",
|
|
255
|
-
"assistant/message",
|
|
256
|
-
"tool/result"
|
|
257
|
-
]);
|
|
258
|
-
/**
|
|
259
|
-
* Make a surface `replace` event's provenance cover its replacement range.
|
|
260
|
-
*
|
|
261
|
-
* The upstream Session seed validation requires a replace's `sourceEventSeqs`
|
|
262
|
-
* to include every surface node it shadows (`assertProvenance`'s shadowed
|
|
263
|
-
* check). Rewind re-uses the upstream seq space: a replace that survives a
|
|
264
|
-
* truncation (e.g. a checkpoint `user/message` before the new boundary) has
|
|
265
|
-
* its range remapped onto DENSE seqs, while its provenance references were
|
|
266
|
-
* written in upstream seqs — references to rows rewind deleted are dropped on
|
|
267
|
-
* read, so the surviving provenance no longer covers the range's dense nodes.
|
|
268
|
-
* Merging every persisted SURFACE node seq inside the (dense) range into the
|
|
269
|
-
* provenance makes the log loadable again; extra references are legal
|
|
270
|
-
* provenance and never affect the fold (the fold consumes only the surfaceOp
|
|
271
|
-
* range). Only events the map could resolve exist in the dense range, so the
|
|
272
|
-
* merged list is strictly increasing after sort/dedup.
|
|
273
|
-
* @param events - a session's dense event list (contiguous seqs), read in
|
|
274
|
-
* full; mutated in place.
|
|
275
|
-
*/
|
|
276
|
-
function normalizeSurfaceReplaceProvenance(events) {
|
|
277
|
-
for (const event of events) {
|
|
278
|
-
const raw = event;
|
|
279
|
-
const op = raw.surfaceOp;
|
|
280
|
-
if (typeof op !== "object" || op === null || op.op !== "replace") continue;
|
|
281
|
-
const { start, end } = op;
|
|
282
|
-
const refs = new Set(raw.sourceEventSeqs ?? []);
|
|
283
|
-
for (const candidate of events) if (candidate.seq >= start && candidate.seq <= end && SURFACE_EVENT_TYPES.has(candidate.type)) refs.add(candidate.seq);
|
|
284
|
-
raw.sourceEventSeqs = [...refs].sort((a, b) => a - b);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
/**
|
|
288
|
-
* Prune `sourceEventSeqs` references that cannot be remapped on read.
|
|
289
|
-
*
|
|
290
|
-
* `sourceEventSeqs` references events by UPSTREAM seq; on read the references
|
|
291
|
-
* are remapped to the DENSE persisted seq through {@link buildSeqMap}. A
|
|
292
|
-
* reference whose event never got a persisted row (a dropped delta, or a seq
|
|
293
|
-
* that never existed) has no map entry and would replay as a `source >=
|
|
294
|
-
* current seq` provenance violation, so the write path prunes it. A fully
|
|
295
|
-
* pruned list is stored as null (no provenance) by the serializer.
|
|
296
|
-
*
|
|
297
|
-
* The `keep` predicate is the CALLER's view of resolvability: the write path
|
|
298
|
-
* knows which upstream seqs THIS INSTANCE dropped (`WriteGuard.pruneRefs` —
|
|
299
|
-
* per-instance knowledge, which must not prune references to rows another
|
|
300
|
-
* instance persisted, e.g. a resume seed segment), while the one-shot repair
|
|
301
|
-
* script knows which upstream seqs exist on DISK (full-database view). The
|
|
302
|
-
* filter itself is shared so the rule lives in one place.
|
|
303
|
-
* @param refs - the event's `sourceEventSeqs` (upstream seqs).
|
|
304
|
-
* @param keep - true for a seq whose referenced event is resolvable.
|
|
305
|
-
* @returns the pruned list.
|
|
306
|
-
*/
|
|
307
|
-
function pruneSourceEventSeqs(refs, keep) {
|
|
308
|
-
return refs.filter(keep);
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Find the preserved prefix of ordered event rows. Fully written rows in an
|
|
312
|
-
* interrupted final turn remain in the prefix. The first unparsable row or seq
|
|
313
|
-
* gap after the last `turn/end` marks a tolerated torn tail; the same hole in
|
|
314
|
-
* the committed region rejects.
|
|
315
|
-
*
|
|
316
|
-
* @param rows - one session's event rows, ordered by persisted seq ascending.
|
|
317
|
-
* @param base - the persisted seq the first row is expected to carry; `0` for
|
|
318
|
-
* a whole log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
|
|
319
|
-
* @param seqMap - upstream→persisted seq map forwarded to {@link rowToEvent}.
|
|
320
|
-
* @param denseTypeMap - every persisted f_sequence → type, forwarded to
|
|
321
|
-
* {@link rowToEvent} (kept together with `seqMap`).
|
|
322
|
-
* @returns the preserved event prefix, plus `tornFrom` — the persisted seq the
|
|
323
|
-
* physical delete starts at — when a torn tail exists.
|
|
324
|
-
*/
|
|
325
|
-
function scanRows(rows, base = 0, seqMap, denseTypeMap) {
|
|
326
|
-
const parsed = rows.map((row) => {
|
|
327
|
-
try {
|
|
328
|
-
return {
|
|
329
|
-
ok: true,
|
|
330
|
-
event: rowToEvent(row, seqMap, denseTypeMap)
|
|
331
|
-
};
|
|
332
|
-
} catch {
|
|
333
|
-
return { ok: false };
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
let lastTurnEnd = -1;
|
|
337
|
-
for (let i = parsed.length - 1; i >= 0; i--) if (parsed[i]?.ok && rows[i]?.fKind === "turn/end") {
|
|
338
|
-
lastTurnEnd = i;
|
|
339
|
-
break;
|
|
340
|
-
}
|
|
341
|
-
const preserved = [];
|
|
342
|
-
for (let i = 0; i < rows.length; i++) {
|
|
343
|
-
const p = parsed[i];
|
|
344
|
-
if (!p?.ok || p.event === void 0) {
|
|
345
|
-
if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.fSequence}`);
|
|
346
|
-
break;
|
|
347
|
-
}
|
|
348
|
-
if (p.event.seq !== base + i) {
|
|
349
|
-
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`);
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
preserved.push(p.event);
|
|
353
|
-
}
|
|
354
|
-
return preserved.length < rows.length ? {
|
|
355
|
-
preserved,
|
|
356
|
-
tornFrom: base + preserved.length
|
|
357
|
-
} : { preserved };
|
|
358
|
-
}
|
|
359
|
-
//#endregion
|
|
360
|
-
//#region src/write-guard.ts
|
|
361
|
-
/**
|
|
362
|
-
* The write-authority state machine for one backend instance. Not part of the
|
|
363
|
-
* {@link Backend} seam: it guards the orchestration layer's own invariants and
|
|
364
|
-
* lives entirely in memory.
|
|
365
|
-
*/
|
|
366
|
-
var WriteGuard = class {
|
|
367
|
-
/**
|
|
368
|
-
* Last CONFIRMED dense head per session — the head this instance itself
|
|
369
|
-
* wrote or observed via `loadStored`. `-1` records a confirmed absence (no
|
|
370
|
-
* row). `undefined` (absent from the map) means this instance never read or
|
|
371
|
-
* wrote the session.
|
|
372
|
-
*/
|
|
373
|
-
headSeqs = /* @__PURE__ */ new Map();
|
|
374
|
-
/**
|
|
375
|
-
* Upstream seqs of delta events dropped per session. Mirrors `headSeqs` in
|
|
376
|
-
* shape: the concurrent-writer guarantee limits each session to one writer,
|
|
377
|
-
* so this instance is the only authority for its dropped seqs.
|
|
378
|
-
*/
|
|
379
|
-
filteredSeqs = /* @__PURE__ */ new Map();
|
|
380
|
-
/**
|
|
381
|
-
* Record a head this instance actually observed or wrote.
|
|
382
|
-
* @param id - the session id.
|
|
383
|
-
* @param head - the confirmed dense head, or `-1` for a confirmed absence
|
|
384
|
-
* (a fresh session this instance has read about — a later append to a
|
|
385
|
-
* session that meanwhile got a row must reject).
|
|
386
|
-
*/
|
|
387
|
-
confirmHead(id, head) {
|
|
388
|
-
this.headSeqs.set(id, head);
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Fail loud when the on-disk head no longer matches this instance's last
|
|
392
|
-
* confirmed head for the session. `undefined` (never read/written here) is
|
|
393
|
-
* only acceptable for a session with NO row: a row written by someone else
|
|
394
|
-
* means this instance's coordinator cursor is not the log's authority.
|
|
395
|
-
* @param id - the session id.
|
|
396
|
-
* @param storedHead - the on-disk head cursor, read inside the append
|
|
397
|
-
* transaction before any re-numbering happens.
|
|
398
|
-
*/
|
|
399
|
-
assertNoConcurrentWriter(id, storedHead) {
|
|
400
|
-
const known = this.headSeqs.get(id);
|
|
401
|
-
if (known === void 0) {
|
|
402
|
-
if (storedHead !== -1) throw new Error(`session "${id}" has a persisted log this instance has not read; another writer may own it — load the session first`);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
if (known !== storedHead) throw new Error(`session "${id}" was modified by another writer (stored head ${storedHead}, this instance last confirmed head ${known}); concurrent writers on one session are not supported`);
|
|
406
|
-
}
|
|
407
|
-
/**
|
|
408
|
-
* Record the upstream seqs of events dropped for a session (delta events and
|
|
409
|
-
* ignorable events), so a later batch's `assistant/message` can prune
|
|
410
|
-
* `sourceEventSeqs` references to events that never got a persisted row.
|
|
411
|
-
* @param id - the session id.
|
|
412
|
-
* @param seqs - the dropped events' upstream seqs (pure-delta batches included).
|
|
413
|
-
*/
|
|
414
|
-
noteDropped(id, seqs) {
|
|
415
|
-
const known = this.filteredSeqs.get(id) ?? /* @__PURE__ */ new Set();
|
|
416
|
-
for (const seq of seqs) known.add(seq);
|
|
417
|
-
this.filteredSeqs.set(id, known);
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Prune `sourceEventSeqs` references that hit this session's dropped-delta
|
|
421
|
-
* seq set. `undefined`-like state (no dropped seqs recorded for the session)
|
|
422
|
-
* leaves the list untouched, matching the write path's "no known drops →
|
|
423
|
-
* keep verbatim" semantics (repair closers, which never carry provenance,
|
|
424
|
-
* call through the identity path).
|
|
425
|
-
*
|
|
426
|
-
* The predicate is THIS INSTANCE's view (see
|
|
427
|
-
* {@link pruneSourceEventSeqs}): only seqs it knows were dropped are
|
|
428
|
-
* pruned — references to rows persisted by another instance (e.g. a resume
|
|
429
|
-
* seed segment) must survive, so the disk-wide view used by the one-shot
|
|
430
|
-
* repair script is not applicable here.
|
|
431
|
-
* @param id - the session id.
|
|
432
|
-
* @param refs - the event's `sourceEventSeqs` (upstream seqs).
|
|
433
|
-
* @returns the pruned list; identical content when nothing was dropped.
|
|
434
|
-
*/
|
|
435
|
-
pruneRefs(id, refs) {
|
|
436
|
-
const dropped = this.filteredSeqs.get(id);
|
|
437
|
-
if (dropped === void 0 || dropped.size === 0) return [...refs];
|
|
438
|
-
return pruneSourceEventSeqs(refs, (seq) => !dropped.has(seq));
|
|
439
|
-
}
|
|
440
|
-
};
|
|
441
|
-
//#endregion
|
|
442
|
-
//#region src/entities/types.ts
|
|
443
|
-
/** 把物理列名转成 drizzle 表对象的属性名(`f_session_id` → `fSessionId`)。 */
|
|
444
|
-
function toProperty(name) {
|
|
445
|
-
return name.replace(/_([a-z])/g, (_match, char) => char.toUpperCase());
|
|
446
|
-
}
|
|
447
|
-
//#endregion
|
|
448
|
-
//#region src/adapters/to-sqlite.ts
|
|
449
|
-
/**
|
|
450
|
-
* 从方言无关的表描述生成 SQLite 的 drizzle `sqliteTable` 对象。查询的类型
|
|
451
|
-
* 安全由调用方的手写行接口(`backend.ts` 的 `SessionRow` / `EventRow` /
|
|
452
|
-
* `EventInsert`)与显式投影兜底——drizzle 无法从运行时构建的列映射保留
|
|
453
|
-
* 精确的列类型。
|
|
454
|
-
*
|
|
455
|
-
* @module @morlay/session-rdb/entities/to-sqlite
|
|
456
|
-
*/
|
|
457
|
-
function buildColumn$1(c, tables) {
|
|
458
|
-
let col;
|
|
459
|
-
switch (c.type) {
|
|
460
|
-
case "text":
|
|
461
|
-
col = text(c.name);
|
|
462
|
-
break;
|
|
463
|
-
case "serial":
|
|
464
|
-
col = integer(c.name).primaryKey({ autoIncrement: true });
|
|
465
|
-
break;
|
|
466
|
-
case "integer":
|
|
467
|
-
case "bigint": {
|
|
468
|
-
const built = integer(c.name);
|
|
469
|
-
col = c.primaryKey ? built.primaryKey() : built;
|
|
470
|
-
break;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
if (c.notNull) col = col.notNull();
|
|
474
|
-
if (c.default !== void 0) col = col.default(c.default);
|
|
475
|
-
if (c.unique) col = col.unique();
|
|
476
|
-
if (c.references) {
|
|
477
|
-
const { table, column, onDelete } = c.references;
|
|
478
|
-
col = col.references(() => tables[table][toProperty(column)], { onDelete });
|
|
479
|
-
}
|
|
480
|
-
return col;
|
|
481
|
-
}
|
|
482
|
-
/** 由表描述构建 SQLite drizzle 表对象(按传入顺序;外键目标须先构建)。 */
|
|
483
|
-
function toSqliteSchema(defs) {
|
|
484
|
-
const tables = {};
|
|
485
|
-
for (const def of defs) {
|
|
486
|
-
const columns = {};
|
|
487
|
-
for (const c of def.columns) columns[toProperty(c.name)] = buildColumn$1(c, tables);
|
|
488
|
-
const extra = (self) => [
|
|
489
|
-
...(def.checks ?? []).map((c) => check(c.name, sql.raw(c.expression))),
|
|
490
|
-
...(def.uniques ?? []).map((u) => unique(u.name).on(...u.columns.map((name) => self[toProperty(name)]))),
|
|
491
|
-
...(def.indexes ?? []).map((i) => index(i.name).on(...i.columns.map((name) => self[toProperty(name)])))
|
|
492
|
-
];
|
|
493
|
-
tables[def.name] = sqliteTable(def.name, columns, extra);
|
|
494
|
-
}
|
|
495
|
-
return tables;
|
|
496
|
-
}
|
|
497
|
-
//#endregion
|
|
498
|
-
//#region src/adapters/to-postgres.ts
|
|
499
|
-
/**
|
|
500
|
-
* 从方言无关的表描述生成 PostgreSQL 的 drizzle `pgTable` 对象。查询的类型
|
|
501
|
-
* 安全由调用方的手写行接口与显式投影兜底(同 `to-sqlite.ts`)。
|
|
502
|
-
*
|
|
503
|
-
* @module @morlay/session-rdb/entities/to-postgres
|
|
504
|
-
*/
|
|
505
|
-
function buildColumn(c, tables) {
|
|
506
|
-
let col;
|
|
507
|
-
switch (c.type) {
|
|
508
|
-
case "text":
|
|
509
|
-
col = text$1(c.name);
|
|
510
|
-
break;
|
|
511
|
-
case "serial":
|
|
512
|
-
col = serial(c.name).primaryKey();
|
|
513
|
-
break;
|
|
514
|
-
case "integer": {
|
|
515
|
-
const built = integer$1(c.name);
|
|
516
|
-
col = c.primaryKey ? built.primaryKey() : built;
|
|
517
|
-
break;
|
|
518
|
-
}
|
|
519
|
-
case "bigint": {
|
|
520
|
-
const built = bigint(c.name, { mode: "number" });
|
|
521
|
-
col = c.primaryKey ? built.primaryKey() : built;
|
|
522
|
-
break;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
if (c.notNull) col = col.notNull();
|
|
526
|
-
if (c.default !== void 0) col = col.default(c.default);
|
|
527
|
-
if (c.unique) col = col.unique();
|
|
528
|
-
if (c.references) {
|
|
529
|
-
const { table, column, onDelete } = c.references;
|
|
530
|
-
col = col.references(() => tables[table][toProperty(column)], { onDelete });
|
|
531
|
-
}
|
|
532
|
-
return col;
|
|
533
|
-
}
|
|
534
|
-
/** 由表描述构建 PostgreSQL drizzle 表对象(按传入顺序;外键目标须先构建)。 */
|
|
535
|
-
function toPostgresSchema(defs) {
|
|
536
|
-
const tables = {};
|
|
537
|
-
for (const def of defs) {
|
|
538
|
-
const columns = {};
|
|
539
|
-
for (const c of def.columns) columns[toProperty(c.name)] = buildColumn(c, tables);
|
|
540
|
-
const extra = (self) => [
|
|
541
|
-
...(def.checks ?? []).map((c) => check$1(c.name, sql.raw(c.expression))),
|
|
542
|
-
...(def.uniques ?? []).map((u) => unique$1(u.name).on(...u.columns.map((name) => self[toProperty(name)]))),
|
|
543
|
-
...(def.indexes ?? []).map((i) => index$1(i.name).on(...i.columns.map((name) => self[toProperty(name)])))
|
|
544
|
-
];
|
|
545
|
-
tables[def.name] = pgTable(def.name, columns, extra);
|
|
546
|
-
}
|
|
547
|
-
return tables;
|
|
548
|
-
}
|
|
549
|
-
//#endregion
|
|
550
|
-
//#region src/adapters/ddl.ts
|
|
551
|
-
function sqlType(dialect, type) {
|
|
552
|
-
switch (type) {
|
|
553
|
-
case "serial": return dialect === "sqlite" ? "INTEGER" : "SERIAL";
|
|
554
|
-
case "integer": return "INTEGER";
|
|
555
|
-
case "bigint": return dialect === "sqlite" ? "INTEGER" : "BIGINT";
|
|
556
|
-
case "text": return "TEXT";
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
function literal(value) {
|
|
560
|
-
return typeof value === "string" ? `'${value.replace(/'/g, "''")}'` : String(value);
|
|
561
|
-
}
|
|
562
|
-
function quote(name) {
|
|
563
|
-
return `"${name}"`;
|
|
564
|
-
}
|
|
565
|
-
function columnSql(dialect, c) {
|
|
566
|
-
let sql = `${quote(c.name)} ${sqlType(dialect, c.type)}`;
|
|
567
|
-
if (c.primaryKey) sql += " PRIMARY KEY";
|
|
568
|
-
if (c.type === "serial" && dialect === "sqlite") sql += " AUTOINCREMENT";
|
|
569
|
-
if (c.notNull) sql += " NOT NULL";
|
|
570
|
-
if (c.default !== void 0) sql += ` DEFAULT ${literal(c.default)}`;
|
|
571
|
-
if (c.unique) sql += " UNIQUE";
|
|
572
|
-
if (c.references) {
|
|
573
|
-
sql += ` REFERENCES ${quote(c.references.table)}(${quote(c.references.column)})`;
|
|
574
|
-
if (c.references.onDelete) sql += ` ON DELETE ${c.references.onDelete.toUpperCase()}`;
|
|
575
|
-
}
|
|
576
|
-
return sql;
|
|
577
|
-
}
|
|
578
|
-
/** 一张表的 `CREATE TABLE IF NOT EXISTS`(表级约束内联在列清单末尾)。 */
|
|
579
|
-
function createTableSql(dialect, def) {
|
|
580
|
-
const parts = def.columns.map((c) => columnSql(dialect, c));
|
|
581
|
-
for (const ck of def.checks ?? []) parts.push(`CHECK (${ck.expression})`);
|
|
582
|
-
for (const u of def.uniques ?? []) parts.push(`UNIQUE (${u.columns.map(quote).join(", ")})`);
|
|
583
|
-
const strict = dialect === "sqlite" ? " STRICT" : "";
|
|
584
|
-
return `CREATE TABLE IF NOT EXISTS ${quote(def.name)} (\n ${parts.join(",\n ")}\n)${strict}`;
|
|
585
|
-
}
|
|
586
|
-
/** 一张表的独立索引语句(两方言索引 DDL 相同,无需 dialect 参数)。 */
|
|
587
|
-
function createIndexSql(def, name) {
|
|
588
|
-
const idx = def.indexes?.find((i) => i.name === name);
|
|
589
|
-
if (idx === void 0) throw new Error(`unknown index "${name}" on table "${def.name}"`);
|
|
590
|
-
return `CREATE INDEX IF NOT EXISTS ${quote(idx.name)} ON ${quote(def.name)}(${idx.columns.map(quote).join(", ")})`;
|
|
591
|
-
}
|
|
592
|
-
/** 一组表的全部建表语句(每表一条 CREATE TABLE + 每条索引)。 */
|
|
593
|
-
function createTablesSql(dialect, defs) {
|
|
594
|
-
const statements = [];
|
|
595
|
-
for (const def of defs) {
|
|
596
|
-
statements.push(createTableSql(dialect, def));
|
|
597
|
-
for (const idx of def.indexes ?? []) statements.push(createIndexSql(def, idx.name));
|
|
598
|
-
}
|
|
599
|
-
return statements;
|
|
600
|
-
}
|
|
601
|
-
//#endregion
|
|
602
|
-
//#region src/entities/persistence-state.ts
|
|
603
|
-
/**
|
|
604
|
-
* `t_persistence_state` — 单例 store 身份行。`f_singleton` 上的 CHECK 把表
|
|
605
|
-
* 钉死为一行(`f_singleton = 1`)。
|
|
606
|
-
*/
|
|
607
|
-
const persistenceState = {
|
|
608
|
-
name: "t_persistence_state",
|
|
609
|
-
columns: [{
|
|
610
|
-
name: "f_singleton",
|
|
611
|
-
type: "integer",
|
|
612
|
-
primaryKey: true
|
|
613
|
-
}, {
|
|
614
|
-
name: "f_store_id",
|
|
615
|
-
type: "text",
|
|
616
|
-
notNull: true
|
|
617
|
-
}],
|
|
618
|
-
checks: [{
|
|
619
|
-
name: "ck_persistence_state_singleton",
|
|
620
|
-
expression: "f_singleton = 1"
|
|
621
|
-
}]
|
|
622
|
-
};
|
|
623
|
-
//#endregion
|
|
624
|
-
//#region src/entities/schema-meta.ts
|
|
625
|
-
/**
|
|
626
|
-
* `t_schema_meta` — PostgreSQL 的 schema 版本 / 应用身份载体(SQLite 用
|
|
627
|
-
* `PRAGMA user_version` / `application_id`,PG 无等价 pragma)。键值表:
|
|
628
|
-
* `schema_version` 与 `application_id` 两行在首次初始化时写入。
|
|
629
|
-
* 仅 PostgreSQL 后端使用。
|
|
630
|
-
*/
|
|
631
|
-
const schemaMeta = {
|
|
632
|
-
name: "t_schema_meta",
|
|
633
|
-
columns: [{
|
|
634
|
-
name: "f_key",
|
|
635
|
-
type: "text",
|
|
636
|
-
primaryKey: true
|
|
637
|
-
}, {
|
|
638
|
-
name: "f_value",
|
|
639
|
-
type: "text",
|
|
640
|
-
notNull: true
|
|
641
|
-
}]
|
|
642
|
-
};
|
|
643
|
-
//#endregion
|
|
644
|
-
//#region src/entities/sessions.ts
|
|
645
|
-
/**
|
|
646
|
-
* `t_sessions` — 会话元数据(`SessionHeader` 列)+ playpen 风格 head 游标
|
|
647
|
-
* (`f_head_event_id` / `f_head_sequence`,事务内维护,append 时提供 parent
|
|
648
|
-
* 链与下一个 seq)。行的存在即 materialized 信号。
|
|
649
|
-
*/
|
|
650
|
-
const sessions = {
|
|
651
|
-
name: "t_sessions",
|
|
652
|
-
columns: [
|
|
653
|
-
{
|
|
654
|
-
name: "f_id",
|
|
655
|
-
type: "serial",
|
|
656
|
-
primaryKey: true
|
|
657
|
-
},
|
|
658
|
-
{
|
|
659
|
-
name: "f_session_id",
|
|
660
|
-
type: "text",
|
|
661
|
-
notNull: true,
|
|
662
|
-
unique: true
|
|
663
|
-
},
|
|
664
|
-
{
|
|
665
|
-
name: "f_head_event_id",
|
|
666
|
-
type: "text",
|
|
667
|
-
notNull: true,
|
|
668
|
-
default: ""
|
|
669
|
-
},
|
|
670
|
-
{
|
|
671
|
-
name: "f_head_sequence",
|
|
672
|
-
type: "integer",
|
|
673
|
-
notNull: true,
|
|
674
|
-
default: -1
|
|
675
|
-
},
|
|
676
|
-
{
|
|
677
|
-
name: "f_version",
|
|
678
|
-
type: "integer",
|
|
679
|
-
notNull: true
|
|
680
|
-
},
|
|
681
|
-
{
|
|
682
|
-
name: "f_created_at",
|
|
683
|
-
type: "bigint",
|
|
684
|
-
notNull: true
|
|
685
|
-
},
|
|
686
|
-
{
|
|
687
|
-
name: "f_cwd",
|
|
688
|
-
type: "text"
|
|
689
|
-
},
|
|
690
|
-
{
|
|
691
|
-
name: "f_parent_session",
|
|
692
|
-
type: "text"
|
|
693
|
-
},
|
|
694
|
-
{
|
|
695
|
-
name: "f_seed_length",
|
|
696
|
-
type: "integer"
|
|
697
|
-
},
|
|
698
|
-
{
|
|
699
|
-
name: "f_origin",
|
|
700
|
-
type: "text"
|
|
701
|
-
},
|
|
702
|
-
{
|
|
703
|
-
name: "f_delegation_depth",
|
|
704
|
-
type: "integer"
|
|
705
|
-
},
|
|
706
|
-
{
|
|
707
|
-
name: "f_incarnation",
|
|
708
|
-
type: "text",
|
|
709
|
-
notNull: true
|
|
710
|
-
},
|
|
711
|
-
{
|
|
712
|
-
name: "f_revision",
|
|
713
|
-
type: "integer",
|
|
714
|
-
notNull: true
|
|
715
|
-
}
|
|
716
|
-
]
|
|
717
|
-
};
|
|
718
|
-
//#endregion
|
|
719
|
-
//#region src/entities/events.ts
|
|
720
|
-
/**
|
|
721
|
-
* `t_events` — 全局可寻址的持久化事件实体:`f_event_id`(UUID 唯一)、
|
|
722
|
-
* `f_parent_id`(事件链,空串为 root)、`f_kind`(= 上游 `type`)、
|
|
723
|
-
* `f_role` / `f_name` / `f_action_id`(playpen 事件维度)、`f_encoding`
|
|
724
|
-
* (`json`)、`f_data`(JSON 文本)、`f_created_at`(= `time`)、
|
|
725
|
-
* `f_original_seq`(上游 seq)以及 surface 元数据列(JSON 文本或 NULL)。
|
|
726
|
-
*/
|
|
727
|
-
const events = {
|
|
728
|
-
name: "t_events",
|
|
729
|
-
columns: [
|
|
730
|
-
{
|
|
731
|
-
name: "f_id",
|
|
732
|
-
type: "serial",
|
|
733
|
-
primaryKey: true
|
|
734
|
-
},
|
|
735
|
-
{
|
|
736
|
-
name: "f_event_id",
|
|
737
|
-
type: "text",
|
|
738
|
-
notNull: true,
|
|
739
|
-
unique: true
|
|
740
|
-
},
|
|
741
|
-
{
|
|
742
|
-
name: "f_parent_id",
|
|
743
|
-
type: "text",
|
|
744
|
-
notNull: true,
|
|
745
|
-
default: ""
|
|
746
|
-
},
|
|
747
|
-
{
|
|
748
|
-
name: "f_kind",
|
|
749
|
-
type: "text",
|
|
750
|
-
notNull: true,
|
|
751
|
-
default: ""
|
|
752
|
-
},
|
|
753
|
-
{
|
|
754
|
-
name: "f_role",
|
|
755
|
-
type: "text",
|
|
756
|
-
notNull: true,
|
|
757
|
-
default: ""
|
|
758
|
-
},
|
|
759
|
-
{
|
|
760
|
-
name: "f_name",
|
|
761
|
-
type: "text",
|
|
762
|
-
notNull: true,
|
|
763
|
-
default: ""
|
|
764
|
-
},
|
|
765
|
-
{
|
|
766
|
-
name: "f_action_id",
|
|
767
|
-
type: "text",
|
|
768
|
-
notNull: true,
|
|
769
|
-
default: ""
|
|
770
|
-
},
|
|
771
|
-
{
|
|
772
|
-
name: "f_encoding",
|
|
773
|
-
type: "text",
|
|
774
|
-
notNull: true,
|
|
775
|
-
default: ""
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
name: "f_data",
|
|
779
|
-
type: "text",
|
|
780
|
-
notNull: true
|
|
781
|
-
},
|
|
782
|
-
{
|
|
783
|
-
name: "f_created_at",
|
|
784
|
-
type: "bigint",
|
|
785
|
-
notNull: true,
|
|
786
|
-
default: 0
|
|
787
|
-
},
|
|
788
|
-
{
|
|
789
|
-
name: "f_original_seq",
|
|
790
|
-
type: "integer",
|
|
791
|
-
notNull: true
|
|
792
|
-
},
|
|
793
|
-
{
|
|
794
|
-
name: "f_source_event_seqs",
|
|
795
|
-
type: "text"
|
|
796
|
-
},
|
|
797
|
-
{
|
|
798
|
-
name: "f_surface_op",
|
|
799
|
-
type: "text"
|
|
800
|
-
}
|
|
801
|
-
]
|
|
802
|
-
};
|
|
803
|
-
//#endregion
|
|
804
|
-
//#region src/entities/session-events.ts
|
|
805
|
-
/**
|
|
806
|
-
* `t_session_events` — 会话↔事件桥接表。`(f_session_id, f_sequence)` 唯一且
|
|
807
|
-
* 有序,会话 log 按稠密 seq 读取;删除 torn tail 只删桥接行(事件实体作为
|
|
808
|
-
* 全局行保留)。
|
|
809
|
-
*/
|
|
810
|
-
const sessionEvents = {
|
|
811
|
-
name: "t_session_events",
|
|
812
|
-
columns: [
|
|
813
|
-
{
|
|
814
|
-
name: "f_id",
|
|
815
|
-
type: "serial",
|
|
816
|
-
primaryKey: true
|
|
817
|
-
},
|
|
818
|
-
{
|
|
819
|
-
name: "f_session_id",
|
|
820
|
-
type: "text",
|
|
821
|
-
notNull: true,
|
|
822
|
-
references: {
|
|
823
|
-
table: "t_sessions",
|
|
824
|
-
column: "f_session_id",
|
|
825
|
-
onDelete: "cascade"
|
|
826
|
-
}
|
|
827
|
-
},
|
|
828
|
-
{
|
|
829
|
-
name: "f_event_id",
|
|
830
|
-
type: "text",
|
|
831
|
-
notNull: true,
|
|
832
|
-
references: {
|
|
833
|
-
table: "t_events",
|
|
834
|
-
column: "f_event_id",
|
|
835
|
-
onDelete: "cascade"
|
|
836
|
-
}
|
|
837
|
-
},
|
|
838
|
-
{
|
|
839
|
-
name: "f_sequence",
|
|
840
|
-
type: "integer",
|
|
841
|
-
notNull: true
|
|
842
|
-
}
|
|
843
|
-
],
|
|
844
|
-
uniques: [{
|
|
845
|
-
name: "uq_session_events_session_sequence",
|
|
846
|
-
columns: ["f_session_id", "f_sequence"]
|
|
847
|
-
}]
|
|
848
|
-
};
|
|
849
|
-
//#endregion
|
|
850
|
-
//#region src/entities/index.ts
|
|
851
|
-
/**
|
|
852
|
-
* 实体纯定义:每张表一个文件,方言无关的表描述(列 / 约束 / 索引 / 外键)。
|
|
853
|
-
* 具体后端的 drizzle 表对象与建表 DDL 由 `src/adapters/` 从这里转化生成。
|
|
854
|
-
*
|
|
855
|
-
* @module @morlay/session-rdb/entities
|
|
856
|
-
*/
|
|
857
|
-
/** SQLite 后端使用的表(不含 pg 专用的 `t_schema_meta`)。 */
|
|
858
|
-
const sqliteTableDefs = [
|
|
859
|
-
persistenceState,
|
|
860
|
-
sessions,
|
|
861
|
-
events,
|
|
862
|
-
sessionEvents
|
|
863
|
-
];
|
|
864
|
-
/** PostgreSQL 后端使用的表。 */
|
|
865
|
-
const postgresTableDefs = [
|
|
866
|
-
persistenceState,
|
|
867
|
-
schemaMeta,
|
|
868
|
-
sessions,
|
|
869
|
-
events,
|
|
870
|
-
sessionEvents
|
|
871
|
-
];
|
|
872
|
-
//#endregion
|
|
873
|
-
//#region src/schema.ts
|
|
874
|
-
/**
|
|
875
|
-
* The on-disk schema version. Bumped only on a breaking change to the table
|
|
876
|
-
* layout; orthogonal to a session's own `version` (which versions the EVENT
|
|
877
|
-
* vocabulary, stored per session in the `t_sessions` row).
|
|
878
|
-
*/
|
|
879
|
-
const SCHEMA_VERSION = 1;
|
|
880
|
-
/** SQLite application id protecting unrelated databases from persistence writes. */
|
|
881
|
-
const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 1146308688;
|
|
882
|
-
/**
|
|
883
|
-
* Event types whose CONTENT is not persisted: the backend drops these rows
|
|
884
|
-
* entirely and re-numbers the surviving events to a dense persisted seq.
|
|
885
|
-
* Mirrors the persistence proposal's "ephemeral events never enter the
|
|
886
|
-
* canonical log" split.
|
|
887
|
-
*/
|
|
888
|
-
const EPHEMERAL_EVENT_TYPES = ["assistant/chunk"];
|
|
889
|
-
/** `t_events.f_encoding` value: JSON text. Future compression would switch this per row. */
|
|
890
|
-
const EVENT_ENCODING = "json";
|
|
891
|
-
/**
|
|
892
|
-
* SQLite drizzle tables derived from the single entity definitions in
|
|
893
|
-
* `src/entities/`. The runtime column objects are authoritative; TS type
|
|
894
|
-
* safety of queries is carried by the hand-written row interfaces in
|
|
895
|
-
* `backend.ts` (drizzle cannot infer precise column types from a runtime-built
|
|
896
|
-
* column map, so the table handles are deliberately loose).
|
|
897
|
-
*/
|
|
898
|
-
const sqliteTables = toSqliteSchema(sqliteTableDefs);
|
|
899
|
-
/** `t_persistence_state` — the singleton row holding the store identity. */
|
|
900
|
-
const tPersistenceState = sqliteTables["t_persistence_state"];
|
|
901
|
-
/** `t_sessions` — the out-of-log metadata plus the playpen-style head cursor. */
|
|
902
|
-
const tSessions = sqliteTables["t_sessions"];
|
|
903
|
-
/** `t_events` — the globally addressable persisted event entity. */
|
|
904
|
-
const tEvents = sqliteTables["t_events"];
|
|
905
|
-
/** `t_session_events` — the session↔event bridge. */
|
|
906
|
-
const tSessionEvents = sqliteTables["t_session_events"];
|
|
907
|
-
/**
|
|
908
|
-
* How long a connection waits for a contended write lock before failing with
|
|
909
|
-
* `SQLITE_BUSY`. SQLite's default is 0 (fail immediately): with two processes
|
|
910
|
-
* sharing one database (a second `dsh` instance on the same `sessions.sqlite`),
|
|
911
|
-
* every append that meets an in-flight commit would fail and that process's
|
|
912
|
-
* session would silently lose its tail. A nonzero wait makes the contention
|
|
913
|
-
* window a queue instead of a loss.
|
|
914
|
-
*/
|
|
915
|
-
const DEFAULT_BUSY_TIMEOUT_MS = 5e3;
|
|
916
|
-
/**
|
|
917
|
-
* Whether an event type is ephemeral (its content must not be persisted).
|
|
918
|
-
* @param type - the upstream `SessionEvent.type`.
|
|
919
|
-
* @returns true for delta events the backend drops at write time.
|
|
920
|
-
*/
|
|
921
|
-
function isEphemeralType(type) {
|
|
922
|
-
return EPHEMERAL_EVENT_TYPES.includes(type);
|
|
923
|
-
}
|
|
924
|
-
/**
|
|
925
|
-
* Whether an event must be persisted. An event is dropped at write time when
|
|
926
|
-
* its type is ephemeral (content not persisted) OR the writer marked it
|
|
927
|
-
* `ignorable` — the envelope contract promises loss of an ignorable event
|
|
928
|
-
* cannot affect reconstruction, so it never enters the canonical log (the
|
|
929
|
-
* upstream seq is still recorded for provenance pruning, exactly like a
|
|
930
|
-
* dropped delta). `session-branch/version` is a branch-layer lineage fact
|
|
931
|
-
* carried as an ignorable seed event: it stays in the LIVE log but is NOT
|
|
932
|
-
* persisted here — the branch provider persists it in its own version table
|
|
933
|
-
* (`t_branch_versions`), keeping canonical-log semantics intact.
|
|
934
|
-
*/
|
|
935
|
-
function isPersistedEvent(event) {
|
|
936
|
-
return !isEphemeralType(event.type) && event.ignorable !== true;
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* Map a persisted event onto the playpen event dimensions. `f_kind` is the
|
|
940
|
-
* upstream type; `f_role`/`f_name`/`f_action_id` are the playpen classification
|
|
941
|
-
* columns. Unknown (plugin-merged) event types keep the playpen defaults so a
|
|
942
|
-
* future extension can classify them without a schema change.
|
|
943
|
-
* @param event - the event to classify (never an ephemeral type at write time).
|
|
944
|
-
* @returns the role, name, and action-id column values.
|
|
945
|
-
*/
|
|
946
|
-
function eventDimensions(event) {
|
|
947
|
-
switch (event.type) {
|
|
948
|
-
case "turn/start":
|
|
949
|
-
case "turn/end":
|
|
950
|
-
case "step/start":
|
|
951
|
-
case "step/end":
|
|
952
|
-
case "session/end-seed": return {
|
|
953
|
-
role: "turn",
|
|
954
|
-
name: "",
|
|
955
|
-
actionId: ""
|
|
956
|
-
};
|
|
957
|
-
case "user/message":
|
|
958
|
-
case "request/header":
|
|
959
|
-
case "request/context": return {
|
|
960
|
-
role: "user",
|
|
961
|
-
name: "",
|
|
962
|
-
actionId: ""
|
|
963
|
-
};
|
|
964
|
-
case "assistant/message": return {
|
|
965
|
-
role: "model",
|
|
966
|
-
name: "",
|
|
967
|
-
actionId: ""
|
|
968
|
-
};
|
|
969
|
-
case "tool/call": return {
|
|
970
|
-
role: "function",
|
|
971
|
-
name: event.data.name,
|
|
972
|
-
actionId: event.data.callId
|
|
973
|
-
};
|
|
974
|
-
case "tool/result": return {
|
|
975
|
-
role: "function",
|
|
976
|
-
name: "",
|
|
977
|
-
actionId: (event.data.message?.content[0])?.toolCallId ?? ""
|
|
978
|
-
};
|
|
979
|
-
case "todo/write": return {
|
|
980
|
-
role: "state",
|
|
981
|
-
name: "todos",
|
|
982
|
-
actionId: ""
|
|
983
|
-
};
|
|
984
|
-
default: return {
|
|
985
|
-
role: "",
|
|
986
|
-
name: "",
|
|
987
|
-
actionId: ""
|
|
988
|
-
};
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
//#endregion
|
|
992
|
-
//#region src/sqlite.ts
|
|
993
|
-
/**
|
|
994
|
-
* SQLite 存储后端:在 `node:sqlite` `DatabaseSync` 之上实现 {@link Backend}。
|
|
995
|
-
* drizzle 经 `drizzle-orm/node-sqlite` 驱动包装,查询语义与 PostgreSQL 后端
|
|
996
|
-
* 共用同一套 {@link BackendTx} 原语。数据库打开/建表/schema 版本与身份校验
|
|
997
|
-
* ({@link openDatabase})也归本模块所有——`SqliteBackend` 的实现不跨文件。
|
|
998
|
-
*
|
|
999
|
-
* 事务:SQLite 单连接,`BEGIN IMMEDIATE` 提前获取写锁(受 `busy_timeout`
|
|
1000
|
-
* pragma 排队保护);`COMMIT`/`ROLLBACK` 之后同一连接继续服务普通查询。
|
|
1001
|
-
* @module @morlay/session-rdb/sqlite
|
|
1002
|
-
*/
|
|
1003
|
-
/**
|
|
1004
|
-
* Process-wide SQLite write-transaction queues, keyed by database path.
|
|
1005
|
-
* SQLite allows exactly one writer, and the async transaction callback (see
|
|
1006
|
-
* {@link SqliteBackend.transaction}) must never overlap another connection's
|
|
1007
|
-
* `BEGIN IMMEDIATE` inside this process: the second, synchronous BEGIN would
|
|
1008
|
-
* busy-wait on the lock and freeze the event loop, so the lock holder could
|
|
1009
|
-
* never commit (deadlock until busy_timeout). Serializing per PATH removes the
|
|
1010
|
-
* gap entirely.
|
|
1011
|
-
*
|
|
1012
|
-
* The queue is per path, not global: two backends on DIFFERENT files have no
|
|
1013
|
-
* lock to contend for, so serializing them would be pure waste. Two instances
|
|
1014
|
-
* sharing one file (the supported multi-process deployment) still share one
|
|
1015
|
-
* queue, preserving the deadlock guarantee. `:memory:` databases are distinct
|
|
1016
|
-
* per connection but share the key — serializing them is harmless (tests).
|
|
1017
|
-
*/
|
|
1018
|
-
const sqliteTxQueues = /* @__PURE__ */ new Map();
|
|
1019
|
-
/** Run `fn` behind the write-transaction queue for one database path. */
|
|
1020
|
-
function enqueueSqliteTx(path, fn) {
|
|
1021
|
-
const run = (sqliteTxQueues.get(path) ?? Promise.resolve()).then(fn);
|
|
1022
|
-
sqliteTxQueues.set(path, run.then(() => void 0, () => void 0));
|
|
1023
|
-
return run;
|
|
1024
|
-
}
|
|
1025
|
-
/**
|
|
1026
|
-
* Exclusively create a missing database file with owner-only permissions.
|
|
1027
|
-
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
|
1028
|
-
* `DatabaseSync` reopens by path, so this does not protect confidentiality or
|
|
1029
|
-
* integrity when another principal can replace the database entry in its parent
|
|
1030
|
-
* directory.
|
|
1031
|
-
*/
|
|
1032
|
-
async function createDatabaseFile(path) {
|
|
1033
|
-
try {
|
|
1034
|
-
await (await open(path, "wx", 384)).close();
|
|
1035
|
-
} catch (error) {
|
|
1036
|
-
if (error.code !== "EEXIST") throw error;
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
/**
|
|
1040
|
-
* Open the database and apply its schema and pragmas. An empty database with a
|
|
1041
|
-
* zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
|
|
1042
|
-
* unversioned database and every other non-current version reject rather than
|
|
1043
|
-
* being migrated in place.
|
|
1044
|
-
* @param path - the SQLite database file to open (created when absent).
|
|
1045
|
-
* @param journalMode - validated journal pragma.
|
|
1046
|
-
* @param busyTimeout - milliseconds to wait for a contended write lock before
|
|
1047
|
-
* failing with `SQLITE_BUSY`; `0` fails immediately (SQLite's default).
|
|
1048
|
-
* @returns the open handle with pragmas applied and all tables ensured.
|
|
1049
|
-
*/
|
|
1050
|
-
function openDatabase(path, journalMode, busyTimeout = DEFAULT_BUSY_TIMEOUT_MS) {
|
|
1051
|
-
const db = new DatabaseSync(path);
|
|
1052
|
-
try {
|
|
1053
|
-
configureDatabase(db, path, journalMode, busyTimeout);
|
|
1054
|
-
return db;
|
|
1055
|
-
} catch (error) {
|
|
1056
|
-
db.close();
|
|
1057
|
-
throw error;
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
function configureDatabase(db, path, journalMode, busyTimeout) {
|
|
1061
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
1062
|
-
db.exec(`PRAGMA busy_timeout = ${busyTimeout}`);
|
|
1063
|
-
drizzle$1({ client: db }).transaction((tx) => {
|
|
1064
|
-
const { user_version: onDisk } = tx.get(sql`PRAGMA user_version`);
|
|
1065
|
-
const { application_id: applicationId } = tx.get(sql`PRAGMA application_id`);
|
|
1066
|
-
const { count: userObjectCount } = tx.get(sql`SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'`);
|
|
1067
|
-
if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) throw new Error(`session database at "${path}" has an unversioned schema or application identity`);
|
|
1068
|
-
if (onDisk !== 0 && onDisk !== 1) throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (1)`);
|
|
1069
|
-
if (onDisk === 1 && applicationId !== 1146308688) throw new Error(`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`);
|
|
1070
|
-
for (const statement of createTablesSql("sqlite", sqliteTableDefs)) tx.run(sql.raw(statement));
|
|
1071
|
-
tx.insert(tPersistenceState).values({
|
|
1072
|
-
fSingleton: 1,
|
|
1073
|
-
fStoreId: randomUUID()
|
|
1074
|
-
}).onConflictDoNothing().run();
|
|
1075
|
-
if (onDisk === 0) {
|
|
1076
|
-
tx.run(sql.raw(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`));
|
|
1077
|
-
tx.run(sql.raw(`PRAGMA user_version = 1`));
|
|
1078
|
-
}
|
|
1079
|
-
}, { behavior: "immediate" });
|
|
1080
|
-
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`);
|
|
1081
|
-
}
|
|
1082
|
-
/**
|
|
1083
|
-
* SQLite 存储后端。打开时创建目录/文件(owner-only)、应用 pragma 与 DDL、
|
|
1084
|
-
* 校验 schema 版本/应用身份并读取 store id。
|
|
1085
|
-
*/
|
|
1086
|
-
var SqliteBackend = class {
|
|
1087
|
-
options;
|
|
1088
|
-
kind = "sqlite";
|
|
1089
|
-
storeIdentity;
|
|
1090
|
-
/** The resolved database path (queue key); set by {@link open}. */
|
|
1091
|
-
dbPath = "";
|
|
1092
|
-
db;
|
|
1093
|
-
constructor(options) {
|
|
1094
|
-
this.options = options;
|
|
1095
|
-
}
|
|
1096
|
-
async open() {
|
|
1097
|
-
const actual = this.options.path === ":memory:" ? this.options.path : resolve(this.options.path);
|
|
1098
|
-
this.dbPath = actual;
|
|
1099
|
-
if (actual !== ":memory:") {
|
|
1100
|
-
await mkdir(dirname(actual), {
|
|
1101
|
-
recursive: true,
|
|
1102
|
-
mode: 448
|
|
1103
|
-
});
|
|
1104
|
-
await createDatabaseFile(actual);
|
|
1105
|
-
}
|
|
1106
|
-
await enqueueSqliteTx(actual, async () => {
|
|
1107
|
-
this.db = drizzle$1({ client: openDatabase(actual, this.options.journalMode, this.options.busyTimeout) });
|
|
1108
|
-
});
|
|
1109
|
-
try {
|
|
1110
|
-
const row = this.db.select({ fStoreId: tPersistenceState.fStoreId }).from(tPersistenceState).where(eq(tPersistenceState.fSingleton, 1)).get();
|
|
1111
|
-
/* v8 ignore next -- openDatabase inserts the singleton before returning. */
|
|
1112
|
-
if (row === void 0) throw new Error(`session database at "${actual}" has no store identity`);
|
|
1113
|
-
if (row.fStoreId.length === 0) throw new Error(`session database at "${actual}" has no valid store identity`);
|
|
1114
|
-
if (actual !== ":memory:") {
|
|
1115
|
-
const identity = statSync(actual, { bigint: true });
|
|
1116
|
-
this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.fStoreId}`;
|
|
1117
|
-
} else this.storeIdentity = `memory:store:${row.fStoreId}`;
|
|
1118
|
-
} catch (error) {
|
|
1119
|
-
this.db.$client.close();
|
|
1120
|
-
throw error;
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
async close() {
|
|
1124
|
-
if (this.db === void 0) return;
|
|
1125
|
-
this.db.$client.close();
|
|
1126
|
-
}
|
|
1127
|
-
async getSession(id) {
|
|
1128
|
-
return this.db.select().from(tSessions).where(eq(tSessions.fSessionId, id)).get();
|
|
1129
|
-
}
|
|
1130
|
-
async getSeqMapRows(id) {
|
|
1131
|
-
return this.eventRows().where(eq(tSessionEvents.fSessionId, id)).all();
|
|
1132
|
-
}
|
|
1133
|
-
async getEventRows(id, fromSequence) {
|
|
1134
|
-
return (fromSequence === void 0 ? this.eventRows().where(eq(tSessionEvents.fSessionId, id)) : this.eventRows().where(and(eq(tSessionEvents.fSessionId, id), gte(tSessionEvents.fSequence, fromSequence)))).orderBy(tSessionEvents.fSequence).all();
|
|
1135
|
-
}
|
|
1136
|
-
async listSessions() {
|
|
1137
|
-
return this.db.select().from(tSessions).all();
|
|
1138
|
-
}
|
|
1139
|
-
async transaction(fn) {
|
|
1140
|
-
return enqueueSqliteTx(this.dbPath, async () => {
|
|
1141
|
-
this.db.$client.exec("BEGIN IMMEDIATE");
|
|
1142
|
-
try {
|
|
1143
|
-
const result = await fn(this.tx);
|
|
1144
|
-
this.db.$client.exec("COMMIT");
|
|
1145
|
-
return result;
|
|
1146
|
-
} catch (error) {
|
|
1147
|
-
/* v8 ignore start */
|
|
1148
|
-
try {
|
|
1149
|
-
this.db.$client.exec("ROLLBACK");
|
|
1150
|
-
} catch {}
|
|
1151
|
-
throw error;
|
|
1152
|
-
}
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
/**
|
|
1156
|
-
* SQLite is a single connection: after `BEGIN IMMEDIATE` every query on the
|
|
1157
|
-
* same handle is inside the transaction, so the tx primitives are the same
|
|
1158
|
-
* row primitives used by the non-transactional reads.
|
|
1159
|
-
*/
|
|
1160
|
-
tx = {
|
|
1161
|
-
upsertSession: (meta, incarnation) => this.upsertSession(meta, incarnation),
|
|
1162
|
-
getHead: (id) => this.getHead(id),
|
|
1163
|
-
insertEvents: (events) => this.insertEvents(events),
|
|
1164
|
-
insertBridges: (rows) => this.insertBridges(rows),
|
|
1165
|
-
updateHead: (id, headEventId, headSequence) => this.updateHead(id, headEventId, headSequence),
|
|
1166
|
-
bumpRevision: (id) => this.bumpRevision(id),
|
|
1167
|
-
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(id, fromSequence),
|
|
1168
|
-
getPrevBridge: (id, sequence) => this.getPrevBridge(id, sequence),
|
|
1169
|
-
getLastBridge: (id) => this.getLastBridge(id),
|
|
1170
|
-
updateEventFields: (id, sequence, fields) => this.updateEventFields(id, sequence, fields)
|
|
1171
|
-
};
|
|
1172
|
-
async upsertSession(meta, incarnation) {
|
|
1173
|
-
this.db.insert(tSessions).values(sessionInsertRow(meta, incarnation)).onConflictDoUpdate({
|
|
1174
|
-
target: tSessions.fSessionId,
|
|
1175
|
-
set: sessionConflictRow(meta)
|
|
1176
|
-
}).run();
|
|
1177
|
-
}
|
|
1178
|
-
async getHead(id) {
|
|
1179
|
-
const head = this.db.select({
|
|
1180
|
-
fHeadEventId: tSessions.fHeadEventId,
|
|
1181
|
-
fHeadSequence: tSessions.fHeadSequence
|
|
1182
|
-
}).from(tSessions).where(eq(tSessions.fSessionId, id)).get();
|
|
1183
|
-
/* v8 ignore next -- appendBatch/commitRepair always materialize the row before reading the head */
|
|
1184
|
-
if (head === void 0) throw new Error(`session "${id}" has no materialized row`);
|
|
1185
|
-
return head;
|
|
1186
|
-
}
|
|
1187
|
-
async insertEvents(events) {
|
|
1188
|
-
if (events.length === 0) return;
|
|
1189
|
-
this.db.insert(tEvents).values(events.map((event) => ({ ...event }))).run();
|
|
1190
|
-
}
|
|
1191
|
-
async insertBridges(rows) {
|
|
1192
|
-
if (rows.length === 0) return;
|
|
1193
|
-
this.db.insert(tSessionEvents).values(rows.map((row) => ({ ...row }))).run();
|
|
1194
|
-
}
|
|
1195
|
-
async updateHead(id, headEventId, headSequence) {
|
|
1196
|
-
this.db.update(tSessions).set({
|
|
1197
|
-
fHeadEventId: headEventId,
|
|
1198
|
-
fHeadSequence: headSequence
|
|
1199
|
-
}).where(eq(tSessions.fSessionId, id)).run();
|
|
1200
|
-
}
|
|
1201
|
-
async bumpRevision(id) {
|
|
1202
|
-
this.db.update(tSessions).set({ fRevision: sql`${tSessions.fRevision} + 1` }).where(eq(tSessions.fSessionId, id)).run();
|
|
1203
|
-
}
|
|
1204
|
-
async deleteBridgeTail(id, fromSequence) {
|
|
1205
|
-
this.db.delete(tSessionEvents).where(and(eq(tSessionEvents.fSessionId, id), gte(tSessionEvents.fSequence, fromSequence))).run();
|
|
1206
|
-
}
|
|
1207
|
-
async getPrevBridge(id, sequence) {
|
|
1208
|
-
return this.db.select({
|
|
1209
|
-
fEventId: tSessionEvents.fEventId,
|
|
1210
|
-
fSequence: tSessionEvents.fSequence
|
|
1211
|
-
}).from(tSessionEvents).where(and(eq(tSessionEvents.fSessionId, id), eq(tSessionEvents.fSequence, sequence))).get();
|
|
1212
|
-
}
|
|
1213
|
-
async getLastBridge(id) {
|
|
1214
|
-
return this.db.select({
|
|
1215
|
-
fEventId: tSessionEvents.fEventId,
|
|
1216
|
-
fSequence: tSessionEvents.fSequence
|
|
1217
|
-
}).from(tSessionEvents).where(eq(tSessionEvents.fSessionId, id)).orderBy(desc(tSessionEvents.fSequence)).limit(1).get();
|
|
1218
|
-
}
|
|
1219
|
-
/** The joined event-row projection shared by whole-log and suffix reads. */
|
|
1220
|
-
eventRows() {
|
|
1221
|
-
return this.db.select({
|
|
1222
|
-
fSequence: tSessionEvents.fSequence,
|
|
1223
|
-
fOriginalSeq: tEvents.fOriginalSeq,
|
|
1224
|
-
fKind: tEvents.fKind,
|
|
1225
|
-
fCreatedAt: tEvents.fCreatedAt,
|
|
1226
|
-
fData: tEvents.fData,
|
|
1227
|
-
fSourceEventSeqs: tEvents.fSourceEventSeqs,
|
|
1228
|
-
fSurfaceOp: tEvents.fSurfaceOp
|
|
1229
|
-
}).from(tSessionEvents).innerJoin(tEvents, eq(tSessionEvents.fEventId, tEvents.fEventId));
|
|
1230
|
-
}
|
|
1231
|
-
async updateEventFields(id, sequence, fields) {
|
|
1232
|
-
const eventIds = this.db.select({ fEventId: tSessionEvents.fEventId }).from(tSessionEvents).where(and(eq(tSessionEvents.fSessionId, id), eq(tSessionEvents.fSequence, sequence)));
|
|
1233
|
-
this.db.update(tEvents).set({
|
|
1234
|
-
...fields.fSourceEventSeqs === void 0 ? {} : { fSourceEventSeqs: fields.fSourceEventSeqs },
|
|
1235
|
-
...fields.fSurfaceOp === void 0 ? {} : { fSurfaceOp: fields.fSurfaceOp },
|
|
1236
|
-
...fields.fData === void 0 ? {} : { fData: fields.fData }
|
|
1237
|
-
}).where(inArray(tEvents.fEventId, eventIds)).run();
|
|
1238
|
-
}
|
|
1239
|
-
};
|
|
1240
|
-
//#endregion
|
|
1241
|
-
//#region src/postgres.ts
|
|
1242
|
-
/**
|
|
1243
|
-
* PostgreSQL 存储后端:在 drizzle 的 PG async 驱动之上实现 {@link Backend}。
|
|
1244
|
-
* 驱动(`drizzle-orm/node-postgres` 生产 / `drizzle-orm/pglite` 测试)在构造
|
|
1245
|
-
* 时注入,因此本模块不依赖具体 PG 客户端包。
|
|
1246
|
-
*
|
|
1247
|
-
* 与 SQLite 的差异(方言事实,非行为差异):
|
|
1248
|
-
* - `f_created_at` 用 `BIGINT`(毫秒时间戳超出 PG `INTEGER` 的 int32 范围);
|
|
1249
|
-
* - schema 版本/应用身份校验用 `t_schema_meta` 键值表代替 SQLite 的
|
|
1250
|
-
* `PRAGMA user_version` / `application_id`(PG 无等价 pragma);
|
|
1251
|
-
* - 事务用 drizzle 的异步 `db.transaction`(PG 无 `BEGIN IMMEDIATE`,写锁靠
|
|
1252
|
-
* `busy_timeout` 之外的数据库行锁/唯一约束兜底)。
|
|
1253
|
-
* @module @morlay/session-rdb/postgres
|
|
1254
|
-
*/
|
|
1255
|
-
/**
|
|
1256
|
-
* PostgreSQL drizzle tables derived from the single entity definitions in
|
|
1257
|
-
* `src/entities/` (type safety carried by the hand-written row interfaces in
|
|
1258
|
-
* `backend.ts`, same as the SQLite side).
|
|
1259
|
-
*/
|
|
1260
|
-
const pgTables = toPostgresSchema(postgresTableDefs);
|
|
1261
|
-
/** `t_persistence_state` — the singleton row holding the store identity. */
|
|
1262
|
-
const pgPersistenceState = pgTables["t_persistence_state"];
|
|
1263
|
-
/** `t_schema_meta` — PG's schema-version / application-identity key-value store. */
|
|
1264
|
-
const pgSchemaMeta = pgTables["t_schema_meta"];
|
|
1265
|
-
/** `t_sessions` — the out-of-log metadata plus the playpen-style head cursor. */
|
|
1266
|
-
const pgSessions = pgTables["t_sessions"];
|
|
1267
|
-
/** `t_events` — the globally addressable persisted event entity. */
|
|
1268
|
-
const pgEvents = pgTables["t_events"];
|
|
1269
|
-
/** `t_session_events` — the session↔event bridge. */
|
|
1270
|
-
const pgSessionEvents = pgTables["t_session_events"];
|
|
1271
|
-
/**
|
|
1272
|
-
* PostgreSQL 存储后端。构造时注入 drizzle PG 实例;{@link open} 建表并做
|
|
1273
|
-
* schema 版本/应用身份校验(`t_schema_meta`,替代 SQLite 的 PRAGMA)。
|
|
1274
|
-
*/
|
|
1275
|
-
var PostgresBackend = class {
|
|
1276
|
-
db;
|
|
1277
|
-
options;
|
|
1278
|
-
kind = "postgres";
|
|
1279
|
-
storeIdentity;
|
|
1280
|
-
constructor(db, options) {
|
|
1281
|
-
this.db = db;
|
|
1282
|
-
this.options = options;
|
|
1283
|
-
}
|
|
1284
|
-
async open() {
|
|
1285
|
-
const storeId = await this.db.transaction(async (tx) => {
|
|
1286
|
-
const metaExists = (await tx.execute(sql`SELECT to_regclass('t_schema_meta') IS NOT NULL AS exists`)).rows[0]?.exists === true;
|
|
1287
|
-
for (const statement of createTablesSql("postgres", postgresTableDefs)) await tx.execute(sql.raw(statement));
|
|
1288
|
-
if (!metaExists) await tx.insert(pgSchemaMeta).values([{
|
|
1289
|
-
fKey: "schema_version",
|
|
1290
|
-
fValue: String(1)
|
|
1291
|
-
}, {
|
|
1292
|
-
fKey: "application_id",
|
|
1293
|
-
fValue: String(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID)
|
|
1294
|
-
}]).execute();
|
|
1295
|
-
const version = await this.readMeta(tx, "schema_version");
|
|
1296
|
-
const applicationId = await this.readMeta(tx, "application_id");
|
|
1297
|
-
if (version === void 0 || applicationId === void 0) throw new Error("session database has an unversioned schema or application identity");
|
|
1298
|
-
if (Number(version) !== 1) throw new Error(`session database has schema version ${version}, incompatible with this build (1)`);
|
|
1299
|
-
if (Number(applicationId) !== 1146308688) throw new Error(`session database has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`);
|
|
1300
|
-
await tx.insert(pgPersistenceState).values({
|
|
1301
|
-
fSingleton: 1,
|
|
1302
|
-
fStoreId: randomUUID()
|
|
1303
|
-
}).onConflictDoNothing().execute();
|
|
1304
|
-
const storeId = (await tx.select({ fStoreId: pgPersistenceState.fStoreId }).from(pgPersistenceState).where(eq(pgPersistenceState.fSingleton, 1)).execute())[0]?.fStoreId;
|
|
1305
|
-
if (storeId === void 0 || storeId.length === 0) throw new Error("session database has no valid store identity");
|
|
1306
|
-
return storeId;
|
|
1307
|
-
});
|
|
1308
|
-
this.storeIdentity = `${this.options.identityBase}:store:${storeId}`;
|
|
1309
|
-
}
|
|
1310
|
-
async close() {
|
|
1311
|
-
await this.options.close();
|
|
1312
|
-
}
|
|
1313
|
-
async getSession(id) {
|
|
1314
|
-
return (await this.db.select().from(pgSessions).where(eq(pgSessions.fSessionId, id)).execute())[0];
|
|
1315
|
-
}
|
|
1316
|
-
async getSeqMapRows(id) {
|
|
1317
|
-
return this.eventRows(this.db).where(eq(pgSessionEvents.fSessionId, id)).execute();
|
|
1318
|
-
}
|
|
1319
|
-
async getEventRows(id, fromSequence) {
|
|
1320
|
-
return (fromSequence === void 0 ? this.eventRows(this.db).where(eq(pgSessionEvents.fSessionId, id)) : this.eventRows(this.db).where(and(eq(pgSessionEvents.fSessionId, id), gte(pgSessionEvents.fSequence, fromSequence)))).orderBy(pgSessionEvents.fSequence).execute();
|
|
1321
|
-
}
|
|
1322
|
-
async listSessions() {
|
|
1323
|
-
return this.db.select().from(pgSessions).execute();
|
|
1324
|
-
}
|
|
1325
|
-
async transaction(fn) {
|
|
1326
|
-
return this.db.transaction(async (tx) => fn(this.txFor(tx)));
|
|
1327
|
-
}
|
|
1328
|
-
/** Bind the {@link BackendTx} primitives to one drizzle PG transaction handle. */
|
|
1329
|
-
txFor(tx) {
|
|
1330
|
-
return {
|
|
1331
|
-
upsertSession: (meta, incarnation) => this.upsertSession(tx, meta, incarnation),
|
|
1332
|
-
getHead: (id) => this.getHead(tx, id),
|
|
1333
|
-
insertEvents: (events) => this.insertEvents(tx, events),
|
|
1334
|
-
insertBridges: (rows) => this.insertBridges(tx, rows),
|
|
1335
|
-
updateHead: (id, headEventId, headSequence) => this.updateHead(tx, id, headEventId, headSequence),
|
|
1336
|
-
bumpRevision: (id) => this.bumpRevision(tx, id),
|
|
1337
|
-
deleteBridgeTail: (id, fromSequence) => this.deleteBridgeTail(tx, id, fromSequence),
|
|
1338
|
-
getPrevBridge: (id, sequence) => this.getPrevBridge(tx, id, sequence),
|
|
1339
|
-
getLastBridge: (id) => this.getLastBridge(tx, id),
|
|
1340
|
-
updateEventFields: (id, sequence, fields) => this.updateEventFields(tx, id, sequence, fields)
|
|
1341
|
-
};
|
|
1342
|
-
}
|
|
1343
|
-
async readMeta(exec, key) {
|
|
1344
|
-
return (await exec.select({ fValue: pgSchemaMeta.fValue }).from(pgSchemaMeta).where(eq(pgSchemaMeta.fKey, key)).execute())[0]?.fValue;
|
|
1345
|
-
}
|
|
1346
|
-
async upsertSession(exec, meta, incarnation) {
|
|
1347
|
-
await exec.insert(pgSessions).values(sessionInsertRow(meta, incarnation)).onConflictDoUpdate({
|
|
1348
|
-
target: pgSessions.fSessionId,
|
|
1349
|
-
set: sessionConflictRow(meta)
|
|
1350
|
-
}).execute();
|
|
1351
|
-
}
|
|
1352
|
-
async getHead(exec, id) {
|
|
1353
|
-
const head = (await exec.select({
|
|
1354
|
-
fHeadEventId: pgSessions.fHeadEventId,
|
|
1355
|
-
fHeadSequence: pgSessions.fHeadSequence
|
|
1356
|
-
}).from(pgSessions).where(eq(pgSessions.fSessionId, id)).execute())[0];
|
|
1357
|
-
/* v8 ignore next -- appendBatch/commitRepair always materialize the row before reading the head */
|
|
1358
|
-
if (head === void 0) throw new Error(`session "${id}" has no materialized row`);
|
|
1359
|
-
return head;
|
|
1360
|
-
}
|
|
1361
|
-
async insertEvents(exec, events) {
|
|
1362
|
-
if (events.length === 0) return;
|
|
1363
|
-
await exec.insert(pgEvents).values(events.map((event) => ({ ...event }))).execute();
|
|
1364
|
-
}
|
|
1365
|
-
async insertBridges(exec, rows) {
|
|
1366
|
-
if (rows.length === 0) return;
|
|
1367
|
-
await exec.insert(pgSessionEvents).values(rows.map((row) => ({ ...row }))).execute();
|
|
1368
|
-
}
|
|
1369
|
-
async updateHead(exec, id, headEventId, headSequence) {
|
|
1370
|
-
await exec.update(pgSessions).set({
|
|
1371
|
-
fHeadEventId: headEventId,
|
|
1372
|
-
fHeadSequence: headSequence
|
|
1373
|
-
}).where(eq(pgSessions.fSessionId, id)).execute();
|
|
1374
|
-
}
|
|
1375
|
-
async bumpRevision(exec, id) {
|
|
1376
|
-
await exec.update(pgSessions).set({ fRevision: sql`${pgSessions.fRevision} + 1` }).where(eq(pgSessions.fSessionId, id)).execute();
|
|
1377
|
-
}
|
|
1378
|
-
async deleteBridgeTail(exec, id, fromSequence) {
|
|
1379
|
-
await exec.delete(pgSessionEvents).where(and(eq(pgSessionEvents.fSessionId, id), gte(pgSessionEvents.fSequence, fromSequence))).execute();
|
|
1380
|
-
}
|
|
1381
|
-
async getPrevBridge(exec, id, sequence) {
|
|
1382
|
-
return (await exec.select({
|
|
1383
|
-
fEventId: pgSessionEvents.fEventId,
|
|
1384
|
-
fSequence: pgSessionEvents.fSequence
|
|
1385
|
-
}).from(pgSessionEvents).where(and(eq(pgSessionEvents.fSessionId, id), eq(pgSessionEvents.fSequence, sequence))).execute())[0];
|
|
1386
|
-
}
|
|
1387
|
-
async getLastBridge(exec, id) {
|
|
1388
|
-
return (await exec.select({
|
|
1389
|
-
fEventId: pgSessionEvents.fEventId,
|
|
1390
|
-
fSequence: pgSessionEvents.fSequence
|
|
1391
|
-
}).from(pgSessionEvents).where(eq(pgSessionEvents.fSessionId, id)).orderBy(desc(pgSessionEvents.fSequence)).limit(1).execute())[0];
|
|
1392
|
-
}
|
|
1393
|
-
/** The joined event-row projection shared by whole-log and suffix reads. */
|
|
1394
|
-
eventRows(exec) {
|
|
1395
|
-
return exec.select({
|
|
1396
|
-
fSequence: pgSessionEvents.fSequence,
|
|
1397
|
-
fOriginalSeq: pgEvents.fOriginalSeq,
|
|
1398
|
-
fKind: pgEvents.fKind,
|
|
1399
|
-
fCreatedAt: pgEvents.fCreatedAt,
|
|
1400
|
-
fData: pgEvents.fData,
|
|
1401
|
-
fSourceEventSeqs: pgEvents.fSourceEventSeqs,
|
|
1402
|
-
fSurfaceOp: pgEvents.fSurfaceOp
|
|
1403
|
-
}).from(pgSessionEvents).innerJoin(pgEvents, eq(pgSessionEvents.fEventId, pgEvents.fEventId));
|
|
1404
|
-
}
|
|
1405
|
-
async updateEventFields(exec, id, sequence, fields) {
|
|
1406
|
-
const eventIds = exec.select({ fEventId: pgSessionEvents.fEventId }).from(pgSessionEvents).where(and(eq(pgSessionEvents.fSessionId, id), eq(pgSessionEvents.fSequence, sequence)));
|
|
1407
|
-
await exec.update(pgEvents).set({
|
|
1408
|
-
...fields.fSourceEventSeqs === void 0 ? {} : { fSourceEventSeqs: fields.fSourceEventSeqs },
|
|
1409
|
-
...fields.fSurfaceOp === void 0 ? {} : { fSurfaceOp: fields.fSurfaceOp },
|
|
1410
|
-
...fields.fData === void 0 ? {} : { fData: fields.fData }
|
|
1411
|
-
}).where(inArray(pgEvents.fEventId, eventIds)).execute();
|
|
1412
|
-
}
|
|
1413
|
-
};
|
|
1414
|
-
//#endregion
|
|
1415
|
-
//#region src/branch.ts
|
|
1416
|
-
/**
|
|
1417
|
-
* RDB 的「额外 provider 抽象」实现:在 `@morlay/session-rdb`
|
|
1418
|
-
* 既有 `PersistenceBackend`(append-only 面)之上,实现
|
|
1419
|
-
* `@morlay/session-branch` 的 `SessionBranchProvider`(分支面:rewind /
|
|
1420
|
-
* forkFrom / readBranchPrefix),并发布 `ctx.sessionBranch` 服务
|
|
1421
|
-
* (`SessionBranchRdb extends SessionBranch`)——使本包成为 rewind /
|
|
1422
|
-
* retry / fork 的持久化闭环。
|
|
1423
|
-
*
|
|
1424
|
-
* 关键设计(不改上游代码):
|
|
1425
|
-
*
|
|
1426
|
-
* - `forkFrom` 走**标准 coordinator 路径**(`create` + `append`):派生是纯
|
|
1427
|
-
* append(新 id / parentSession / seedLength),上游 `PersistenceCoordinator`
|
|
1428
|
-
* 天然支持;seed 前缀按新会话 log 重新编号(保序重编号,`sourceEventSeqs`
|
|
1429
|
-
* 的相对序不变,`source < seq` 校验成立)。
|
|
1430
|
-
* - `rewind` 是上游没有的原语(`PersistenceCoordinator` 只有 append-only +
|
|
1431
|
-
* torn-tail 修复),因此直接操作本后端的 `Backend` 事务(DELETE 尾部 +
|
|
1432
|
-
* head 游标回退 + revision bump),随后**重新 load 同步 coordinator 状态**
|
|
1433
|
-
* (revision 变化使 `isPreparedSourceCurrent` 失效 → 重新 adopt →
|
|
1434
|
-
* `state.cursor` 与新尾部一致)并更新 `WriteGuard` 确认 head。
|
|
1435
|
-
* - 独占条件:无 live owner(`ctx.sessions.get(id) === undefined`)。prepared
|
|
1436
|
-
* reservation 无法从公开 API 查询,rewind 通过 revision 变化使其自然失效。
|
|
1437
|
-
*
|
|
1438
|
-
* @module @morlay/session-rdb/branch
|
|
1439
|
-
*/
|
|
1440
|
-
/**
|
|
1441
|
-
* 定位 `atSeq` 锚定的闭合 `turn/end` 边界 seq。见
|
|
1442
|
-
* {@link SessionBranchProvider.readBranchPrefix} 的锚定语义说明。
|
|
1443
|
-
* @param events - 完整(或前缀)事件列表,seq 连续。
|
|
1444
|
-
* @param atSeq - 锚定 seq(inclusive);省略取最后闭合轮次。
|
|
1445
|
-
* @param mode - `"after"`(默认)或 `"before"`。
|
|
1446
|
-
* @returns 边界事件 seq;`"before"` 模式下 atSeq 之前无闭合轮次时返回 -1
|
|
1447
|
-
* (空前缀)。
|
|
1448
|
-
*/
|
|
1449
|
-
function locateTurnEnd(events, atSeq, mode = "after") {
|
|
1450
|
-
const ends = events.filter((event) => event.type === "turn/end").map((event) => event.seq);
|
|
1451
|
-
if (atSeq === void 0) {
|
|
1452
|
-
const last = ends.at(-1);
|
|
1453
|
-
if (last === void 0) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
1454
|
-
return last;
|
|
1455
|
-
}
|
|
1456
|
-
if (mode === "before") {
|
|
1457
|
-
let boundary = -1;
|
|
1458
|
-
for (const seq of ends) if (seq < atSeq) boundary = seq;
|
|
1459
|
-
else break;
|
|
1460
|
-
return boundary;
|
|
1461
|
-
}
|
|
1462
|
-
const firstAfter = ends.find((seq) => seq >= atSeq);
|
|
1463
|
-
if (firstAfter !== void 0) return firstAfter;
|
|
1464
|
-
const lastStart = [...events].reverse().find((event) => event.type === "turn/start");
|
|
1465
|
-
if (lastStart !== void 0 && lastStart.seq <= atSeq) throw new SessionBranchError(`anchor ${atSeq} lies inside an open turn`, "OPEN_TURN");
|
|
1466
|
-
const last = ends.at(-1);
|
|
1467
|
-
if (last === void 0) throw new SessionBranchError("session has no closed turn", "OPEN_TURN");
|
|
1468
|
-
return last;
|
|
1469
|
-
}
|
|
1470
|
-
/** 按新会话 log 重新编号:保序重编号,`sourceEventSeqs` 相对序不变。 */
|
|
1471
|
-
function renumber(events, offset) {
|
|
1472
|
-
return events.map((event, index) => ({
|
|
1473
|
-
...event,
|
|
1474
|
-
seq: offset + index
|
|
1475
|
-
}));
|
|
1476
|
-
}
|
|
1477
|
-
/** 派生会话 id(后端 mint 策略,与 apiproxy `session-<uuid>` 一致)。 */
|
|
1478
|
-
function mintSessionId() {
|
|
1479
|
-
return `session-${randomUUID()}`;
|
|
1480
|
-
}
|
|
1481
|
-
/**
|
|
1482
|
-
* 截断 live 会话的内存 log 并重置全部派生缓存。上游 `Session` 的
|
|
1483
|
-
* `log`/`surfaceManager`/`headerFold`/`contextFold`/`derived` 都是增量缓存:
|
|
1484
|
-
* 截断 `log` 后必须同步重置,否则下一次 append 会基于陈旧状态校验/投影。
|
|
1485
|
-
* 字段名取编译后产物(`@deepseek-ai/dsh-session` lib),并对
|
|
1486
|
-
* `SurfaceManager`(持有同一 `log` 数组引用)做全量重折叠复位——
|
|
1487
|
-
* `_lastProcessedSeq = baseSeq - 1` 使下次访问重新 fold 整个截断后 log。
|
|
1488
|
-
*/
|
|
1489
|
-
function truncateLiveSession(session, newLength) {
|
|
1490
|
-
const s = session;
|
|
1491
|
-
s.log.length = newLength;
|
|
1492
|
-
s.eventsSnapshot = void 0;
|
|
1493
|
-
s.headerFold = void 0;
|
|
1494
|
-
s.headerFoldSeq = 0;
|
|
1495
|
-
s.contextFold = void 0;
|
|
1496
|
-
s.contextFoldSeq = 0;
|
|
1497
|
-
s.derived = [];
|
|
1498
|
-
s.derivedNodes = 0;
|
|
1499
|
-
s.derivedGeneration = 0;
|
|
1500
|
-
s.surfaceManager._state = {
|
|
1501
|
-
nodes: [],
|
|
1502
|
-
replaceGeneration: 0
|
|
1503
|
-
};
|
|
1504
|
-
s.surfaceManager._lastProcessedSeq = s.surfaceManager.baseSeq - 1;
|
|
1505
|
-
s.surfaceManager._pendingPlan = void 0;
|
|
1506
|
-
}
|
|
1507
|
-
/**
|
|
1508
|
-
* RDB 分支数据层实现(`SessionBranchProvider`)。与 `SessionPersistenceRdb`
|
|
1509
|
-
* 共享同一数据库连接(`Backend`)与 coordinator 写路径。
|
|
1510
|
-
*/
|
|
1511
|
-
var SessionBranchRdbProvider = class {
|
|
1512
|
-
persistence;
|
|
1513
|
-
live;
|
|
1514
|
-
name = "session-rdb";
|
|
1515
|
-
constructor(persistence, live = {
|
|
1516
|
-
getSession: () => void 0,
|
|
1517
|
-
getAgent: () => void 0,
|
|
1518
|
-
flush: async () => true,
|
|
1519
|
-
setCoordinatorCursor: () => {},
|
|
1520
|
-
setCoordinatorState: () => {}
|
|
1521
|
-
}) {
|
|
1522
|
-
this.persistence = persistence;
|
|
1523
|
-
this.live = live;
|
|
1524
|
-
}
|
|
1525
|
-
async readBranchPrefix(id, atSeq, mode = "after", signal) {
|
|
1526
|
-
const { events } = await this.readRawEvents(id, signal);
|
|
1527
|
-
const boundary = locateTurnEnd(events, atSeq, mode);
|
|
1528
|
-
return {
|
|
1529
|
-
seq: boundary,
|
|
1530
|
-
events: events.slice(0, boundary + 1)
|
|
1531
|
-
};
|
|
1532
|
-
}
|
|
1533
|
-
/**
|
|
1534
|
-
* 读取会话的**原始**事件(不含 coordinator 补记的合成 closers)。
|
|
1535
|
-
*
|
|
1536
|
-
* `persistence.inspect` 走 coordinator 的 `prepareCore`,会给未闭合 log
|
|
1537
|
-
* 补 `interruptedTurnClosers`(合成 step/end + turn/end)——对普通读取
|
|
1538
|
-
* (客户端历史)这是正确的逻辑视图,但对分支编排是误导:未闭合轮次被
|
|
1539
|
-
* closers 掩盖成闭合,编辑未闭合轮次的 user 消息会走错边界。这里用
|
|
1540
|
-
* `loadStored`(backend hook,scanRows 只做 torn-tail 切割、不补 closers)
|
|
1541
|
-
* 读原始事件,使未闭合状态真实可见。
|
|
1542
|
-
*/
|
|
1543
|
-
async readRawEvents(id, signal) {
|
|
1544
|
-
const stored = await this.persistence.loadStored(id, signal);
|
|
1545
|
-
if (stored === void 0) throw new SessionBranchError(`session "${id}" not found`, "SESSION_NOT_FOUND");
|
|
1546
|
-
return {
|
|
1547
|
-
meta: stored.meta,
|
|
1548
|
-
events: stored.events
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
async forkFrom(sourceId, options = {}, signal) {
|
|
1552
|
-
signal?.throwIfAborted();
|
|
1553
|
-
const { atSeq, anchorMode = "after", seedSuffix = [], childSessionId, meta = {} } = options;
|
|
1554
|
-
const source = await this.persistence.inspect(sourceId, signal);
|
|
1555
|
-
const boundary = locateTurnEnd(source.events, atSeq, anchorMode);
|
|
1556
|
-
const prefix = source.events.slice(0, boundary + 1);
|
|
1557
|
-
const childId = childSessionId ?? mintSessionId();
|
|
1558
|
-
const childMeta = {
|
|
1559
|
-
version: SESSION_FORMAT_VERSION,
|
|
1560
|
-
id: childId,
|
|
1561
|
-
createdAt: meta.createdAt ?? Date.now(),
|
|
1562
|
-
...meta.cwd !== void 0 ? { cwd: meta.cwd } : source.meta.cwd !== void 0 ? { cwd: source.meta.cwd } : {},
|
|
1563
|
-
parentSession: sourceId,
|
|
1564
|
-
seedLength: prefix.length,
|
|
1565
|
-
...meta.agentPreset !== void 0 ? { agentPreset: meta.agentPreset } : source.meta.agentPreset !== void 0 ? { agentPreset: source.meta.agentPreset } : {},
|
|
1566
|
-
...meta.origin !== void 0 ? { origin: meta.origin } : {},
|
|
1567
|
-
...meta.delegationDepth !== void 0 ? { delegationDepth: meta.delegationDepth } : {}
|
|
1568
|
-
};
|
|
1569
|
-
const seed = [...renumber(prefix, 0), ...renumber(seedSuffix, prefix.length)];
|
|
1570
|
-
await this.persistence.create(childMeta);
|
|
1571
|
-
if (seed.length > 0) await this.persistence.append(childId, seed);
|
|
1572
|
-
return childId;
|
|
1573
|
-
}
|
|
1574
|
-
async rewind(id, toBoundary, signal) {
|
|
1575
|
-
signal?.throwIfAborted();
|
|
1576
|
-
if (!Number.isSafeInteger(toBoundary) || toBoundary < -1) throw new SessionBranchError(`rewind boundary must be a non-negative safe integer, got ${toBoundary}`, "INVALID_BOUNDARY");
|
|
1577
|
-
const live = this.live.getSession(id);
|
|
1578
|
-
if (live !== void 0) await this.live.flush(live);
|
|
1579
|
-
const raw = live === void 0 ? await this.readRawEvents(id, signal) : void 0;
|
|
1580
|
-
const inspection = live === void 0 ? void 0 : await this.persistence.inspect(id, signal);
|
|
1581
|
-
const events = live === void 0 ? raw.events : inspection.events;
|
|
1582
|
-
const meta = live === void 0 ? raw.meta : inspection.meta;
|
|
1583
|
-
const boundaryEvent = events[toBoundary];
|
|
1584
|
-
if (toBoundary === -1) {} else if (boundaryEvent === void 0) throw new SessionBranchError(`rewind boundary ${toBoundary} does not exist in session "${id}"`, "INVALID_BOUNDARY");
|
|
1585
|
-
else if (boundaryEvent.type !== "turn/end" && boundaryEvent.type !== "user/message") throw new SessionBranchError(`rewind boundary ${toBoundary} is not a turn/end or user/message (${boundaryEvent.type})`, "INVALID_BOUNDARY");
|
|
1586
|
-
const rawKeepLength = toBoundary === -1 ? 0 : boundaryEvent.type === "turn/end" ? toBoundary + 1 : toBoundary;
|
|
1587
|
-
const keepLength = balanceRewindPrefix(events.slice(0, rawKeepLength)).length;
|
|
1588
|
-
const internals = this.persistence.internals();
|
|
1589
|
-
const denseBoundary = live === void 0 ? keepLength - 1 : events.slice(0, keepLength).filter(isPersistedEvent).length - 1;
|
|
1590
|
-
await internals.backend.transaction(async (tx) => {
|
|
1591
|
-
signal?.throwIfAborted();
|
|
1592
|
-
const head = await tx.getHead(id);
|
|
1593
|
-
if (denseBoundary > head.fHeadSequence) throw new SessionBranchError(`rewind boundary ${toBoundary} is beyond the stored head ${head.fHeadSequence}`, "INVALID_BOUNDARY");
|
|
1594
|
-
if (denseBoundary < head.fHeadSequence) {
|
|
1595
|
-
await tx.deleteBridgeTail(id, denseBoundary + 1);
|
|
1596
|
-
const prev = denseBoundary === -1 ? void 0 : await tx.getPrevBridge(id, denseBoundary);
|
|
1597
|
-
if (prev === void 0) await tx.updateHead(id, "", -1);
|
|
1598
|
-
else await tx.updateHead(id, prev.fEventId, prev.fSequence);
|
|
1599
|
-
}
|
|
1600
|
-
await tx.bumpRevision(id);
|
|
1601
|
-
});
|
|
1602
|
-
internals.writeGuard.confirmHead(id, denseBoundary);
|
|
1603
|
-
if (live !== void 0) {
|
|
1604
|
-
truncateLiveSession(live, keepLength);
|
|
1605
|
-
const agent = this.live.getAgent(id);
|
|
1606
|
-
if (agent !== void 0) {
|
|
1607
|
-
agent.requestHeaderLogged = false;
|
|
1608
|
-
const lastTurn = live.events.findLast((e) => e.type === "turn/start")?.data.turn ?? 0;
|
|
1609
|
-
const phase = agent.phase;
|
|
1610
|
-
if (phase !== void 0) phase.lastTurn = lastTurn;
|
|
1611
|
-
}
|
|
1612
|
-
this.live.setCoordinatorCursor(id, keepLength);
|
|
1613
|
-
} else if (boundaryEvent?.type === "user/message") this.live.setCoordinatorState(id, keepLength, meta);
|
|
1614
|
-
else await this.persistence.load(id);
|
|
1615
|
-
const row = await internals.backend.getSession(id);
|
|
1616
|
-
if (row === void 0) return {
|
|
1617
|
-
header: {
|
|
1618
|
-
version: SESSION_FORMAT_VERSION,
|
|
1619
|
-
id,
|
|
1620
|
-
createdAt: meta.createdAt,
|
|
1621
|
-
...meta.cwd !== void 0 ? { cwd: meta.cwd } : {},
|
|
1622
|
-
...meta.parentSession !== void 0 ? { parentSession: meta.parentSession } : {},
|
|
1623
|
-
...meta.seedLength !== void 0 ? { seedLength: meta.seedLength } : {},
|
|
1624
|
-
...meta.origin !== void 0 ? { origin: meta.origin } : {},
|
|
1625
|
-
...meta.delegationDepth !== void 0 ? { delegationDepth: meta.delegationDepth } : {},
|
|
1626
|
-
...meta.agentPreset !== void 0 ? { agentPreset: meta.agentPreset } : {}
|
|
1627
|
-
},
|
|
1628
|
-
revision: await internals.readStoredRevision(id) ?? await this.persistence.readStoredRevision(id)
|
|
1629
|
-
};
|
|
1630
|
-
return {
|
|
1631
|
-
header: rowToMeta(row),
|
|
1632
|
-
revision: await internals.readStoredRevision(id)
|
|
1633
|
-
};
|
|
1634
|
-
}
|
|
1635
|
-
};
|
|
1636
|
-
/**
|
|
1637
|
-
* RDB 的 `ctx.sessionBranch` 服务:组合 {@link SessionBranchRdbProvider} 的
|
|
1638
|
-
* 数据层原语与共享版本树投影。插件把本类注册为 `sessionBranch` 服务后,
|
|
1639
|
-
* 编排层即可通过统一服务面完成 rewind / retry / fork。
|
|
1640
|
-
*/
|
|
1641
|
-
var SessionBranchRdb = class extends SessionBranch {
|
|
1642
|
-
static inject = ["sessionPersistence", "sessions"];
|
|
1643
|
-
constructor(ctx) {
|
|
1644
|
-
super(ctx);
|
|
1645
|
-
}
|
|
1646
|
-
provider = new SessionBranchRdbProvider(this.ctx.sessionPersistence, {
|
|
1647
|
-
getSession: (id) => this.ctx.sessions.get(id),
|
|
1648
|
-
getAgent: (id) => {
|
|
1649
|
-
return this.ctx.get("agents")?.get(id);
|
|
1650
|
-
},
|
|
1651
|
-
flush: (session) => this.ctx.sessions.flush(session),
|
|
1652
|
-
setCoordinatorCursor: (id, cursor) => {
|
|
1653
|
-
const state = this.ctx.sessionPersistence.coordinator?.states?.get(id);
|
|
1654
|
-
if (state !== void 0) state.cursor = cursor;
|
|
1655
|
-
},
|
|
1656
|
-
setCoordinatorState: (id, cursor, meta) => {
|
|
1657
|
-
const states = this.ctx.sessionPersistence.coordinator?.states;
|
|
1658
|
-
if (states === void 0) return;
|
|
1659
|
-
const state = states.get(id);
|
|
1660
|
-
if (state !== void 0) state.cursor = cursor;
|
|
1661
|
-
else states.set(id, {
|
|
1662
|
-
meta,
|
|
1663
|
-
cursor,
|
|
1664
|
-
materialized: true
|
|
1665
|
-
});
|
|
1666
|
-
}
|
|
1667
|
-
});
|
|
1668
|
-
readBranchPrefix(id, atSeq, mode, signal) {
|
|
1669
|
-
return this.provider.readBranchPrefix(id, atSeq, mode, signal);
|
|
1670
|
-
}
|
|
1671
|
-
/**
|
|
1672
|
-
* 读取会话的**原始**事件(不含 coordinator 补记的合成 closers)。
|
|
1673
|
-
* 编排层用它识别未闭合轮次(inspect 会把未闭合 log 补成闭合)。
|
|
1674
|
-
*/
|
|
1675
|
-
readRawEvents(id, signal) {
|
|
1676
|
-
return this.provider.readRawEvents(id, signal);
|
|
1677
|
-
}
|
|
1678
|
-
forkFrom(sourceId, options, signal) {
|
|
1679
|
-
return this.provider.forkFrom(sourceId, options, signal);
|
|
1680
|
-
}
|
|
1681
|
-
rewind(id, toBoundary, signal) {
|
|
1682
|
-
return this.provider.rewind(id, toBoundary, signal);
|
|
1683
|
-
}
|
|
1684
|
-
/**
|
|
1685
|
-
* 清洗一个 session 的 surface/provenance 坐标到稠密空间(持久化层透传)。
|
|
1686
|
-
* 旧数据(rewind 前写入)的 sourceEventSeqs / surfaceOp / shadowedRange
|
|
1687
|
-
* 是上游坐标,读取时每次都要对齐;清洗一次性写回稠密坐标,之后读取无需
|
|
1688
|
-
* 对齐。返回实际变更的事件数。
|
|
1689
|
-
*/
|
|
1690
|
-
cleanseSession(sessionId, signal) {
|
|
1691
|
-
return this.ctx.sessionPersistence.cleanseSession(sessionId, signal);
|
|
1692
|
-
}
|
|
1693
|
-
/**
|
|
1694
|
-
* 同步 live 会话的 coordinator 内存 cursor,跳过 ignorable 占位事件。
|
|
1695
|
-
* 编排层在 rewind 后把 ignorable 版本效果 push 进 live log(不发布、不
|
|
1696
|
-
* 进缓冲)——它占用一个上游 seq,但 coordinator 的 cursor(rewind 设到
|
|
1697
|
-
* 截断后长度)看不见它;不跳过的话,flush 的 `appendLiveBatch` 会以
|
|
1698
|
-
* `e.seq >= cursor` 过滤掉 cursor 之前的事件(manualTurn 永远不落盘),
|
|
1699
|
-
* 或 `appendCore` 的 seq 连续性校验错位。这里把 cursor 从当前值起跳过
|
|
1700
|
-
* 连续的 ignorable 事件,对齐到下一个待持久化事件的 seq。
|
|
1701
|
-
*/
|
|
1702
|
-
syncLiveCursor(sessionId) {
|
|
1703
|
-
const live = this.ctx.sessions.get(sessionId);
|
|
1704
|
-
if (live === void 0) return;
|
|
1705
|
-
const state = this.ctx.sessionPersistence.coordinator?.states?.get(sessionId);
|
|
1706
|
-
if (state === void 0) return;
|
|
1707
|
-
let cursor = state.cursor;
|
|
1708
|
-
while (live.events[cursor]?.ignorable === true) cursor += 1;
|
|
1709
|
-
state.cursor = cursor;
|
|
1710
|
-
}
|
|
1711
|
-
async timeline(sessionId, signal) {
|
|
1712
|
-
const persistence = this.ctx.sessionPersistence;
|
|
1713
|
-
const snapshots = await persistence.listSnapshots(signal);
|
|
1714
|
-
const readOwnEvents = async (id, fromSeq, s) => {
|
|
1715
|
-
const live = this.ctx.sessions.get(id);
|
|
1716
|
-
if (live !== void 0) return live.events.slice(fromSeq);
|
|
1717
|
-
return (await persistence.readFrom(id, fromSeq, s)).events;
|
|
1718
|
-
};
|
|
1719
|
-
return buildTimeline(snapshots, readOwnEvents, sessionId, signal);
|
|
1720
|
-
}
|
|
1721
|
-
};
|
|
1722
|
-
//#endregion
|
|
1723
|
-
//#region src/index.ts
|
|
1724
|
-
/**
|
|
1725
|
-
* The persistence backend. Load as a plugin; it registers as
|
|
1726
|
-
* `ctx.sessionPersistence` and (via the coordinator) installs the write-path
|
|
1727
|
-
* listeners. Its torn-tail marker is the persisted seq to delete from.
|
|
1728
|
-
*
|
|
1729
|
-
* Configuration resolution: `$DSH_HOME/settings.yaml` 的
|
|
1730
|
-
* `session-rdb` namespace(settings 服务)覆盖 cordis 层 entry
|
|
1731
|
-
* config,见 {@link installSettingsSection}。
|
|
1732
|
-
*/
|
|
1733
|
-
var SessionPersistenceRdb = class SessionPersistenceRdb extends SessionPersistence {
|
|
1734
|
-
config;
|
|
1735
|
-
static inject = ["sessions", "settings"];
|
|
1736
|
-
static Config = z.union([z.object({
|
|
1737
|
-
type: z.const("sqlite"),
|
|
1738
|
-
path: z.string().required(),
|
|
1739
|
-
journalMode: z.union([
|
|
1740
|
-
"wal",
|
|
1741
|
-
"delete",
|
|
1742
|
-
"truncate",
|
|
1743
|
-
"persist"
|
|
1744
|
-
]).default("wal"),
|
|
1745
|
-
busyTimeout: z.number().step(1).min(0).default(DEFAULT_BUSY_TIMEOUT_MS)
|
|
1746
|
-
}), z.object({
|
|
1747
|
-
type: z.const("postgres"),
|
|
1748
|
-
connectionString: z.string().required()
|
|
1749
|
-
})]);
|
|
1750
|
-
/** settings namespace:`$DSH_HOME/settings.yaml` 的 `session-rdb` section。 */
|
|
1751
|
-
static settingsNs = settingsNamespace("session-rdb");
|
|
1752
|
-
/**
|
|
1753
|
-
* Backend label for the coordinator's dispose diagnostics. Intentionally
|
|
1754
|
-
* shadows cordis `Service.name` (set to `'sessionPersistence'` by the base);
|
|
1755
|
-
* see the JSONL backend for why this does not affect service resolution.
|
|
1756
|
-
*/
|
|
1757
|
-
name = "session-rdb";
|
|
1758
|
-
/** One RDB database holds every session; there is no per-session raw artifact. */
|
|
1759
|
-
supportsRawArtifacts = false;
|
|
1760
|
-
backend;
|
|
1761
|
-
storeIdentity;
|
|
1762
|
-
ready;
|
|
1763
|
-
coordinator;
|
|
1764
|
-
/**
|
|
1765
|
-
* Write-authority state: the confirmed dense head per session (concurrent-
|
|
1766
|
-
* writer detection) and the dropped delta seqs per session (provenance
|
|
1767
|
-
* pruning). See {@link WriteGuard} for the timing contract.
|
|
1768
|
-
*/
|
|
1769
|
-
writeGuard = new WriteGuard();
|
|
1770
|
-
constructor(ctx, config, injectedBackend) {
|
|
1771
|
-
let resolved = config;
|
|
1772
|
-
const settings = ctx.reflect.get("settings");
|
|
1773
|
-
if (settings !== void 0) {
|
|
1774
|
-
const scope = settings.register(SessionPersistenceRdb.settingsNs, SessionPersistenceRdb.Config, { base: config });
|
|
1775
|
-
resolved = scope.get();
|
|
1776
|
-
scope.watch(() => {
|
|
1777
|
-
ctx.logger.warn("session-rdb: settings changed; restart to apply the new configuration");
|
|
1778
|
-
});
|
|
1779
|
-
}
|
|
1780
|
-
super(ctx);
|
|
1781
|
-
this.config = config;
|
|
1782
|
-
this.config = resolved;
|
|
1783
|
-
this.backend = injectedBackend ?? createBackend(resolved);
|
|
1784
|
-
this.ready = this.init();
|
|
1785
|
-
this.coordinator = new PersistenceCoordinator(this.ctx, this);
|
|
1786
|
-
new SessionBranchRdb(this.ctx);
|
|
1787
|
-
}
|
|
1788
|
-
async init() {
|
|
1789
|
-
await this.backend.open();
|
|
1790
|
-
this.storeIdentity = this.backend.storeIdentity;
|
|
1791
|
-
}
|
|
1792
|
-
/** The backend has one database, not an independent local artifact per session. */
|
|
1793
|
-
locate(_meta) {}
|
|
1794
|
-
create(meta) {
|
|
1795
|
-
return this.coordinator.create(meta);
|
|
1796
|
-
}
|
|
1797
|
-
append(id, events) {
|
|
1798
|
-
return this.coordinator.append(id, events);
|
|
1799
|
-
}
|
|
1800
|
-
load(id) {
|
|
1801
|
-
return this.coordinator.load(id);
|
|
1802
|
-
}
|
|
1803
|
-
inspect(id, signal) {
|
|
1804
|
-
return this.coordinator.inspect(id, signal);
|
|
1805
|
-
}
|
|
1806
|
-
readFrom(id, fromSeq, signal) {
|
|
1807
|
-
return this.coordinator.readFrom(id, fromSeq, signal);
|
|
1808
|
-
}
|
|
1809
|
-
/** Read a stored prefix by id (ids are globally unique — no scope to scan). */
|
|
1810
|
-
loadStored(id, signal) {
|
|
1811
|
-
return this.readPrefix(id, signal);
|
|
1812
|
-
}
|
|
1813
|
-
/**
|
|
1814
|
-
* Seek-capable suffix read: the backend selects `f_sequence >= fromSeq`
|
|
1815
|
-
* directly, so the read scales with the suffix, not the log. Provenance
|
|
1816
|
-
* remapping still needs every row's upstream seq, so a lightweight
|
|
1817
|
-
* two-column map is read alongside. Torn rows past the preserved region are
|
|
1818
|
-
* dropped, never repaired (non-mutating read).
|
|
1819
|
-
*/
|
|
1820
|
-
async loadStoredFrom(id, fromSeq, signal) {
|
|
1821
|
-
const log = await this.readLog(id, { fromSeq }, signal);
|
|
1822
|
-
if (log === void 0) return void 0;
|
|
1823
|
-
return {
|
|
1824
|
-
meta: log.meta,
|
|
1825
|
-
events: log.events
|
|
1826
|
-
};
|
|
1827
|
-
}
|
|
1828
|
-
/**
|
|
1829
|
-
* Read a session's row + ordered events into a {@link StoredPrefix}. The
|
|
1830
|
-
* torn-tail marker is the persisted seq from which a never-committed tail
|
|
1831
|
-
* must be deleted (`scanRows` already returns it as `number | undefined`).
|
|
1832
|
-
* Records the confirmed dense head (or confirmed absence) so a later
|
|
1833
|
-
* `appendBatch` can detect a second writer that advanced the log.
|
|
1834
|
-
*/
|
|
1835
|
-
async readPrefix(id, signal) {
|
|
1836
|
-
const log = await this.readLog(id, {}, signal);
|
|
1837
|
-
if (log === void 0) {
|
|
1838
|
-
this.writeGuard.confirmHead(id, -1);
|
|
1839
|
-
return;
|
|
1840
|
-
}
|
|
1841
|
-
this.writeGuard.confirmHead(id, log.events.at(-1)?.seq ?? -1);
|
|
1842
|
-
normalizeSurfaceReplaceProvenance(log.events);
|
|
1843
|
-
return {
|
|
1844
|
-
meta: log.meta,
|
|
1845
|
-
events: log.events,
|
|
1846
|
-
revision: SessionPersistenceRevision(`${this.storeIdentity}:incarnation:${log.incarnation}:revision:${log.revision}`),
|
|
1847
|
-
...log.tornFrom !== void 0 ? { tornMarker: log.tornFrom } : {}
|
|
1848
|
-
};
|
|
1849
|
-
}
|
|
1850
|
-
/**
|
|
1851
|
-
* Read the current source-qualified revision for one stored session without
|
|
1852
|
-
* loading its event log. Returns `undefined` when the identity is absent.
|
|
1853
|
-
* The representation matches {@link loadStored}'s `revision` and
|
|
1854
|
-
* {@link listSnapshots} — the coordinator compares them with `===`.
|
|
1855
|
-
*/
|
|
1856
|
-
async readStoredRevision(id, signal) {
|
|
1857
|
-
signal?.throwIfAborted();
|
|
1858
|
-
await this.ready;
|
|
1859
|
-
signal?.throwIfAborted();
|
|
1860
|
-
const row = await this.backend.getSession(id);
|
|
1861
|
-
if (row === void 0) return void 0;
|
|
1862
|
-
return SessionPersistenceRevision(`${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`);
|
|
1863
|
-
}
|
|
1864
|
-
/**
|
|
1865
|
-
* Shared read pipeline: session row → meta, event rows → preserved prefix.
|
|
1866
|
-
* A whole-log read (`fromSeq` absent) builds the seq map from the same rows;
|
|
1867
|
-
* a suffix read keeps the backend's lightweight two-column seq-map source so
|
|
1868
|
-
* the query still scales with the suffix, not the log.
|
|
1869
|
-
*/
|
|
1870
|
-
async readLog(id, options = {}, signal) {
|
|
1871
|
-
signal?.throwIfAborted();
|
|
1872
|
-
await this.ready;
|
|
1873
|
-
signal?.throwIfAborted();
|
|
1874
|
-
const row = await this.backend.getSession(id);
|
|
1875
|
-
if (row === void 0) return void 0;
|
|
1876
|
-
const meta = rowToMeta(row);
|
|
1877
|
-
let eventRows;
|
|
1878
|
-
let seqMap;
|
|
1879
|
-
let denseTypeMap;
|
|
1880
|
-
if (options.fromSeq === void 0) {
|
|
1881
|
-
eventRows = await this.backend.getEventRows(id);
|
|
1882
|
-
seqMap = buildSeqMap(eventRows);
|
|
1883
|
-
denseTypeMap = new Map(eventRows.map((r) => [r.fSequence, r.fKind]));
|
|
1884
|
-
} else {
|
|
1885
|
-
eventRows = await this.backend.getEventRows(id, options.fromSeq);
|
|
1886
|
-
const seqRows = await this.backend.getSeqMapRows(id);
|
|
1887
|
-
seqMap = buildSeqMap(seqRows);
|
|
1888
|
-
denseTypeMap = new Map(seqRows.map((r) => [r.fSequence, r.fKind]));
|
|
1889
|
-
}
|
|
1890
|
-
signal?.throwIfAborted();
|
|
1891
|
-
const { preserved, tornFrom } = scanRows(eventRows, options.fromSeq ?? 0, seqMap, denseTypeMap);
|
|
1892
|
-
return {
|
|
1893
|
-
meta,
|
|
1894
|
-
events: preserved,
|
|
1895
|
-
incarnation: row.fIncarnation,
|
|
1896
|
-
revision: row.fRevision,
|
|
1897
|
-
...tornFrom !== void 0 ? { tornFrom } : {}
|
|
1898
|
-
};
|
|
1899
|
-
}
|
|
1900
|
-
/**
|
|
1901
|
-
* Durably append a batch in ONE transaction: materialize the sessions row (if
|
|
1902
|
-
* lazy) and INSERT every persisted event (plus its bridge row), or roll back
|
|
1903
|
-
* entirely. Delta events and events the writer marked `ignorable` are dropped
|
|
1904
|
-
* and the surviving events are re-numbered densely from the session's head
|
|
1905
|
-
* cursor; a batch that contains only dropped events is a no-op (no row
|
|
1906
|
-
* materialization, no revision bump). Dropped events' upstream seqs are
|
|
1907
|
-
* recorded per session so a later batch's surface provenance can prune
|
|
1908
|
-
* references to them (see {@link surfaceBindings}).
|
|
1909
|
-
* The transaction is the atomicity + durability boundary, so a mid-batch
|
|
1910
|
-
* failure (a UNIQUE violation on a duplicated seq) leaves the stored log
|
|
1911
|
-
* untouched.
|
|
1912
|
-
*
|
|
1913
|
-
* SQLite acquires the write lock up front (`BEGIN IMMEDIATE`, queued behind
|
|
1914
|
-
* `busy_timeout`); PostgreSQL relies on the transaction's row locks and the
|
|
1915
|
-
* `UNIQUE (f_session_id, f_sequence)` constraint to reject a colliding batch.
|
|
1916
|
-
* Either way {@link assertNoConcurrentWriter} rejects a second writer before
|
|
1917
|
-
* re-numbering — a session has exactly one writer per log, and a second
|
|
1918
|
-
* writer fails loud instead of corrupting the log.
|
|
1919
|
-
*
|
|
1920
|
-
* The row upsert runs UNCONDITIONALLY, not only when `!isMaterialized`: a
|
|
1921
|
-
* delta-only batch leaves the coordinator's materialized flag true while no
|
|
1922
|
-
* row exists, so the flag cannot be trusted as the row's existence signal.
|
|
1923
|
-
* The upsert keeps an existing row's head cursor (only header columns are
|
|
1924
|
-
* refreshed on conflict), so a fresh row still starts at the initial head.
|
|
1925
|
-
*/
|
|
1926
|
-
async appendBatch(meta, events, _isMaterialized) {
|
|
1927
|
-
await this.ready;
|
|
1928
|
-
const droppedSeqs = /* @__PURE__ */ new Set();
|
|
1929
|
-
for (const event of events) if (!isPersistedEvent(event)) droppedSeqs.add(event.seq);
|
|
1930
|
-
if (droppedSeqs.size > 0) this.writeGuard.noteDropped(meta.id, droppedSeqs);
|
|
1931
|
-
const persisted = events.filter(isPersistedEvent);
|
|
1932
|
-
if (persisted.length === 0) return;
|
|
1933
|
-
let confirmedHead = -1;
|
|
1934
|
-
await this.backend.transaction(async (tx) => {
|
|
1935
|
-
await tx.upsertSession(meta, randomUUID());
|
|
1936
|
-
const head = await tx.getHead(meta.id);
|
|
1937
|
-
this.writeGuard.assertNoConcurrentWriter(meta.id, head.fHeadSequence);
|
|
1938
|
-
const { headEventId, headSequence } = await appendEventTail(tx, meta, persisted, {
|
|
1939
|
-
parentId: head.fHeadEventId,
|
|
1940
|
-
nextSeq: head.fHeadSequence + 1
|
|
1941
|
-
}, (refs) => this.writeGuard.pruneRefs(meta.id, refs));
|
|
1942
|
-
await tx.updateHead(meta.id, headEventId, headSequence);
|
|
1943
|
-
await tx.bumpRevision(meta.id);
|
|
1944
|
-
confirmedHead = headSequence;
|
|
1945
|
-
});
|
|
1946
|
-
this.writeGuard.confirmHead(meta.id, confirmedHead);
|
|
1947
|
-
}
|
|
1948
|
-
/**
|
|
1949
|
-
* Make a crash repair durable in ONE transaction: DELETE the torn tail (from
|
|
1950
|
-
* `tornMarker`), rewind the head cursor to the last surviving event, INSERT
|
|
1951
|
-
* the synthetic `closers`, and bump the revision once. After COMMIT the
|
|
1952
|
-
* stored rows == the balanced log.
|
|
1953
|
-
*/
|
|
1954
|
-
async commitRepair(meta, tornMarker, closers) {
|
|
1955
|
-
await this.ready;
|
|
1956
|
-
const persistedClosers = closers.filter(isPersistedEvent);
|
|
1957
|
-
if (tornMarker === void 0 && persistedClosers.length === 0) return;
|
|
1958
|
-
await this.backend.transaction(async (tx) => {
|
|
1959
|
-
if (tornMarker !== void 0) {
|
|
1960
|
-
await tx.deleteBridgeTail(meta.id, tornMarker);
|
|
1961
|
-
const prev = await tx.getPrevBridge(meta.id, tornMarker - 1);
|
|
1962
|
-
if (prev === void 0) await tx.updateHead(meta.id, "", -1);
|
|
1963
|
-
else await tx.updateHead(meta.id, prev.fEventId, prev.fSequence);
|
|
1964
|
-
}
|
|
1965
|
-
if (persistedClosers.length > 0) {
|
|
1966
|
-
const last = await tx.getLastBridge(meta.id);
|
|
1967
|
-
const { headEventId, headSequence } = await appendEventTail(tx, meta, persistedClosers, {
|
|
1968
|
-
parentId: last?.fEventId ?? "",
|
|
1969
|
-
nextSeq: (last?.fSequence ?? -1) + 1
|
|
1970
|
-
});
|
|
1971
|
-
await tx.updateHead(meta.id, headEventId, headSequence);
|
|
1972
|
-
}
|
|
1973
|
-
await tx.bumpRevision(meta.id);
|
|
1974
|
-
});
|
|
1975
|
-
const row = await this.backend.getSession(meta.id);
|
|
1976
|
-
this.writeGuard.confirmHead(meta.id, row?.fHeadSequence ?? -1);
|
|
1977
|
-
}
|
|
1978
|
-
/**
|
|
1979
|
-
* 一次性清洗一个 session 的 surface/provenance 坐标到稠密空间并写回。
|
|
1980
|
-
*
|
|
1981
|
-
* 旧数据(rewind 前的代码写入)的 `sourceEventSeqs`、`surfaceOp` replace
|
|
1982
|
-
* range 与 compaction `shadowedRange` 是上游坐标,读取时每次都要做坐标
|
|
1983
|
-
* 解析对齐;清洗用与读取完全相同的解析(稠密优先 + 上游映射 + 剪枝 +
|
|
1984
|
-
* replace provenance 补全)把它们重写为稠密坐标,之后读取无需再对齐
|
|
1985
|
-
* (对已清洗数据解析恒为恒等)。torn tail 片段不参与(读取路径也不会
|
|
1986
|
-
* 保留它们)。返回实际变更的事件数。
|
|
1987
|
-
*/
|
|
1988
|
-
async cleanseSession(id, signal) {
|
|
1989
|
-
signal?.throwIfAborted();
|
|
1990
|
-
await this.ready;
|
|
1991
|
-
signal?.throwIfAborted();
|
|
1992
|
-
const eventRows = await this.backend.getEventRows(id);
|
|
1993
|
-
if (eventRows.length === 0) return { changed: 0 };
|
|
1994
|
-
const { preserved } = scanRows(eventRows, 0, buildSeqMap(eventRows), new Map(eventRows.map((r) => [r.fSequence, r.fKind])));
|
|
1995
|
-
normalizeSurfaceReplaceProvenance(preserved);
|
|
1996
|
-
const bySeq = new Map(preserved.map((event) => [event.seq, event]));
|
|
1997
|
-
const updates = [];
|
|
1998
|
-
for (const row of eventRows) {
|
|
1999
|
-
const event = bySeq.get(row.fSequence);
|
|
2000
|
-
if (event === void 0) continue;
|
|
2001
|
-
const surface = event;
|
|
2002
|
-
const newSeqs = surface.sourceEventSeqs === void 0 ? null : JSON.stringify(surface.sourceEventSeqs);
|
|
2003
|
-
const newOp = surface.surfaceOp === void 0 ? null : JSON.stringify(surface.surfaceOp);
|
|
2004
|
-
const fields = {};
|
|
2005
|
-
if (newSeqs !== row.fSourceEventSeqs) fields.fSourceEventSeqs = newSeqs;
|
|
2006
|
-
if (newOp !== row.fSurfaceOp) fields.fSurfaceOp = newOp;
|
|
2007
|
-
if (row.fKind === "compaction/summary" || row.fKind === "compaction/prune") {
|
|
2008
|
-
const storedData = JSON.parse(row.fData);
|
|
2009
|
-
const newData = event.data;
|
|
2010
|
-
if (JSON.stringify(storedData.shadowedRange) !== JSON.stringify(newData.shadowedRange)) fields.fData = JSON.stringify(event.data);
|
|
2011
|
-
}
|
|
2012
|
-
if (Object.keys(fields).length > 0) updates.push({
|
|
2013
|
-
fSequence: row.fSequence,
|
|
2014
|
-
...fields
|
|
2015
|
-
});
|
|
2016
|
-
}
|
|
2017
|
-
if (updates.length === 0) return { changed: 0 };
|
|
2018
|
-
await this.backend.transaction(async (tx) => {
|
|
2019
|
-
for (const update of updates) await tx.updateEventFields(id, update.fSequence, {
|
|
2020
|
-
...update.fSourceEventSeqs === void 0 ? {} : { fSourceEventSeqs: update.fSourceEventSeqs },
|
|
2021
|
-
...update.fSurfaceOp === void 0 ? {} : { fSurfaceOp: update.fSurfaceOp },
|
|
2022
|
-
...update.fData === void 0 ? {} : { fData: update.fData }
|
|
2023
|
-
});
|
|
2024
|
-
});
|
|
2025
|
-
return { changed: updates.length };
|
|
2026
|
-
}
|
|
2027
|
-
/** List all materialized sessions' metadata (every row is a materialized session). */
|
|
2028
|
-
async list(signal) {
|
|
2029
|
-
signal?.throwIfAborted();
|
|
2030
|
-
await this.ready;
|
|
2031
|
-
signal?.throwIfAborted();
|
|
2032
|
-
const rows = await this.backend.listSessions();
|
|
2033
|
-
signal?.throwIfAborted();
|
|
2034
|
-
return rows.map(rowToMeta);
|
|
2035
|
-
}
|
|
2036
|
-
/** List metadata with a source-qualified monotonic revision per session. */
|
|
2037
|
-
async listSnapshots(signal) {
|
|
2038
|
-
signal?.throwIfAborted();
|
|
2039
|
-
await this.ready;
|
|
2040
|
-
signal?.throwIfAborted();
|
|
2041
|
-
const rows = await this.backend.listSessions();
|
|
2042
|
-
signal?.throwIfAborted();
|
|
2043
|
-
return rows.map((row) => ({
|
|
2044
|
-
header: rowToMeta(row),
|
|
2045
|
-
revision: SessionPersistenceRevision(`${this.storeIdentity}:incarnation:${row.fIncarnation}:revision:${row.fRevision}`)
|
|
2046
|
-
}));
|
|
2047
|
-
}
|
|
2048
|
-
/** Close the database connection (awaited by the coordinator's dispose, post-drain). */
|
|
2049
|
-
async close() {
|
|
2050
|
-
await this.ready;
|
|
2051
|
-
await this.backend.close();
|
|
2052
|
-
}
|
|
2053
|
-
/**
|
|
2054
|
-
* 同包分支 provider 的内部访问面(rewind / forkFrom 共享后端与写路径)。
|
|
2055
|
-
* @internal 仅供 `SessionBranchRdbProvider` 使用;不是公开 API。
|
|
2056
|
-
*/
|
|
2057
|
-
internals() {
|
|
2058
|
-
return {
|
|
2059
|
-
backend: this.backend,
|
|
2060
|
-
writeGuard: this.writeGuard,
|
|
2061
|
-
create: (meta) => this.create(meta),
|
|
2062
|
-
append: (id, events) => this.append(id, events),
|
|
2063
|
-
load: (id) => this.load(id),
|
|
2064
|
-
inspect: (id, signal) => this.inspect(id, signal),
|
|
2065
|
-
readFrom: (id, fromSeq, signal) => this.readFrom(id, fromSeq, signal),
|
|
2066
|
-
listSnapshots: (signal) => this.listSnapshots(signal),
|
|
2067
|
-
readStoredRevision: (id, signal) => this.readStoredRevision(id, signal)
|
|
2068
|
-
};
|
|
2069
|
-
}
|
|
2070
|
-
};
|
|
2071
|
-
/**
|
|
2072
|
-
* Build the configured backend. The PostgreSQL arm creates the `node-postgres`
|
|
2073
|
-
* pool here (its identity base comes from the parsed pool options); tests
|
|
2074
|
-
* inject a drizzle PG instance directly via {@link PostgresBackend}.
|
|
2075
|
-
*/
|
|
2076
|
-
function createBackend(config) {
|
|
2077
|
-
if (config.type === "sqlite") return new SqliteBackend({
|
|
2078
|
-
path: config.path,
|
|
2079
|
-
journalMode: config.journalMode ?? "wal",
|
|
2080
|
-
busyTimeout: config.busyTimeout ?? 5e3
|
|
2081
|
-
});
|
|
2082
|
-
const pool = new Pool({ connectionString: config.connectionString });
|
|
2083
|
-
pool.on("error", () => {});
|
|
2084
|
-
return new PostgresBackend(drizzle({ client: pool }), {
|
|
2085
|
-
identityBase: [
|
|
2086
|
-
"postgres",
|
|
2087
|
-
pool.options.host ?? "localhost",
|
|
2088
|
-
String(pool.options.port ?? 5432),
|
|
2089
|
-
pool.options.database ?? ""
|
|
2090
|
-
].join(":"),
|
|
2091
|
-
close: () => pool.end()
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
/**
|
|
2095
|
-
* Serialize an event's surface-metadata fields for SQL binding. Both fields are
|
|
2096
|
-
* nullable TEXT columns — null when the event has no surface metadata (non-surface
|
|
2097
|
-
* events, events written before surface support).
|
|
2098
|
-
*
|
|
2099
|
-
* `sourceEventSeqs` references events by UPSTREAM seq. Delta events dropped at
|
|
2100
|
-
* write time never get a persisted row, so a reference to one can never be
|
|
2101
|
-
* remapped on read — keeping it verbatim produces a `source >= current seq`
|
|
2102
|
-
* provenance violation when the log is replayed as a session seed. The write
|
|
2103
|
-
* path therefore prunes references through {@link WriteGuard.pruneRefs}; a
|
|
2104
|
-
* fully pruned list is stored as null (no provenance).
|
|
2105
|
-
* @param event - the event to serialize.
|
|
2106
|
-
* @param prune - prunes references to dropped deltas before binding. Defaults
|
|
2107
|
-
* to identity (e.g. repair closers, which never carry provenance).
|
|
2108
|
-
*/
|
|
2109
|
-
function surfaceBindings(event, prune = (refs) => refs) {
|
|
2110
|
-
const se = event;
|
|
2111
|
-
const sourceSeqs = se.sourceEventSeqs === void 0 ? void 0 : prune(se.sourceEventSeqs);
|
|
2112
|
-
return [sourceSeqs !== void 0 && sourceSeqs.length > 0 ? JSON.stringify(sourceSeqs) : null, se.surfaceOp !== void 0 ? JSON.stringify(se.surfaceOp) : null];
|
|
2113
|
-
}
|
|
2114
|
-
/**
|
|
2115
|
-
* Durably append one batch of persisted events to a session's tail inside the
|
|
2116
|
-
* enclosing transaction: mint each event's row (parent chain + playpen
|
|
2117
|
-
* dimensions + surface-metadata columns) and its bridge row, land both as ONE
|
|
2118
|
-
* multi-row INSERT each (N events are 2 statements instead of 2N), and return
|
|
2119
|
-
* the resulting head cursor.
|
|
2120
|
-
*
|
|
2121
|
-
* The anchor is the caller's responsibility: a normal append starts from the
|
|
2122
|
-
* head cursor (`head.fHeadEventId` / `head.fHeadSequence + 1`), while
|
|
2123
|
-
* crash-repair closers start from the ACTUAL tail row (the head cursor can lag
|
|
2124
|
-
* a hand-written torn tail). Both callers then persist the returned cursor via
|
|
2125
|
-
* {@link BackendTx.updateHead}.
|
|
2126
|
-
* @param tx - the enclosing transaction.
|
|
2127
|
-
* @param meta - the session being written (`meta.id` drives the bridge rows).
|
|
2128
|
-
* @param events - persisted events to append (callers already filtered out
|
|
2129
|
-
* ephemeral/ignorable events; non-empty).
|
|
2130
|
-
* @param anchor - the parent event id to chain from and the next dense seq.
|
|
2131
|
-
* @param prune - forwarded to {@link surfaceBindings}; defaults to identity
|
|
2132
|
-
* (repair closers never carry provenance).
|
|
2133
|
-
* @returns the new head cursor (last event id + its dense seq).
|
|
2134
|
-
*/
|
|
2135
|
-
async function appendEventTail(tx, meta, events, anchor, prune = (refs) => refs) {
|
|
2136
|
-
let parentId = anchor.parentId;
|
|
2137
|
-
let nextSeq = anchor.nextSeq;
|
|
2138
|
-
const eventRows = [];
|
|
2139
|
-
const bridgeRows = [];
|
|
2140
|
-
for (const event of events) {
|
|
2141
|
-
const eventId = randomUUID();
|
|
2142
|
-
const { role, name, actionId } = eventDimensions(event);
|
|
2143
|
-
const [surfaceSeqs, surfaceOp] = surfaceBindings(event, prune);
|
|
2144
|
-
eventRows.push({
|
|
2145
|
-
fEventId: eventId,
|
|
2146
|
-
fParentId: parentId,
|
|
2147
|
-
fKind: event.type,
|
|
2148
|
-
fRole: role,
|
|
2149
|
-
fName: name,
|
|
2150
|
-
fActionId: actionId,
|
|
2151
|
-
fEncoding: EVENT_ENCODING,
|
|
2152
|
-
fData: JSON.stringify(event.data),
|
|
2153
|
-
fCreatedAt: event.time,
|
|
2154
|
-
fOriginalSeq: event.seq,
|
|
2155
|
-
fSourceEventSeqs: surfaceSeqs,
|
|
2156
|
-
fSurfaceOp: surfaceOp
|
|
2157
|
-
});
|
|
2158
|
-
bridgeRows.push({
|
|
2159
|
-
fSessionId: meta.id,
|
|
2160
|
-
fEventId: eventId,
|
|
2161
|
-
fSequence: nextSeq
|
|
2162
|
-
});
|
|
2163
|
-
parentId = eventId;
|
|
2164
|
-
nextSeq++;
|
|
2165
|
-
}
|
|
2166
|
-
await tx.insertEvents(eventRows);
|
|
2167
|
-
await tx.insertBridges(bridgeRows);
|
|
2168
|
-
return {
|
|
2169
|
-
headEventId: parentId,
|
|
2170
|
-
headSequence: nextSeq - 1
|
|
2171
|
-
};
|
|
2172
|
-
}
|
|
2173
|
-
//#endregion
|
|
2174
|
-
export { EPHEMERAL_EVENT_TYPES, SCHEMA_VERSION, SessionBranchRdb, SessionBranchRdbProvider, SessionPersistenceRdb, SessionPersistenceRdb as default, locateTurnEnd };
|
|
2175
|
-
|
|
2176
|
-
//# sourceMappingURL=index.mjs.map
|