@deepseek-ai/dsh-session-persistence 0.1.2-rc.1 → 0.1.5-alpha.1
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.i18n.yaml +2 -2
- package/README.md +45 -28
- package/README.zh.md +46 -29
- package/lib/index.js +167 -1494
- package/lib/types/errors.d.ts +98 -2
- package/lib/types/handle.d.ts +107 -0
- package/lib/types/index.d.ts +114 -191
- package/lib/types/storage-contract.d.ts +62 -0
- package/package.json +9 -9
- package/lib/types/coordinator.d.ts +0 -361
- package/lib/types/preparations.d.ts +0 -129
- package/lib/types/write-behind.d.ts +0 -62
package/lib/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
-
import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION,
|
|
3
|
-
import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
|
|
2
|
+
import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, adoptSessionEvent } from "@deepseek-ai/dsh-session";
|
|
4
3
|
import { snapshotJsonValue } from "@deepseek-ai/dsh-util-values";
|
|
5
4
|
//#region lib/types/revision.js
|
|
6
5
|
/** Opaque revision identity for lightweight persistence observations. */
|
|
@@ -14,8 +13,14 @@ function SessionPersistenceRevision(value) {
|
|
|
14
13
|
}
|
|
15
14
|
//#endregion
|
|
16
15
|
//#region lib/types/errors.js
|
|
17
|
-
/**
|
|
18
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Stable failures exposed by the session-persistence service and its handles,
|
|
18
|
+
* including the format refusals shared by every backend: a stored log this
|
|
19
|
+
* build cannot faithfully interpret is refused, never misread, and the
|
|
20
|
+
* refusal points at the raw artifact when the backend keeps one per session.
|
|
21
|
+
* @module @deepseek-ai/dsh-session-persistence/errors
|
|
22
|
+
*/
|
|
23
|
+
/** The requested Session identity has no durable log visible to this caller. */
|
|
19
24
|
var SessionPersistenceNotFoundError = class extends Error {
|
|
20
25
|
sessionId;
|
|
21
26
|
/** @param sessionId - absent durable Session identity. */
|
|
@@ -25,471 +30,69 @@ var SessionPersistenceNotFoundError = class extends Error {
|
|
|
25
30
|
this.name = "SessionPersistenceNotFoundError";
|
|
26
31
|
}
|
|
27
32
|
};
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
capacity;
|
|
37
|
-
entries = /* @__PURE__ */ new Map();
|
|
38
|
-
constructor(capacity) {
|
|
39
|
-
this.capacity = capacity;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Whether this pool currently knows about an unpublished identity.
|
|
43
|
-
* @param id - session identity.
|
|
44
|
-
* @returns whether an entry exists for the identity.
|
|
45
|
-
*/
|
|
46
|
-
has(id) {
|
|
47
|
-
return this.entries.has(id);
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Observe one prepared source, sharing an in-flight read for the same id.
|
|
51
|
-
* @param id - session identity.
|
|
52
|
-
* @param load - cold loader used when no entry exists.
|
|
53
|
-
* @param signal - optional cancellation signal while waiting.
|
|
54
|
-
* @returns the shared prepared source.
|
|
55
|
-
*/
|
|
56
|
-
async inspect(id, load, signal) {
|
|
57
|
-
const entry = this.entryFor(id, load);
|
|
58
|
-
const loaded = signal === void 0 ? await entry.result : await observeQueuedAbort(entry.result, signal);
|
|
59
|
-
const source = entry.source ?? loaded;
|
|
60
|
-
if (this.entries.get(id) === entry && entry.phase === "ready") this.touch(entry);
|
|
61
|
-
return source;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Borrow one prepared source and pin its ready entry against LRU eviction.
|
|
65
|
-
* @param id - session identity.
|
|
66
|
-
* @param load - cold loader used when no entry exists.
|
|
67
|
-
* @param signal - optional cancellation signal while waiting.
|
|
68
|
-
* @returns a caller-owned observation lease.
|
|
69
|
-
*/
|
|
70
|
-
async borrow(id, load, signal) {
|
|
71
|
-
const entry = this.entryFor(id, load);
|
|
72
|
-
const pinned = this.entries.get(id) === entry;
|
|
73
|
-
if (pinned) entry.pins += 1;
|
|
74
|
-
let loaded;
|
|
75
|
-
try {
|
|
76
|
-
loaded = signal === void 0 ? await entry.result : await observeQueuedAbort(entry.result, signal);
|
|
77
|
-
} catch (error) {
|
|
78
|
-
if (pinned && this.entries.get(id) === entry) {
|
|
79
|
-
entry.pins -= 1;
|
|
80
|
-
if (entry.phase === "ready") this.touch(entry);
|
|
81
|
-
}
|
|
82
|
-
throw error;
|
|
83
|
-
}
|
|
84
|
-
const source = entry.source ?? loaded;
|
|
85
|
-
if (this.entries.get(id) !== entry) return {
|
|
86
|
-
source,
|
|
87
|
-
[Symbol.dispose]: () => {}
|
|
88
|
-
};
|
|
89
|
-
if (entry.phase === "ready") this.touch(entry);
|
|
90
|
-
let released = false;
|
|
91
|
-
return {
|
|
92
|
-
source,
|
|
93
|
-
[Symbol.dispose]: () => {
|
|
94
|
-
if (released) return;
|
|
95
|
-
released = true;
|
|
96
|
-
if (this.entries.get(id) !== entry) return;
|
|
97
|
-
entry.pins -= 1;
|
|
98
|
-
if (entry.phase === "ready") this.touch(entry);
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Reserve one ready source after committing its pending durable repair.
|
|
104
|
-
* @param id - session identity.
|
|
105
|
-
* @param load - cold loader used when no entry exists.
|
|
106
|
-
* @param commit - durable repair and cursor-state commit.
|
|
107
|
-
* @param signal - optional cancellation signal while waiting.
|
|
108
|
-
* @returns the exclusive reservation, or undefined if its entry was invalidated.
|
|
109
|
-
*/
|
|
110
|
-
async reserve(id, load, commit, signal) {
|
|
111
|
-
const entry = this.entryFor(id, load);
|
|
112
|
-
await (signal === void 0 ? entry.result : observeQueuedAbort(entry.result, signal));
|
|
113
|
-
while (this.entries.get(id) === entry && entry.phase !== "ready") {
|
|
114
|
-
const settled = entry.reservationSettled;
|
|
115
|
-
/* v8 ignore next -- committing/reserved transitions install this waiter synchronously. */
|
|
116
|
-
if (settled === void 0) throw new Error(`session "${id}" preparation lost its reservation waiter`);
|
|
117
|
-
if (signal === void 0) await settled;
|
|
118
|
-
else await observeQueuedAbort(settled, signal);
|
|
119
|
-
}
|
|
120
|
-
if (this.entries.get(id) !== entry) return void 0;
|
|
121
|
-
const source = entry.source;
|
|
122
|
-
const reservationSettled = Promise.withResolvers();
|
|
123
|
-
entry.phase = "committing";
|
|
124
|
-
entry.reservationSettled = reservationSettled.promise;
|
|
125
|
-
entry.settleReservation = reservationSettled.resolve;
|
|
126
|
-
let committed;
|
|
127
|
-
try {
|
|
128
|
-
committed = await commit(source);
|
|
129
|
-
} catch (error) {
|
|
130
|
-
this.remove(entry);
|
|
131
|
-
throw error;
|
|
132
|
-
}
|
|
133
|
-
if (committed === void 0) {
|
|
134
|
-
this.remove(entry);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
entry.source = committed.source;
|
|
138
|
-
try {
|
|
139
|
-
signal?.throwIfAborted();
|
|
140
|
-
} catch (error) {
|
|
141
|
-
this.makeReady(entry);
|
|
142
|
-
throw error;
|
|
143
|
-
}
|
|
144
|
-
if (this.entries.get(id) !== entry) return void 0;
|
|
145
|
-
const reservation = {
|
|
146
|
-
entry,
|
|
147
|
-
source: committed.source,
|
|
148
|
-
state: committed.state
|
|
149
|
-
};
|
|
150
|
-
entry.phase = "reserved";
|
|
151
|
-
entry.reservation = reservation;
|
|
152
|
-
return reservation;
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* Return the exact reservation for Session publication, rejecting aliases.
|
|
156
|
-
* @param session - exact Session candidate for publication.
|
|
157
|
-
* @returns its reservation, or undefined when no preparation exists.
|
|
158
|
-
*/
|
|
159
|
-
reservationFor(session) {
|
|
160
|
-
const entry = this.entries.get(session.id);
|
|
161
|
-
if (entry === void 0) return void 0;
|
|
162
|
-
if (entry.phase === "reserved" && entry.source?.session === session && entry.reservation !== void 0) return entry.reservation;
|
|
163
|
-
throw new Error(`cannot publish session "${session.id}": persisted state already owns this identity`);
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Consume a reservation after its exact Session has attached.
|
|
167
|
-
* @param reservation - reservation to consume.
|
|
168
|
-
*/
|
|
169
|
-
attach(reservation) {
|
|
170
|
-
const { entry } = reservation;
|
|
171
|
-
if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) throw new Error(`session "${entry.id}" preparation is no longer reserved`);
|
|
172
|
-
this.remove(entry);
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Consume a reservation whose caller only needs the committed inspection.
|
|
176
|
-
* @param reservation - reservation to consume.
|
|
177
|
-
*/
|
|
178
|
-
discard(reservation) {
|
|
179
|
-
const { entry } = reservation;
|
|
180
|
-
if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) return;
|
|
181
|
-
this.remove(entry);
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Return a reusable unpublished reservation to the ready LRU.
|
|
185
|
-
* @param reservation - reservation to release.
|
|
186
|
-
* @param reusable - whether the source remains valid for reuse.
|
|
187
|
-
*/
|
|
188
|
-
release(reservation, reusable) {
|
|
189
|
-
const { entry } = reservation;
|
|
190
|
-
if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation || entry.phase !== "reserved") return;
|
|
191
|
-
if (!reusable) {
|
|
192
|
-
this.remove(entry);
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
delete entry.reservation;
|
|
196
|
-
this.makeReady(entry);
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Discard a prepared view after the durable log changes.
|
|
200
|
-
* @param id - changed session identity.
|
|
201
|
-
*/
|
|
202
|
-
invalidate(id) {
|
|
203
|
-
const entry = this.entries.get(id);
|
|
204
|
-
if (entry !== void 0) this.remove(entry);
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Discard an exact stale ready source without disturbing an exclusive owner.
|
|
208
|
-
* @param id - changed session identity.
|
|
209
|
-
* @param expected - exact source observed before its revision check.
|
|
210
|
-
* @returns whether the source was discarded, retained by a reservation, or is absent.
|
|
211
|
-
*/
|
|
212
|
-
discardReady(id, expected) {
|
|
213
|
-
const entry = this.entries.get(id);
|
|
214
|
-
if (entry === void 0 || entry.source !== expected) return "missing";
|
|
215
|
-
if (entry.phase !== "ready") return "retained";
|
|
216
|
-
this.remove(entry);
|
|
217
|
-
return "discarded";
|
|
33
|
+
/** `create` targeted a Session identity that already exists in this backend. */
|
|
34
|
+
var SessionAlreadyExistsError = class extends Error {
|
|
35
|
+
sessionId;
|
|
36
|
+
/** @param sessionId - the occupied durable Session identity. */
|
|
37
|
+
constructor(sessionId) {
|
|
38
|
+
super(`session "${sessionId}" already exists`);
|
|
39
|
+
this.sessionId = sessionId;
|
|
40
|
+
this.name = "SessionAlreadyExistsError";
|
|
218
41
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
42
|
+
};
|
|
43
|
+
/** A write open found the session already bound to an active write handle. */
|
|
44
|
+
var SessionAlreadyOwnedError = class extends Error {
|
|
45
|
+
sessionId;
|
|
46
|
+
/** @param sessionId - the session whose write ownership is taken. */
|
|
47
|
+
constructor(sessionId) {
|
|
48
|
+
super(`session "${sessionId}" is already owned by an active write handle`);
|
|
49
|
+
this.sessionId = sessionId;
|
|
50
|
+
this.name = "SessionAlreadyOwnedError";
|
|
226
51
|
}
|
|
52
|
+
};
|
|
53
|
+
/** A mutation (`append`/`flush`) was called on a read handle. */
|
|
54
|
+
var SessionReadOnlyError = class extends Error {
|
|
55
|
+
sessionId;
|
|
227
56
|
/**
|
|
228
|
-
*
|
|
229
|
-
* @param
|
|
230
|
-
* @returns the prepared source, or undefined when no ready entry exists.
|
|
57
|
+
* @param sessionId - the session the read handle observes.
|
|
58
|
+
* @param operation - the refused mutating operation name.
|
|
231
59
|
*/
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.
|
|
236
|
-
return entry.source;
|
|
237
|
-
}
|
|
238
|
-
entryFor(id, load) {
|
|
239
|
-
const existing = this.entries.get(id);
|
|
240
|
-
if (existing !== void 0) return existing;
|
|
241
|
-
const deferred = Promise.withResolvers();
|
|
242
|
-
const entry = {
|
|
243
|
-
id,
|
|
244
|
-
result: deferred.promise,
|
|
245
|
-
phase: "loading",
|
|
246
|
-
pins: 0
|
|
247
|
-
};
|
|
248
|
-
this.entries.set(id, entry);
|
|
249
|
-
let loading;
|
|
250
|
-
try {
|
|
251
|
-
loading = load();
|
|
252
|
-
} catch (error) {
|
|
253
|
-
this.remove(entry);
|
|
254
|
-
deferred.reject(error);
|
|
255
|
-
return entry;
|
|
256
|
-
}
|
|
257
|
-
loading.then((source) => {
|
|
258
|
-
if (this.entries.get(id) === entry) {
|
|
259
|
-
entry.source = source;
|
|
260
|
-
this.makeReady(entry);
|
|
261
|
-
}
|
|
262
|
-
deferred.resolve(source);
|
|
263
|
-
}, (error) => {
|
|
264
|
-
this.remove(entry);
|
|
265
|
-
deferred.reject(error);
|
|
266
|
-
});
|
|
267
|
-
return entry;
|
|
268
|
-
}
|
|
269
|
-
makeReady(entry) {
|
|
270
|
-
if (this.entries.get(entry.id) !== entry) return;
|
|
271
|
-
entry.phase = "ready";
|
|
272
|
-
const settle = entry.settleReservation;
|
|
273
|
-
delete entry.reservationSettled;
|
|
274
|
-
delete entry.settleReservation;
|
|
275
|
-
settle?.();
|
|
276
|
-
this.touch(entry);
|
|
277
|
-
}
|
|
278
|
-
remove(entry) {
|
|
279
|
-
if (this.entries.get(entry.id) !== entry) return;
|
|
280
|
-
this.entries.delete(entry.id);
|
|
281
|
-
const settle = entry.settleReservation;
|
|
282
|
-
delete entry.reservationSettled;
|
|
283
|
-
delete entry.settleReservation;
|
|
284
|
-
settle?.();
|
|
285
|
-
}
|
|
286
|
-
touch(entry) {
|
|
287
|
-
this.entries.delete(entry.id);
|
|
288
|
-
this.entries.set(entry.id, entry);
|
|
289
|
-
let readyCount = 0;
|
|
290
|
-
for (const candidate of this.entries.values()) if (candidate.phase === "ready") readyCount += 1;
|
|
291
|
-
if (readyCount <= this.capacity) return;
|
|
292
|
-
for (const [id, candidate] of this.entries) {
|
|
293
|
-
if (candidate.phase !== "ready" || candidate.pins > 0) continue;
|
|
294
|
-
this.entries.delete(id);
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
60
|
+
constructor(sessionId, operation) {
|
|
61
|
+
super(`session "${sessionId}": ${operation} is not available on a read handle`);
|
|
62
|
+
this.sessionId = sessionId;
|
|
63
|
+
this.name = "SessionReadOnlyError";
|
|
297
64
|
}
|
|
298
65
|
};
|
|
299
66
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
|
|
306
|
-
function observeQueuedAbort(operation, signal, started = () => false) {
|
|
307
|
-
return new Promise((resolve, reject) => {
|
|
308
|
-
let settled = false;
|
|
309
|
-
const finish = (callback) => {
|
|
310
|
-
if (settled) return;
|
|
311
|
-
settled = true;
|
|
312
|
-
signal.removeEventListener("abort", onAbort);
|
|
313
|
-
callback();
|
|
314
|
-
};
|
|
315
|
-
const onAbort = () => {
|
|
316
|
-
if (started()) return;
|
|
317
|
-
finish(() => {
|
|
318
|
-
try {
|
|
319
|
-
signal.throwIfAborted();
|
|
320
|
-
} catch (reason) {
|
|
321
|
-
rejectObservation(reject, reason);
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
/* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted. */
|
|
325
|
-
reject(/* @__PURE__ */ new Error("queued observation abort event lacked an aborted signal"));
|
|
326
|
-
});
|
|
327
|
-
};
|
|
328
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
329
|
-
operation.then((value) => {
|
|
330
|
-
finish(() => {
|
|
331
|
-
resolve(value);
|
|
332
|
-
});
|
|
333
|
-
}, (reason) => {
|
|
334
|
-
finish(() => {
|
|
335
|
-
rejectObservation(reject, reason);
|
|
336
|
-
});
|
|
337
|
-
});
|
|
338
|
-
if (signal.aborted) onAbort();
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
/** Preserve an exact loader or AbortSignal reason, including legacy non-Error values. */
|
|
342
|
-
function rejectObservation(reject, reason) {
|
|
343
|
-
reject(reason);
|
|
344
|
-
}
|
|
345
|
-
//#endregion
|
|
346
|
-
//#region lib/types/write-behind.js
|
|
347
|
-
/**
|
|
348
|
-
* Bounded per-session write batching for the shared persistence coordinator.
|
|
349
|
-
* @module @deepseek-ai/dsh-session-persistence/write-behind
|
|
350
|
-
*/
|
|
351
|
-
/**
|
|
352
|
-
* Owns one live session's pending events, fixed batching deadline, active write,
|
|
353
|
-
* failure retention, and explicit quiescence barrier.
|
|
67
|
+
* A write handle's ownership is permanently gone: its lease expired, a renewal
|
|
68
|
+
* failed, or the durable ownership record no longer names this handle. The
|
|
69
|
+
* handle never re-acquires ownership — close it and reopen for write.
|
|
70
|
+
*
|
|
71
|
+
* Declared for the cross-process lease layer; the shipped in-process backends
|
|
72
|
+
* never throw it yet.
|
|
354
73
|
*/
|
|
355
|
-
var
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
automaticPaused = false;
|
|
363
|
-
/**
|
|
364
|
-
* @param options - fixed scheduling policy and durable batch sink.
|
|
365
|
-
*/
|
|
366
|
-
constructor(options) {
|
|
367
|
-
this.options = options;
|
|
368
|
-
}
|
|
369
|
-
/** Whether this controller owns queued events or an active durable write. */
|
|
370
|
-
get hasWork() {
|
|
371
|
-
return this.pending.length > 0 || this.active !== void 0;
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Copy one event into the persistence-owned queue and start a fixed deadline
|
|
375
|
-
* when the automatic path is idle.
|
|
376
|
-
* @param event - frozen live event to retain independently of its producer.
|
|
377
|
-
*/
|
|
378
|
-
enqueue(event) {
|
|
379
|
-
const wasEmpty = this.pending.length === 0;
|
|
380
|
-
this.pending.push(structuredClone(event));
|
|
381
|
-
if (this.barrier !== void 0) return;
|
|
382
|
-
if (this.automaticPaused) {
|
|
383
|
-
this.automaticPaused = false;
|
|
384
|
-
this.deadlineExpired = false;
|
|
385
|
-
this.armTimer();
|
|
386
|
-
} else if (wasEmpty) this.armTimer();
|
|
74
|
+
var SessionOwnershipLostError = class extends Error {
|
|
75
|
+
sessionId;
|
|
76
|
+
/** @param sessionId - the session whose write ownership this handle lost. */
|
|
77
|
+
constructor(sessionId) {
|
|
78
|
+
super(`session "${sessionId}": write ownership was lost; close this handle and reopen`);
|
|
79
|
+
this.sessionId = sessionId;
|
|
80
|
+
this.name = "SessionOwnershipLostError";
|
|
387
81
|
}
|
|
82
|
+
};
|
|
83
|
+
/** An operation was called on a handle after `close()` was called. */
|
|
84
|
+
var SessionHandleClosedError = class extends Error {
|
|
85
|
+
sessionId;
|
|
388
86
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* @returns a promise that rejects if the barrier's durable retry fails.
|
|
87
|
+
* @param sessionId - the session the closed handle addressed.
|
|
88
|
+
* @param operation - the refused operation name.
|
|
392
89
|
*/
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
this.
|
|
396
|
-
this.
|
|
397
|
-
this.automaticPaused = false;
|
|
398
|
-
const barrier = Promise.withResolvers();
|
|
399
|
-
this.barrier = barrier.promise;
|
|
400
|
-
this.drainBarrier(barrier.resolve, barrier.reject);
|
|
401
|
-
return barrier.promise;
|
|
402
|
-
}
|
|
403
|
-
/** Cancel the current automatic deadline without draining retained work. */
|
|
404
|
-
cancelAutomaticWait() {
|
|
405
|
-
this.cancelTimer();
|
|
406
|
-
this.deadlineExpired = false;
|
|
407
|
-
}
|
|
408
|
-
/** Start the one fixed window for the current pending prefix. */
|
|
409
|
-
armTimer() {
|
|
410
|
-
this.timer = setTimeout(() => {
|
|
411
|
-
this.onDeadline();
|
|
412
|
-
}, this.options.maxDelayMs);
|
|
413
|
-
}
|
|
414
|
-
/** Cancel any pending automatic deadline. */
|
|
415
|
-
cancelTimer() {
|
|
416
|
-
if (this.timer === void 0) return;
|
|
417
|
-
clearTimeout(this.timer);
|
|
418
|
-
this.timer = void 0;
|
|
419
|
-
}
|
|
420
|
-
/** Start a background write now, or remember that an active write used the budget. */
|
|
421
|
-
onDeadline() {
|
|
422
|
-
this.timer = void 0;
|
|
423
|
-
if (this.active !== void 0) {
|
|
424
|
-
this.deadlineExpired = true;
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
this.startBackground();
|
|
428
|
-
}
|
|
429
|
-
/** Start one detached write whose failure is reported and retained. */
|
|
430
|
-
startBackground() {
|
|
431
|
-
this.startWrite(true).then(() => {
|
|
432
|
-
this.continueAutomatic();
|
|
433
|
-
}, () => {});
|
|
434
|
-
}
|
|
435
|
-
/** Continue immediately after an over-budget active write, otherwise keep its timer. */
|
|
436
|
-
continueAutomatic() {
|
|
437
|
-
if (this.barrier !== void 0 || this.pending.length === 0) return;
|
|
438
|
-
if (this.deadlineExpired) {
|
|
439
|
-
this.deadlineExpired = false;
|
|
440
|
-
this.startBackground();
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
/** Await overlapping work, drain to quiescence, and settle the shared barrier. */
|
|
444
|
-
async drainBarrier(resolve, reject) {
|
|
445
|
-
try {
|
|
446
|
-
const overlapping = this.active;
|
|
447
|
-
if (overlapping !== void 0) {
|
|
448
|
-
await Promise.allSettled([overlapping]);
|
|
449
|
-
this.automaticPaused = false;
|
|
450
|
-
}
|
|
451
|
-
while (this.pending.length > 0) await this.startWrite(false);
|
|
452
|
-
} catch (error) {
|
|
453
|
-
this.barrier = void 0;
|
|
454
|
-
reject(error);
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
this.barrier = void 0;
|
|
458
|
-
resolve();
|
|
459
|
-
}
|
|
460
|
-
/** Start one stable pending prefix, retaining it in order if durability fails. */
|
|
461
|
-
startWrite(background) {
|
|
462
|
-
const batch = this.pending.splice(0);
|
|
463
|
-
this.cancelTimer();
|
|
464
|
-
this.deadlineExpired = false;
|
|
465
|
-
const active = Promise.resolve().then(() => this.options.write(batch)).catch((error) => {
|
|
466
|
-
this.pending = batch.concat(this.pending);
|
|
467
|
-
this.cancelTimer();
|
|
468
|
-
this.deadlineExpired = false;
|
|
469
|
-
this.automaticPaused = true;
|
|
470
|
-
if (background) this.options.reportBackgroundFailure(error);
|
|
471
|
-
throw error;
|
|
472
|
-
}).finally(() => {
|
|
473
|
-
this.active = void 0;
|
|
474
|
-
});
|
|
475
|
-
this.active = active;
|
|
476
|
-
return active;
|
|
90
|
+
constructor(sessionId, operation) {
|
|
91
|
+
super(`session "${sessionId}": ${operation} on a closed handle`);
|
|
92
|
+
this.sessionId = sessionId;
|
|
93
|
+
this.name = "SessionHandleClosedError";
|
|
477
94
|
}
|
|
478
95
|
};
|
|
479
|
-
//#endregion
|
|
480
|
-
//#region lib/types/coordinator.js
|
|
481
|
-
/**
|
|
482
|
-
* Shared buffering, serialization, adoption, repair, and disposal orchestration
|
|
483
|
-
* for first-party backends. Third-party backends may implement the public
|
|
484
|
-
* persistence seam directly.
|
|
485
|
-
* @module @deepseek-ai/dsh-session-persistence/coordinator
|
|
486
|
-
*/
|
|
487
|
-
/** Default number of detached session preparations retained by a coordinator. */
|
|
488
|
-
const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5;
|
|
489
|
-
/** Default maximum intentional wait before a live session batch starts writing. */
|
|
490
|
-
const DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200;
|
|
491
|
-
/** Largest write batching delay accepted by Node's timer implementation. */
|
|
492
|
-
const MAX_WRITE_BATCH_DELAY_MS = MAX_TIMER_DELAY_MS;
|
|
493
96
|
/** Durable session contents failed validation after a successful backend read. */
|
|
494
97
|
var SessionPersistenceCorruptionError = class extends Error {
|
|
495
98
|
/**
|
|
@@ -504,10 +107,9 @@ var SessionPersistenceCorruptionError = class extends Error {
|
|
|
504
107
|
/**
|
|
505
108
|
* The stored log is intact but this runtime cannot faithfully interpret it:
|
|
506
109
|
* the header carries an unsupported format version, or an event's type is
|
|
507
|
-
* unknown to this build
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* per session.
|
|
110
|
+
* unknown to this build. Distinct from {@link SessionPersistenceCorruptionError}
|
|
111
|
+
* — nothing is damaged; the raw log remains readable at {@link location} when
|
|
112
|
+
* the backend keeps one artifact per session.
|
|
511
113
|
*/
|
|
512
114
|
var SessionFormatUnsupportedError = class extends Error {
|
|
513
115
|
location;
|
|
@@ -524,10 +126,10 @@ var SessionFormatUnsupportedError = class extends Error {
|
|
|
524
126
|
};
|
|
525
127
|
/**
|
|
526
128
|
* Direction-aware refusal text for a stored session whose format version this
|
|
527
|
-
* build does not read. Shared by
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
129
|
+
* build does not read. Shared by load-time checks and by backends that must
|
|
130
|
+
* refuse BEFORE decoding version-dependent structure (a future format may not
|
|
131
|
+
* satisfy this build's structural checks at all, and the user must see
|
|
132
|
+
* "upgrade the harness", never "corrupt").
|
|
531
133
|
* @param id - the stored session id, for message context.
|
|
532
134
|
* @param version - the stored format version.
|
|
533
135
|
* @returns the stable refusal text, without a raw-log path suffix.
|
|
@@ -535,1060 +137,131 @@ var SessionFormatUnsupportedError = class extends Error {
|
|
|
535
137
|
function sessionFormatVersionRefusal(id, version) {
|
|
536
138
|
return version > SESSION_FORMAT_VERSION ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`;
|
|
537
139
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
return seedEvent !== void 0 && JSON.stringify(seedEvent) === JSON.stringify(event);
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
/** Normalize the exact fork cut paired with one logical Session header. */
|
|
553
|
-
function storageMetadata(meta, inheritedEventCount) {
|
|
554
|
-
if (meta.isSeeded && inheritedEventCount === void 0) throw new TypeError("seeded session metadata requires an inherited event count");
|
|
555
|
-
const cut = SessionLogOffset(inheritedEventCount ?? 0);
|
|
556
|
-
if (!meta.isSeeded && cut !== 0) throw new TypeError("unseeded session metadata inherited event count must be 0");
|
|
557
|
-
return {
|
|
558
|
-
meta,
|
|
559
|
-
inheritedEventCount: cut
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
/** Exact storage metadata owned by one live Session. */
|
|
563
|
-
function sessionStorageMetadata(session) {
|
|
564
|
-
return storageMetadata(session.header, session.inheritedEventCount);
|
|
565
|
-
}
|
|
566
|
-
/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
|
|
567
|
-
function assertSupportedEvents(events, id) {
|
|
568
|
-
const legacyType = "request/header-delta";
|
|
569
|
-
const legacy = events.find((event) => event.type === legacyType);
|
|
570
|
-
if (legacy !== void 0) throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`);
|
|
571
|
-
const legacyModeType = "mode/set";
|
|
572
|
-
const legacyMode = events.find((event) => event.type === legacyModeType);
|
|
573
|
-
if (legacyMode !== void 0) throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`);
|
|
574
|
-
const fallback = events.find((event) => event.type === "request/header" && event.data.reason === "fallback");
|
|
575
|
-
if (fallback !== void 0) throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`);
|
|
576
|
-
}
|
|
577
|
-
/** Return an object record without widening arrays into message payloads. */
|
|
578
|
-
function asRecord(value) {
|
|
579
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
580
|
-
}
|
|
581
|
-
/** Whether a record contains every required key and no key outside the optional extension set. */
|
|
582
|
-
function hasOnlyKeys(record, required, optional = []) {
|
|
583
|
-
const allowed = [...required, ...optional];
|
|
584
|
-
return Object.keys(record).every((key) => allowed.includes(key)) && required.every((key) => Object.hasOwn(record, key));
|
|
585
|
-
}
|
|
586
|
-
/** Mint the stable import identity for a message persisted before identities existed. */
|
|
587
|
-
function legacyMessageId(id, seq) {
|
|
588
|
-
return `legacy-message:${id}:${seq}`;
|
|
589
|
-
}
|
|
590
|
-
/** Read a replacement target while leaving malformed surface metadata to the session validator. */
|
|
591
|
-
function replacementStart(event) {
|
|
592
|
-
const op = asRecord(event.surfaceOp);
|
|
593
|
-
if (op?.["op"] !== "replace" || typeof op["start"] !== "number") return void 0;
|
|
594
|
-
try {
|
|
595
|
-
return SessionSeq(op["start"]);
|
|
596
|
-
} catch {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
/** Whether one suffix event needs facts available only from the preceding stored prefix. */
|
|
601
|
-
function needsLegacyPrefix(event) {
|
|
602
|
-
const data = asRecord(event.data);
|
|
603
|
-
if (event.type === "steering/message") return true;
|
|
604
|
-
if (data === void 0) return false;
|
|
605
|
-
switch (event.type) {
|
|
606
|
-
case "user/message": return !Object.hasOwn(data, "id") && Object.hasOwn(data, "content");
|
|
607
|
-
case "assistant/message": return !Object.hasOwn(data, "message") && Object.hasOwn(data, "content");
|
|
608
|
-
case "tool/result": return !Object.hasOwn(data, "message") && Object.hasOwn(data, "callId");
|
|
609
|
-
default: return false;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
/** Upgrade the removed steering surface event into its current user-message equivalent. */
|
|
613
|
-
function migrateLegacySteeringEvent(event, id) {
|
|
614
|
-
if (event.type !== "steering/message") return event;
|
|
615
|
-
const data = asRecord(event.data);
|
|
616
|
-
if (data === void 0) throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`);
|
|
617
|
-
const wrapped = asRecord(data["message"]);
|
|
618
|
-
if (wrapped !== void 0 && Number.isSafeInteger(data["turn"]) && hasOnlyKeys(data, ["turn", "message"])) return {
|
|
619
|
-
...event,
|
|
620
|
-
type: "user/message",
|
|
621
|
-
data: wrapped
|
|
622
|
-
};
|
|
623
|
-
if (!Number.isSafeInteger(data["turn"]) || !hasOnlyKeys(data, [
|
|
624
|
-
"turn",
|
|
625
|
-
"content",
|
|
626
|
-
"source"
|
|
627
|
-
])) throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`);
|
|
628
|
-
const { turn: _turn, ...message } = data;
|
|
629
|
-
return {
|
|
630
|
-
...event,
|
|
631
|
-
type: "user/message",
|
|
632
|
-
data: {
|
|
633
|
-
...message,
|
|
634
|
-
id: legacyMessageId(id, event.seq),
|
|
635
|
-
role: "user"
|
|
636
|
-
}
|
|
637
|
-
};
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region lib/types/storage-contract.js
|
|
142
|
+
/**
|
|
143
|
+
* Backend-shared storage validation: the version gate, the fail-closed event
|
|
144
|
+
* vocabulary, append-batch materialization, and contiguity — one place so
|
|
145
|
+
* every backend refuses the same inputs identically.
|
|
146
|
+
* @module @deepseek-ai/dsh-session-persistence/storage-contract
|
|
147
|
+
*/
|
|
148
|
+
/** Build a format refusal that points at the raw artifact when the backend has one. */
|
|
149
|
+
function unsupported(reason, location) {
|
|
150
|
+
return new SessionFormatUnsupportedError(location === void 0 ? reason : `${reason} (raw log: ${location.path})`, location);
|
|
638
151
|
}
|
|
639
|
-
/**
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (
|
|
646
|
-
return {
|
|
647
|
-
...event,
|
|
648
|
-
data: { turn: data["turn"] }
|
|
649
|
-
};
|
|
152
|
+
/**
|
|
153
|
+
* Refuse stored metadata that is not bound to the requested session id.
|
|
154
|
+
* @param id - the requested session id.
|
|
155
|
+
* @param meta - the stored header.
|
|
156
|
+
*/
|
|
157
|
+
function assertStoredId(id, meta) {
|
|
158
|
+
if (meta.id !== id) throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`);
|
|
650
159
|
}
|
|
651
|
-
/**
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`);
|
|
659
|
-
};
|
|
660
|
-
const reason = asRecord(data["reason"]);
|
|
661
|
-
if (!Number.isSafeInteger(data["turn"]) || data["turn"] < 1 || !hasOnlyKeys(data, ["turn", "reason"]) || reason === void 0 || typeof reason["kind"] !== "string") return malformed();
|
|
662
|
-
let currentReason;
|
|
663
|
-
switch (reason["kind"]) {
|
|
664
|
-
case "completed":
|
|
665
|
-
case "blocked":
|
|
666
|
-
case "max-tokens":
|
|
667
|
-
case "interrupted":
|
|
668
|
-
if (!hasOnlyKeys(reason, ["kind"])) return malformed();
|
|
669
|
-
return event;
|
|
670
|
-
case "aborted":
|
|
671
|
-
if (Object.hasOwn(reason, "reason")) return event;
|
|
672
|
-
if (!hasOnlyKeys(reason, ["kind"])) return malformed();
|
|
673
|
-
currentReason = {
|
|
674
|
-
kind: "aborted",
|
|
675
|
-
reason: { kind: "legacy" }
|
|
676
|
-
};
|
|
677
|
-
break;
|
|
678
|
-
case "disposed":
|
|
679
|
-
if (!hasOnlyKeys(reason, ["kind"])) return malformed();
|
|
680
|
-
currentReason = {
|
|
681
|
-
kind: "aborted",
|
|
682
|
-
reason: { kind: "disposed" }
|
|
683
|
-
};
|
|
684
|
-
break;
|
|
685
|
-
case "error": {
|
|
686
|
-
if (Object.hasOwn(reason, "error")) return event;
|
|
687
|
-
if (!Number.isSafeInteger(reason["step"]) || reason["step"] < 0) return malformed();
|
|
688
|
-
const failure = asRecord(reason["failure"]);
|
|
689
|
-
if (failure !== void 0 && hasOnlyKeys(reason, [
|
|
690
|
-
"kind",
|
|
691
|
-
"step",
|
|
692
|
-
"failure"
|
|
693
|
-
]) && hasOnlyKeys(failure, ["message", "code"], [
|
|
694
|
-
"status",
|
|
695
|
-
"providerRetryAfterMs",
|
|
696
|
-
"requestId"
|
|
697
|
-
]) && typeof failure["message"] === "string" && typeof failure["code"] === "string" && (failure["status"] === void 0 || typeof failure["status"] === "number") && (failure["providerRetryAfterMs"] === void 0 || typeof failure["providerRetryAfterMs"] === "number") && (failure["requestId"] === void 0 || typeof failure["requestId"] === "string")) {
|
|
698
|
-
currentReason = {
|
|
699
|
-
kind: "error",
|
|
700
|
-
error: failure
|
|
701
|
-
};
|
|
702
|
-
break;
|
|
703
|
-
}
|
|
704
|
-
if (!hasOnlyKeys(reason, reason["code"] === void 0 ? [
|
|
705
|
-
"kind",
|
|
706
|
-
"step",
|
|
707
|
-
"message"
|
|
708
|
-
] : [
|
|
709
|
-
"kind",
|
|
710
|
-
"step",
|
|
711
|
-
"message",
|
|
712
|
-
"code"
|
|
713
|
-
]) || typeof reason["message"] !== "string" || reason["code"] !== void 0 && typeof reason["code"] !== "string") return malformed();
|
|
714
|
-
currentReason = {
|
|
715
|
-
kind: "error",
|
|
716
|
-
error: {
|
|
717
|
-
message: reason["message"],
|
|
718
|
-
code: typeof reason["code"] === "string" ? reason["code"] : "UNKNOWN"
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
break;
|
|
722
|
-
}
|
|
723
|
-
default: return event;
|
|
724
|
-
}
|
|
725
|
-
return {
|
|
726
|
-
...event,
|
|
727
|
-
data: {
|
|
728
|
-
...data,
|
|
729
|
-
reason: currentReason
|
|
730
|
-
}
|
|
731
|
-
};
|
|
160
|
+
/**
|
|
161
|
+
* Refuse a stored header whose format version this build does not read.
|
|
162
|
+
* @param meta - the stored header.
|
|
163
|
+
* @param location - the backend's artifact location for the refusal, when one exists.
|
|
164
|
+
*/
|
|
165
|
+
function assertVersion(meta, location) {
|
|
166
|
+
if (meta.version !== SESSION_FORMAT_VERSION) throw unsupported(sessionFormatVersionRefusal(meta.id, meta.version), location);
|
|
732
167
|
}
|
|
733
168
|
/**
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
169
|
+
* Validate one exclusively owned stored event array in place: adopt each
|
|
170
|
+
* record (validating and freezing it) and refuse any event type this build
|
|
171
|
+
* does not know, unless its writer marked it `ignorable: true` — silently
|
|
172
|
+
* skipping an unknown required event could reconstruct a wrong session (the
|
|
173
|
+
* envelope contract on `SessionEvent.ignorable`). Both newer vocabularies and
|
|
174
|
+
* retired pre-release shapes refuse here; this build ships no migration.
|
|
175
|
+
* @param meta - the stored header the events belong to.
|
|
176
|
+
* @param events - exclusively owned decoded events; validated in place.
|
|
177
|
+
* @param location - the backend's artifact location for refusals, when one exists.
|
|
178
|
+
* @returns the same array, validated and frozen.
|
|
179
|
+
* @throws {SessionFormatUnsupportedError} for unknown event types.
|
|
180
|
+
* @throws {SessionPersistenceCorruptionError} for records that fail validation.
|
|
737
181
|
*/
|
|
738
|
-
function
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
if (
|
|
744
|
-
return {
|
|
745
|
-
...event,
|
|
746
|
-
data: {
|
|
747
|
-
...data,
|
|
748
|
-
id: legacyMessageId(id, event.seq),
|
|
749
|
-
role: "user"
|
|
750
|
-
}
|
|
751
|
-
};
|
|
752
|
-
case "assistant/message": {
|
|
753
|
-
if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "provenance")) return event;
|
|
754
|
-
const { content, provenance, ...eventData } = data;
|
|
755
|
-
return {
|
|
756
|
-
...event,
|
|
757
|
-
data: {
|
|
758
|
-
...eventData,
|
|
759
|
-
message: {
|
|
760
|
-
id: legacyMessageId(id, event.seq),
|
|
761
|
-
role: "assistant",
|
|
762
|
-
content,
|
|
763
|
-
source: {
|
|
764
|
-
...asRecord(provenance),
|
|
765
|
-
kind: "model"
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
};
|
|
770
|
-
}
|
|
771
|
-
case "tool/result": {
|
|
772
|
-
if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "callId") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "isError")) return event;
|
|
773
|
-
const { callId, content, isError, ...eventData } = data;
|
|
774
|
-
const inheritedId = replacementStart(event);
|
|
775
|
-
return {
|
|
776
|
-
...event,
|
|
777
|
-
data: {
|
|
778
|
-
...eventData,
|
|
779
|
-
message: {
|
|
780
|
-
id: inheritedId === void 0 ? legacyMessageId(id, event.seq) : messageIds.get(inheritedId),
|
|
781
|
-
role: "user",
|
|
782
|
-
content: [{
|
|
783
|
-
type: "tool-result",
|
|
784
|
-
toolCallId: callId,
|
|
785
|
-
content,
|
|
786
|
-
isError
|
|
787
|
-
}],
|
|
788
|
-
source: {
|
|
789
|
-
kind: "tool",
|
|
790
|
-
callId
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
};
|
|
182
|
+
function validateStoredEvents(meta, events, location) {
|
|
183
|
+
for (const event of events) {
|
|
184
|
+
if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable !== true) throw unsupported(`session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, location);
|
|
185
|
+
if (event.type === "request/header") {
|
|
186
|
+
const data = event.data;
|
|
187
|
+
if (typeof data === "object" && data !== null && data["reason"] === "fallback") throw unsupported(`session "${meta.id}" contains a request/header event (seq ${event.seq}) with the unsupported legacy reason "fallback"; refusing to interpret the log — it was written by a retired pre-release harness`, location);
|
|
795
188
|
}
|
|
796
|
-
default: return event;
|
|
797
189
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
return typeof message?.["id"] === "string" ? message["id"] : void 0;
|
|
804
|
-
}
|
|
805
|
-
/** Materialize stored events as upgraded, validated snapshots with immutable messages. */
|
|
806
|
-
function snapshotStoredEvents(events, id) {
|
|
807
|
-
assertSupportedEvents(events, id);
|
|
808
|
-
const messageIds = /* @__PURE__ */ new Map();
|
|
809
|
-
return events.map((event) => {
|
|
810
|
-
const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migrateLegacySteeringEvent(migrateLegacyTurnEndEvent(migrateLegacyTurnStartEvent(event, id), id), id), id, messageIds));
|
|
811
|
-
const messageId = eventMessageId(snapshot);
|
|
812
|
-
if (messageId !== void 0) messageIds.set(snapshot.seq, messageId);
|
|
813
|
-
return snapshot;
|
|
814
|
-
});
|
|
815
|
-
}
|
|
816
|
-
/** Upgrade and validate an exclusively owned backend result without copying it. */
|
|
817
|
-
function adoptStoredEvents(events, id) {
|
|
818
|
-
assertSupportedEvents(events, id);
|
|
819
|
-
const messageIds = /* @__PURE__ */ new Map();
|
|
820
|
-
for (const [index, event] of events.entries()) {
|
|
821
|
-
const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migrateLegacySteeringEvent(migrateLegacyTurnEndEvent(migrateLegacyTurnStartEvent(event, id), id), id), id, messageIds));
|
|
822
|
-
events[index] = adopted;
|
|
823
|
-
const messageId = eventMessageId(adopted);
|
|
824
|
-
if (messageId !== void 0) messageIds.set(adopted.seq, messageId);
|
|
190
|
+
try {
|
|
191
|
+
for (const [index, event] of events.entries()) events[index] = adoptSessionEvent(event);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof SessionFormatUnsupportedError) throw error;
|
|
194
|
+
throw new SessionPersistenceCorruptionError(`stored session "${meta.id}" failed validation: ${String(error)}`, { cause: error });
|
|
825
195
|
}
|
|
826
196
|
return events;
|
|
827
197
|
}
|
|
828
198
|
/**
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
* All per-id operations are serialized (a per-id promise chain) so concurrent
|
|
835
|
-
* flushes / a flush racing a load never interleave storage writes. The
|
|
836
|
-
* constructor installs the write-path listeners, per-session retirement, and
|
|
837
|
-
* the backend dispose effect.
|
|
838
|
-
*
|
|
839
|
-
* @typeParam TornMarker - the backend's opaque torn-tail repair token.
|
|
199
|
+
* Validate and deep-snapshot a header passed to `create` in one traversal.
|
|
200
|
+
* @param header - the caller's header.
|
|
201
|
+
* @returns the detached lossless-JSON header.
|
|
202
|
+
* @throws {TypeError} for non-JSON metadata or an invalid `createdAt`.
|
|
840
203
|
*/
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
this.installWritePath();
|
|
870
|
-
}
|
|
871
|
-
/**
|
|
872
|
-
* Register detached session metadata for lazy creation on the first append.
|
|
873
|
-
* @param meta - header to snapshot; duplicate tracked or persisted ids reject.
|
|
874
|
-
* @param inheritedEventCount - exact inherited prefix length; required for
|
|
875
|
-
* a seeded header and omitted only for an unseeded header.
|
|
876
|
-
*/
|
|
877
|
-
create(meta, inheritedEventCount) {
|
|
878
|
-
const snapshot = snapshotJsonValue(meta);
|
|
879
|
-
if (snapshot === void 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata must be losslessly JSON-serializable"));
|
|
880
|
-
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata createdAt must be a non-negative safe integer"));
|
|
881
|
-
let storage;
|
|
882
|
-
try {
|
|
883
|
-
storage = storageMetadata(snapshot, inheritedEventCount);
|
|
884
|
-
} catch (error) {
|
|
885
|
-
/* v8 ignore next -- Session storage validation only throws Error instances. */
|
|
886
|
-
return Promise.reject(error instanceof Error ? error : new TypeError("invalid session storage metadata", { cause: error }));
|
|
887
|
-
}
|
|
888
|
-
return this.serialize(snapshot.id, () => this.createCore(storage));
|
|
889
|
-
}
|
|
890
|
-
/**
|
|
891
|
-
* Materialize one exact live session without inventing a session event.
|
|
892
|
-
* @param session - live session already registered through the write path.
|
|
893
|
-
*/
|
|
894
|
-
async ensureMaterialized(session) {
|
|
895
|
-
await this.flush(session);
|
|
896
|
-
await this.serialize(session.id, async () => {
|
|
897
|
-
const state = this.states.get(session.id);
|
|
898
|
-
/* v8 ignore next -- successful live flush always initializes the exact session state. */
|
|
899
|
-
if (state === void 0) throw new Error(`session "${session.id}" is not registered for persistence`);
|
|
900
|
-
if (state.materialized) return;
|
|
901
|
-
if (this.backend.materializeHeader === void 0) throw new Error("session persistence backend cannot materialize an empty session");
|
|
902
|
-
await this.backend.materializeHeader(state.storage);
|
|
903
|
-
state.materialized = true;
|
|
904
|
-
this.preparations.invalidate(session.id);
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
async createCore(storage) {
|
|
908
|
-
const { meta } = storage;
|
|
909
|
-
if (this.states.has(meta.id) || this.preparations.has(meta.id)) throw new Error(`session "${meta.id}" already exists in this backend`);
|
|
910
|
-
if (await this.backend.loadStored(meta.id) !== void 0) throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`);
|
|
911
|
-
this.states.set(meta.id, {
|
|
912
|
-
storage,
|
|
913
|
-
cursor: SessionLogOffset(0),
|
|
914
|
-
materialized: false
|
|
915
|
-
});
|
|
916
|
-
}
|
|
917
|
-
/**
|
|
918
|
-
* Durably persist a batch of events. Honors the append-only and contiguous-seq
|
|
919
|
-
* contracts; rejects non-JSON-serializable `event.data`.
|
|
920
|
-
* @param id - the session the batch belongs to.
|
|
921
|
-
* @param events - the contiguous batch to persist, in seq order; materialized
|
|
922
|
-
* as a detached lossless-JSON snapshot at call time.
|
|
923
|
-
*/
|
|
924
|
-
async append(id, events) {
|
|
925
|
-
const batch = snapshotJsonValue(events);
|
|
926
|
-
if (batch === void 0) throw new TypeError("session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data");
|
|
927
|
-
return this.serialize(id, () => this.appendCore(id, batch));
|
|
928
|
-
}
|
|
929
|
-
async appendCore(id, events) {
|
|
930
|
-
assertSupportedEvents(events, id);
|
|
931
|
-
if (events.length === 0) return;
|
|
932
|
-
this.preparations.assertWritable(id);
|
|
933
|
-
let state = this.states.get(id);
|
|
934
|
-
if (state === void 0) state = await this.adopt(id);
|
|
935
|
-
for (const [i, event] of events.entries()) if (event.seq !== state.cursor + i) throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`);
|
|
936
|
-
const nextCursor = SessionLogOffset(state.cursor + events.length);
|
|
937
|
-
if (!state.materialized && nextCursor < state.storage.inheritedEventCount) throw new Error(`session "${id}" cannot materialize before its inherited prefix is complete`);
|
|
938
|
-
await this.backend.appendBatch(state.storage, events, state.materialized);
|
|
939
|
-
state.materialized = true;
|
|
940
|
-
state.cursor = nextCursor;
|
|
941
|
-
this.preparations.invalidate(id);
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* Prepare and reserve the exact unpublished Session used by resume.
|
|
945
|
-
* Revision retries converge once the durable log remains unchanged for one
|
|
946
|
-
* read/check round trip; continuous external writers may delay completion.
|
|
947
|
-
* @param id - persisted session to prepare.
|
|
948
|
-
* @param signal - optional cancellation for reading and repair.
|
|
949
|
-
* @returns an owned preparation released after publication or rollback.
|
|
950
|
-
*/
|
|
951
|
-
async prepare(id, signal) {
|
|
952
|
-
for (;;) {
|
|
953
|
-
await this.waitForRetirement(id, signal);
|
|
954
|
-
if (this.ctx.sessions.get(id) !== void 0) throw new Error(`cannot prepare session "${id}" while it is live`);
|
|
955
|
-
const reservation = await this.preparations.reserve(id, () => this.serialize(id, () => this.prepareCore(id)), (source) => this.serialize(id, () => this.commitPrepared(source), signal), signal);
|
|
956
|
-
if (reservation === void 0) continue;
|
|
957
|
-
if (this.ctx.sessions.get(id) !== void 0) {
|
|
958
|
-
this.preparations.release(reservation, false);
|
|
959
|
-
throw new Error(`cannot prepare session "${id}" while it is live`);
|
|
960
|
-
}
|
|
961
|
-
return SessionPreparation.create(reservation.source.session, { release: () => {
|
|
962
|
-
this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.seq === reservation.source.sessionLength);
|
|
963
|
-
} });
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* Commit recovery and return its immutable logical view without publication.
|
|
968
|
-
* Revision retries converge once the durable log remains unchanged for one
|
|
969
|
-
* read/check round trip; continuous external writers may delay completion.
|
|
970
|
-
* @param id - persisted session to load.
|
|
971
|
-
* @returns prepared header and balanced events.
|
|
972
|
-
*/
|
|
973
|
-
async load(id) {
|
|
974
|
-
for (;;) {
|
|
975
|
-
await this.waitForRetirement(id);
|
|
976
|
-
const live = this.ctx.sessions.get(id);
|
|
977
|
-
if (live !== void 0) return this.loadLiveSnapshot(live);
|
|
978
|
-
const reservation = await this.preparations.reserve(id, () => this.serialize(id, () => this.prepareCore(id)), (source) => this.serialize(id, () => this.commitPrepared(source)));
|
|
979
|
-
if (reservation === void 0) continue;
|
|
980
|
-
const attached = this.ctx.sessions.get(id);
|
|
981
|
-
if (attached !== void 0) {
|
|
982
|
-
this.preparations.discard(reservation);
|
|
983
|
-
return this.loadLiveSnapshot(attached);
|
|
984
|
-
}
|
|
985
|
-
this.preparations.discard(reservation);
|
|
986
|
-
return reservation.source.inspection;
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* Inspect a logical session without publishing it or committing recovery.
|
|
991
|
-
* A stale ready source is reloaded. A source already committing or reserved
|
|
992
|
-
* for resume remains exclusive, and inspection may borrow its immutable view.
|
|
993
|
-
* Revision retries converge once the log is stable for one read/check round
|
|
994
|
-
* trip; continuous external writers may delay completion.
|
|
995
|
-
* @param id - persisted session to inspect.
|
|
996
|
-
* @param signal - optional cancellation for preparation work.
|
|
997
|
-
* @returns immutable prepared metadata and events; a live view may have an open turn.
|
|
998
|
-
*/
|
|
999
|
-
async inspect(id, signal) {
|
|
1000
|
-
for (;;) {
|
|
1001
|
-
signal?.throwIfAborted();
|
|
1002
|
-
if (this.retirements.has(id)) await this.waitForRetirement(id, signal);
|
|
1003
|
-
const live = this.ctx.sessions.get(id);
|
|
1004
|
-
if (live !== void 0) return this.inspectLive(live);
|
|
1005
|
-
try {
|
|
1006
|
-
const source = await this.preparations.inspect(id, () => this.serialize(id, () => this.prepareCore(id)), signal);
|
|
1007
|
-
const attached = this.ctx.sessions.get(id);
|
|
1008
|
-
if (attached !== void 0) return this.inspectLive(attached);
|
|
1009
|
-
const current = await this.serialize(id, () => this.isPreparedSourceCurrent(source, signal), signal);
|
|
1010
|
-
const published = this.ctx.sessions.get(id);
|
|
1011
|
-
if (published !== void 0) return this.inspectLive(published);
|
|
1012
|
-
if (current) return source.inspection;
|
|
1013
|
-
if (this.preparations.discardReady(id, source) === "retained") return source.inspection;
|
|
1014
|
-
} catch (error) {
|
|
1015
|
-
signal?.throwIfAborted();
|
|
1016
|
-
const attached = this.ctx.sessions.get(id);
|
|
1017
|
-
if (attached !== void 0) return this.inspectLive(attached);
|
|
1018
|
-
throw error;
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
/**
|
|
1023
|
-
* Borrow one exact logical view while pinning its reusable prepared Session.
|
|
1024
|
-
* @param id - persisted session to observe.
|
|
1025
|
-
* @param signal - optional cancellation for preparation work.
|
|
1026
|
-
* @returns a disposable observation retaining the prepared source.
|
|
1027
|
-
*/
|
|
1028
|
-
async borrowSession(id, signal) {
|
|
1029
|
-
for (;;) {
|
|
1030
|
-
signal?.throwIfAborted();
|
|
1031
|
-
if (this.retirements.has(id)) await this.waitForRetirement(id, signal);
|
|
1032
|
-
const live = this.ctx.sessions.get(id);
|
|
1033
|
-
if (live !== void 0) return {
|
|
1034
|
-
source: "live",
|
|
1035
|
-
inspection: this.inspectLive(live),
|
|
1036
|
-
[Symbol.dispose]: () => {}
|
|
1037
|
-
};
|
|
1038
|
-
const observation = await this.preparations.borrow(id, () => this.serialize(id, () => this.prepareCore(id)), signal);
|
|
1039
|
-
const source = observation.source;
|
|
1040
|
-
try {
|
|
1041
|
-
const attached = this.ctx.sessions.get(id);
|
|
1042
|
-
if (attached !== void 0) {
|
|
1043
|
-
observation[Symbol.dispose]();
|
|
1044
|
-
return {
|
|
1045
|
-
source: "live",
|
|
1046
|
-
inspection: this.inspectLive(attached),
|
|
1047
|
-
[Symbol.dispose]: () => {}
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
const current = await this.serialize(id, () => this.isPreparedSourceCurrent(source, signal), signal);
|
|
1051
|
-
const published = this.ctx.sessions.get(id);
|
|
1052
|
-
if (published !== void 0) {
|
|
1053
|
-
observation[Symbol.dispose]();
|
|
1054
|
-
return {
|
|
1055
|
-
source: "live",
|
|
1056
|
-
inspection: this.inspectLive(published),
|
|
1057
|
-
[Symbol.dispose]: () => {}
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
if (current || this.preparations.discardReady(id, source) === "retained") return {
|
|
1061
|
-
source: "prepared",
|
|
1062
|
-
inspection: source.inspection,
|
|
1063
|
-
revision: source.revision,
|
|
1064
|
-
preparedSession: source.session,
|
|
1065
|
-
[Symbol.dispose]: () => {
|
|
1066
|
-
observation[Symbol.dispose]();
|
|
1067
|
-
}
|
|
1068
|
-
};
|
|
1069
|
-
} catch (error) {
|
|
1070
|
-
observation[Symbol.dispose]();
|
|
1071
|
-
signal?.throwIfAborted();
|
|
1072
|
-
const attached = this.ctx.sessions.get(id);
|
|
1073
|
-
if (attached !== void 0) return {
|
|
1074
|
-
source: "live",
|
|
1075
|
-
inspection: this.inspectLive(attached),
|
|
1076
|
-
[Symbol.dispose]: () => {}
|
|
1077
|
-
};
|
|
1078
|
-
throw error;
|
|
1079
|
-
}
|
|
1080
|
-
observation[Symbol.dispose]();
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
/**
|
|
1084
|
-
* Read the stored events from `fromSeq` onward, detached and non-mutating
|
|
1085
|
-
* (the read-from-seq primitive behind the service's `readFrom`). Runs on
|
|
1086
|
-
* the same per-id chain as writes; a backend with the seek-capable
|
|
1087
|
-
* {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix,
|
|
1088
|
-
* every other backend reads its stored prefix and skips forward here.
|
|
1089
|
-
* @param id - persisted session to read.
|
|
1090
|
-
* @param fromSeq - first event seq to include; a non-negative safe integer.
|
|
1091
|
-
* @param signal - optional cancellation for queued and backend read work.
|
|
1092
|
-
* @returns stored metadata, the requested offset, and valid events with `seq >= fromSeq`.
|
|
1093
|
-
*/
|
|
1094
|
-
readFrom(id, fromSeq, signal) {
|
|
1095
|
-
try {
|
|
1096
|
-
SessionLogOffset(fromSeq);
|
|
1097
|
-
} catch (error) {
|
|
1098
|
-
/* v8 ignore next -- Session log-offset validation only throws Error instances. */
|
|
1099
|
-
return Promise.reject(error instanceof Error ? error : new TypeError("invalid session read offset", { cause: error }));
|
|
1100
|
-
}
|
|
1101
|
-
const retired = Promise.resolve(this.retirements.get(id));
|
|
1102
|
-
return (signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false)).then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal));
|
|
1103
|
-
}
|
|
1104
|
-
async readFromCore(id, fromSeq, signal) {
|
|
1105
|
-
signal?.throwIfAborted();
|
|
1106
|
-
if (this.backend.loadStoredFrom !== void 0) {
|
|
1107
|
-
let suffix;
|
|
1108
|
-
try {
|
|
1109
|
-
suffix = await this.backend.loadStoredFrom(id, fromSeq, signal);
|
|
1110
|
-
} catch (error) {
|
|
1111
|
-
if (signal?.aborted) signal.throwIfAborted();
|
|
1112
|
-
throw error;
|
|
1113
|
-
}
|
|
1114
|
-
signal?.throwIfAborted();
|
|
1115
|
-
if (suffix === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
1116
|
-
this.assertStoredId(id, suffix.meta);
|
|
1117
|
-
this.assertVersion(suffix.meta);
|
|
1118
|
-
if (suffix.events.some(needsLegacyPrefix)) {
|
|
1119
|
-
const whole = await this.readStoredPrefix(id, signal);
|
|
1120
|
-
return {
|
|
1121
|
-
meta: whole.meta,
|
|
1122
|
-
inheritedEventCount: whole.inheritedEventCount,
|
|
1123
|
-
fromSeq,
|
|
1124
|
-
events: whole.events.filter((event) => event.seq >= fromSeq)
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
const events = snapshotStoredEvents(suffix.events, id);
|
|
1128
|
-
this.assertEventsSupported(suffix.meta, events);
|
|
1129
|
-
return {
|
|
1130
|
-
meta: structuredClone(suffix.meta),
|
|
1131
|
-
inheritedEventCount: SessionLogOffset(suffix.inheritedEventCount),
|
|
1132
|
-
fromSeq,
|
|
1133
|
-
events
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
const whole = await this.readStoredPrefix(id, signal);
|
|
1137
|
-
return {
|
|
1138
|
-
meta: whole.meta,
|
|
1139
|
-
inheritedEventCount: whole.inheritedEventCount,
|
|
1140
|
-
fromSeq,
|
|
1141
|
-
events: whole.events.slice(fromSeq)
|
|
1142
|
-
};
|
|
1143
|
-
}
|
|
1144
|
-
/** Read one detached physical prefix without logical recovery or caching. */
|
|
1145
|
-
async readStoredPrefix(id, signal) {
|
|
1146
|
-
signal?.throwIfAborted();
|
|
1147
|
-
const stored = await this.backend.loadStored(id, signal);
|
|
1148
|
-
signal?.throwIfAborted();
|
|
1149
|
-
if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
1150
|
-
this.assertStoredId(id, stored.meta);
|
|
1151
|
-
this.assertVersion(stored.meta);
|
|
1152
|
-
const events = snapshotStoredEvents(stored.events, id);
|
|
1153
|
-
this.assertEventsSupported(stored.meta, events);
|
|
1154
|
-
return {
|
|
1155
|
-
meta: structuredClone(stored.meta),
|
|
1156
|
-
inheritedEventCount: SessionLogOffset(stored.inheritedEventCount),
|
|
1157
|
-
events
|
|
1158
|
-
};
|
|
1159
|
-
}
|
|
1160
|
-
/** Read, repair in memory, validate, and freeze one cold source once. */
|
|
1161
|
-
async prepareCore(id) {
|
|
1162
|
-
const stored = await this.backend.loadStored(id);
|
|
1163
|
-
if (stored === void 0) throw new SessionPersistenceNotFoundError(id);
|
|
1164
|
-
try {
|
|
1165
|
-
const { meta, inheritedEventCount, events, revision, tornMarker } = stored;
|
|
1166
|
-
this.assertStoredId(id, meta);
|
|
1167
|
-
this.assertVersion(meta);
|
|
1168
|
-
const storedEvents = adoptStoredEvents(events, id);
|
|
1169
|
-
this.assertEventsSupported(meta, storedEvents);
|
|
1170
|
-
if (inheritedEventCount > storedEvents.length) throw new Error(`session "${id}" inherited event count exceeds its stored event count`);
|
|
1171
|
-
const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent);
|
|
1172
|
-
const balanced = [...storedEvents, ...closers];
|
|
1173
|
-
const session = this.ctx.sessions.prepare(id, {
|
|
1174
|
-
seed: balanced,
|
|
1175
|
-
meta,
|
|
1176
|
-
inheritedEventCount,
|
|
1177
|
-
seedSource: "persistence"
|
|
1178
|
-
});
|
|
1179
|
-
return {
|
|
1180
|
-
inspection: Object.freeze({
|
|
1181
|
-
meta: session.header,
|
|
1182
|
-
inheritedEventCount: session.inheritedEventCount,
|
|
1183
|
-
events: Object.freeze(balanced)
|
|
1184
|
-
}),
|
|
1185
|
-
session,
|
|
1186
|
-
revision,
|
|
1187
|
-
sessionLength: session.seq,
|
|
1188
|
-
tornMarker,
|
|
1189
|
-
closers
|
|
1190
|
-
};
|
|
1191
|
-
} catch (error) {
|
|
1192
|
-
if (error instanceof SessionFormatUnsupportedError) throw error;
|
|
1193
|
-
throw new SessionPersistenceCorruptionError(`stored session "${id}" failed validation: ${String(error)}`, { cause: error });
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
/** Commit one prepared repair and establish its ownerless durable cursor. */
|
|
1197
|
-
async commitPrepared(source) {
|
|
1198
|
-
const id = source.inspection.meta.id;
|
|
1199
|
-
const cursor = SessionLogOffset(source.inspection.events.length);
|
|
1200
|
-
const existing = this.states.get(id);
|
|
1201
|
-
if (existing?.owner !== void 0) throw new Error(`session "${id}" already has a live persistence owner`);
|
|
1202
|
-
if (!await this.isPreparedSourceCurrent(source)) return void 0;
|
|
1203
|
-
if (source.tornMarker !== void 0 || source.closers.length > 0) {
|
|
1204
|
-
await this.backend.commitRepair(source.inspection, source.tornMarker, source.closers);
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
const state = existing ?? {
|
|
1208
|
-
storage: source.inspection,
|
|
1209
|
-
cursor,
|
|
1210
|
-
materialized: true
|
|
1211
|
-
};
|
|
1212
|
-
state.storage = source.inspection;
|
|
1213
|
-
state.cursor = cursor;
|
|
1214
|
-
state.materialized = true;
|
|
1215
|
-
this.states.set(id, state);
|
|
1216
|
-
return {
|
|
1217
|
-
source,
|
|
1218
|
-
state
|
|
1219
|
-
};
|
|
1220
|
-
}
|
|
1221
|
-
/** Whether one cached source still names the current durable log revision. */
|
|
1222
|
-
async isPreparedSourceCurrent(source, signal) {
|
|
1223
|
-
return await this.backend.readStoredRevision(source.inspection.meta.id, signal) === source.revision;
|
|
1224
|
-
}
|
|
1225
|
-
/** Return one durable immutable view of an already-live Session. */
|
|
1226
|
-
async loadLiveSnapshot(session) {
|
|
1227
|
-
const events = session.snapshotEvents();
|
|
1228
|
-
await this.flush(session);
|
|
1229
|
-
const state = this.states.get(session.id);
|
|
1230
|
-
/* v8 ignore next -- successful flush always publishes this live session's durable state */
|
|
1231
|
-
if (state === void 0) throw new Error(`session "${session.id}" lost persistence state during load`);
|
|
1232
|
-
if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`);
|
|
1233
|
-
if (interruptedTurnClosers(events).length > 0) throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`);
|
|
1234
|
-
return Object.freeze({
|
|
1235
|
-
meta: state.storage.meta,
|
|
1236
|
-
inheritedEventCount: state.storage.inheritedEventCount,
|
|
1237
|
-
events
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
/** Borrow one immutable view from an already-live Session. */
|
|
1241
|
-
inspectLive(session) {
|
|
1242
|
-
return Object.freeze({
|
|
1243
|
-
meta: session.header,
|
|
1244
|
-
inheritedEventCount: session.inheritedEventCount,
|
|
1245
|
-
events: session.snapshotEvents()
|
|
1246
|
-
});
|
|
1247
|
-
}
|
|
1248
|
-
/** Await one retiring lifecycle with caller cancellation. */
|
|
1249
|
-
waitForRetirement(id, signal) {
|
|
1250
|
-
const retired = Promise.resolve(this.retirements.get(id));
|
|
1251
|
-
return signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false);
|
|
1252
|
-
}
|
|
1253
|
-
/**
|
|
1254
|
-
* Run `op` after any in-flight operation for the same session id, so writes for
|
|
1255
|
-
* one session never interleave. Errors do not poison the chain. NOTE: serialized
|
|
1256
|
-
* public methods must NOT call each other (deadlock); they call the unserialized
|
|
1257
|
-
* `*Core` helpers instead.
|
|
1258
|
-
*/
|
|
1259
|
-
serialize(id, op, signal) {
|
|
1260
|
-
const prior = this.chains.get(id) ?? Promise.resolve();
|
|
1261
|
-
let started = false;
|
|
1262
|
-
const run = () => {
|
|
1263
|
-
signal?.throwIfAborted();
|
|
1264
|
-
started = true;
|
|
1265
|
-
return op();
|
|
1266
|
-
};
|
|
1267
|
-
const next = prior.then(run, run);
|
|
1268
|
-
const tail = next.then(() => void 0, () => void 0);
|
|
1269
|
-
this.chains.set(id, tail);
|
|
1270
|
-
tail.then(() => {
|
|
1271
|
-
if (this.chains.get(id) === tail) this.chains.delete(id);
|
|
1272
|
-
});
|
|
1273
|
-
return signal === void 0 ? next : observeQueuedAbort(next, signal, () => started);
|
|
1274
|
-
}
|
|
1275
|
-
/** Build a state for a session discovered in storage but not yet in memory. */
|
|
1276
|
-
async adopt(id) {
|
|
1277
|
-
for (;;) {
|
|
1278
|
-
const source = this.preparations.takeReady(id) ?? await this.prepareCore(id);
|
|
1279
|
-
const committed = await this.commitPrepared(source);
|
|
1280
|
-
if (committed !== void 0) return committed.state;
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
assertVersion(meta) {
|
|
1284
|
-
if (meta.version === SESSION_FORMAT_VERSION) return;
|
|
1285
|
-
throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version));
|
|
1286
|
-
}
|
|
1287
|
-
/**
|
|
1288
|
-
* Refuse a log containing an event type this build does not know, unless the
|
|
1289
|
-
* writer marked the event ignorable: an unrecognized required event may
|
|
1290
|
-
* change how the rest of the log must be interpreted, so silently skipping
|
|
1291
|
-
* it would reconstruct a wrong session (the envelope contract on
|
|
1292
|
-
* `SessionEvent.ignorable`). Runs on NORMALIZED events — after
|
|
1293
|
-
* `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes
|
|
1294
|
-
* this build still reads and rejected the ones it does not, so those keep
|
|
1295
|
-
* their specific diagnostics.
|
|
1296
|
-
*/
|
|
1297
|
-
assertEventsSupported(meta, events) {
|
|
1298
|
-
for (const event of events) {
|
|
1299
|
-
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue;
|
|
1300
|
-
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
/** Build a format refusal that points at the raw artifact when the backend has one. */
|
|
1304
|
-
unsupported(meta, reason) {
|
|
1305
|
-
const location = this.backend.locate?.(meta);
|
|
1306
|
-
return new SessionFormatUnsupportedError(location === void 0 ? reason : `${reason} (raw log: ${location.path})`, location);
|
|
1307
|
-
}
|
|
1308
|
-
/** Reject backend metadata that is not bound to the requested session id. */
|
|
1309
|
-
assertStoredId(id, meta) {
|
|
1310
|
-
if (meta.id !== id) throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`);
|
|
1311
|
-
}
|
|
1312
|
-
installWritePath() {
|
|
1313
|
-
const ctx = this.ctx;
|
|
1314
|
-
ctx.effect(() => async () => {
|
|
1315
|
-
let disposeError;
|
|
1316
|
-
try {
|
|
1317
|
-
const errors = await settledErrors([...this.live.keys()].map((session) => this.flush(session)));
|
|
1318
|
-
while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]);
|
|
1319
|
-
if (errors.length > 0) throw new AggregateError(errors, `${this.backend.name} dispose failed`);
|
|
1320
|
-
} catch (error) {
|
|
1321
|
-
disposeError = error;
|
|
1322
|
-
throw error;
|
|
1323
|
-
} finally {
|
|
1324
|
-
try {
|
|
1325
|
-
await this.backend.close?.();
|
|
1326
|
-
} catch (closeError) {
|
|
1327
|
-
/* v8 ignore start -- close failure racing disposal is a defensive teardown edge */
|
|
1328
|
-
if (disposeError === void 0) throw closeError;
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
}, `${this.backend.name} write path`);
|
|
1332
|
-
ctx.on("session/created", (session) => {
|
|
1333
|
-
this.initFor(session);
|
|
1334
|
-
});
|
|
1335
|
-
ctx.on("session/event", (session, event) => {
|
|
1336
|
-
this.initFor(session).writes.enqueue(event);
|
|
1337
|
-
});
|
|
1338
|
-
ctx.on("session/flush", (session) => this.flush(session));
|
|
1339
|
-
ctx.on("session/disposed", (session) => {
|
|
1340
|
-
this.retire(session);
|
|
1341
|
-
});
|
|
1342
|
-
for (const session of ctx.sessions.list()) this.initFor(session);
|
|
1343
|
-
}
|
|
1344
|
-
/** Start and observe one disposed session's final drain. */
|
|
1345
|
-
retire(session) {
|
|
1346
|
-
if (!this.live.has(session)) return;
|
|
1347
|
-
const retirement = this.retireCore(session);
|
|
1348
|
-
this.retirements.set(session.id, retirement);
|
|
1349
|
-
const forget = () => {
|
|
1350
|
-
if (this.retirements.get(session.id) === retirement) this.retirements.delete(session.id);
|
|
1351
|
-
};
|
|
1352
|
-
retirement.then(forget, forget);
|
|
1353
|
-
retirement.catch((error) => {
|
|
1354
|
-
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`);
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
/** Drain and release state owned by one exact disposed Session lifecycle. */
|
|
1358
|
-
async retireCore(session) {
|
|
1359
|
-
await this.flush(session);
|
|
1360
|
-
const id = session.header.id;
|
|
1361
|
-
await this.serialize(id, () => {
|
|
1362
|
-
this.live.delete(session);
|
|
1363
|
-
if (this.states.get(id)?.owner === session) this.states.delete(id);
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
/** Return the one lifecycle controller for a live session, creating it if needed. */
|
|
1367
|
-
initFor(session) {
|
|
1368
|
-
const existing = this.live.get(session);
|
|
1369
|
-
if (existing) return existing;
|
|
1370
|
-
const reservation = this.preparations.reservationFor(session);
|
|
1371
|
-
if (reservation !== void 0) {
|
|
1372
|
-
const restored = this.attachPrepared(session, reservation);
|
|
1373
|
-
this.live.set(session, restored);
|
|
1374
|
-
return restored;
|
|
1375
|
-
}
|
|
1376
|
-
const seed = session.snapshotEvents();
|
|
1377
|
-
const live = {
|
|
1378
|
-
init: Promise.resolve(),
|
|
1379
|
-
writes: this.createWriteBehind(session, () => live.init)
|
|
1380
|
-
};
|
|
1381
|
-
this.live.set(session, live);
|
|
1382
|
-
live.init = this.serialize(session.header.id, () => this.onCreated(session, seed));
|
|
1383
|
-
live.init.catch(() => {});
|
|
1384
|
-
return live;
|
|
1385
|
-
}
|
|
1386
|
-
/** Bind one exact prepared Session and persist only its unpublished suffix. */
|
|
1387
|
-
attachPrepared(session, reservation) {
|
|
1388
|
-
const { source, state } = reservation;
|
|
1389
|
-
if (source.session !== session || state.owner !== void 0 || state.cursor !== source.inspection.events.length || session.firstLiveSeq !== state.cursor) throw new Error(`session "${session.id}" preparation no longer matches its persistence state`);
|
|
1390
|
-
const suffix = session.snapshotEvents(state.cursor).map((event) => structuredClone(event));
|
|
1391
|
-
this.preparations.attach(reservation);
|
|
1392
|
-
state.owner = session;
|
|
1393
|
-
const live = {
|
|
1394
|
-
init: Promise.resolve(),
|
|
1395
|
-
writes: this.createWriteBehind(session, () => live.init)
|
|
1396
|
-
};
|
|
1397
|
-
if (suffix.length > 0) {
|
|
1398
|
-
live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix));
|
|
1399
|
-
live.init.catch(() => {});
|
|
1400
|
-
}
|
|
1401
|
-
return live;
|
|
1402
|
-
}
|
|
1403
|
-
/**
|
|
1404
|
-
* Whether a live session's `seed` reproduces the first `cursor` persisted
|
|
1405
|
-
* events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when
|
|
1406
|
-
* a live session claims ownerless state left by a prior `load()`/`create()`.
|
|
1407
|
-
*/
|
|
1408
|
-
async seedMatchesPersisted(id, seed, cursor) {
|
|
1409
|
-
if (cursor === 0) return true;
|
|
1410
|
-
const stored = await this.backend.loadStored(id);
|
|
1411
|
-
/* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
|
|
1412
|
-
if (stored === void 0) return false;
|
|
1413
|
-
this.assertStoredId(id, stored.meta);
|
|
1414
|
-
return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor));
|
|
1415
|
-
}
|
|
1416
|
-
/**
|
|
1417
|
-
* On session/created: sync the backend's in-memory state to a live Session.
|
|
1418
|
-
*
|
|
1419
|
-
* Cases, by whether this backend tracks the id and whether an artifact exists:
|
|
1420
|
-
* 1. Already tracked → no-op (or claim ownerless state if the seed matches,
|
|
1421
|
-
* or reclaim a truly-abandoned id, else reject as a collision).
|
|
1422
|
-
* 2. Not tracked, an artifact EXISTS at the same cwd and is a seq-aligned
|
|
1423
|
-
* PREFIX of the live events → ADOPT it, persisting any live suffix.
|
|
1424
|
-
* 3. Not tracked, an artifact EXISTS at another cwd or is NOT a prefix →
|
|
1425
|
-
* REJECT (collision).
|
|
1426
|
-
* 4. Not tracked and NO artifact → a genuinely new session: register meta
|
|
1427
|
-
* (lazy) and persist its seed once.
|
|
1428
|
-
*/
|
|
1429
|
-
async onCreated(session, seed) {
|
|
1430
|
-
const id = session.header.id;
|
|
1431
|
-
const tracked = this.states.get(id);
|
|
1432
|
-
if (tracked !== void 0) {
|
|
1433
|
-
/* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
|
|
1434
|
-
if (tracked.owner === session) return;
|
|
1435
|
-
if (tracked.owner === void 0) {
|
|
1436
|
-
if (tracked.storage.meta.cwd !== session.header.cwd) throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.storage.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
|
|
1437
|
-
if (tracked.storage.inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${id}" is already persisted with a different inherited event count (id collision)`);
|
|
1438
|
-
if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`);
|
|
1439
|
-
tracked.owner = session;
|
|
1440
|
-
const suffix = seed.slice(tracked.cursor);
|
|
1441
|
-
if (suffix.length > 0) await this.appendCore(id, suffix);
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const owner = this.live.get(tracked.owner);
|
|
1445
|
-
if (!tracked.materialized && !owner?.writes.hasWork) this.states.delete(id);
|
|
1446
|
-
else throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`);
|
|
1447
|
-
}
|
|
1448
|
-
const live = await this.backend.loadStored(id);
|
|
1449
|
-
if (live !== void 0) {
|
|
1450
|
-
await this.adoptLivePrefix(session, seed, live);
|
|
1451
|
-
return;
|
|
1452
|
-
}
|
|
1453
|
-
const storage = sessionStorageMetadata(session);
|
|
1454
|
-
await this.createCore({
|
|
1455
|
-
meta: { ...storage.meta },
|
|
1456
|
-
inheritedEventCount: storage.inheritedEventCount
|
|
1457
|
-
});
|
|
1458
|
-
const created = this.states.get(id);
|
|
1459
|
-
/* v8 ignore next -- create() always sets the state for the id */
|
|
1460
|
-
if (created !== void 0) created.owner = session;
|
|
1461
|
-
if (seed.length > 0) await this.appendCore(id, seed);
|
|
1462
|
-
}
|
|
1463
|
-
/**
|
|
1464
|
-
* Adopt a stored prefix as a live session's history (HMR/reload): verify the
|
|
1465
|
-
* seed covers the stored prefix, truncate any torn tail (NOT the open turn —
|
|
1466
|
-
* the live Session is still the authority), bind ownership, and persist the
|
|
1467
|
-
* live suffix that was ahead of the stored prefix.
|
|
1468
|
-
*/
|
|
1469
|
-
async adoptLivePrefix(session, seed, stored) {
|
|
1470
|
-
const { meta, inheritedEventCount, events, tornMarker } = stored;
|
|
1471
|
-
this.assertStoredId(session.header.id, meta);
|
|
1472
|
-
if (meta.cwd !== session.header.cwd) throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
|
|
1473
|
-
if (inheritedEventCount !== session.inheritedEventCount) throw new Error(`session "${session.header.id}" is already persisted with a different inherited event count (id collision)`);
|
|
1474
|
-
this.assertVersion(meta);
|
|
1475
|
-
const storedEvents = snapshotStoredEvents(events, session.header.id);
|
|
1476
|
-
this.assertEventsSupported(meta, storedEvents);
|
|
1477
|
-
if (!seedCoversPrefix(seed, storedEvents)) throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`);
|
|
1478
|
-
if (tornMarker !== void 0) await this.backend.commitRepair(stored, tornMarker, []);
|
|
1479
|
-
this.states.set(session.header.id, {
|
|
1480
|
-
storage: {
|
|
1481
|
-
meta: { ...meta },
|
|
1482
|
-
inheritedEventCount
|
|
1483
|
-
},
|
|
1484
|
-
cursor: SessionLogOffset(storedEvents.length),
|
|
1485
|
-
materialized: true,
|
|
1486
|
-
owner: session
|
|
1487
|
-
});
|
|
1488
|
-
const suffix = seed.slice(storedEvents.length);
|
|
1489
|
-
if (suffix.length > 0) await this.appendCore(session.header.id, suffix);
|
|
1490
|
-
}
|
|
1491
|
-
async flush(session) {
|
|
1492
|
-
const live = this.initFor(session);
|
|
1493
|
-
live.writes.cancelAutomaticWait();
|
|
1494
|
-
try {
|
|
1495
|
-
await live.init;
|
|
1496
|
-
} catch (error) {
|
|
1497
|
-
live.writes.cancelAutomaticWait();
|
|
1498
|
-
throw error;
|
|
1499
|
-
}
|
|
1500
|
-
await live.writes.flush();
|
|
1501
|
-
}
|
|
1502
|
-
/** Build one package-private write controller around initialization and id serialization. */
|
|
1503
|
-
createWriteBehind(session, ready) {
|
|
1504
|
-
return new SessionWriteBehind({
|
|
1505
|
-
maxDelayMs: this.writeBatchMaxDelayMs,
|
|
1506
|
-
write: async (batch) => {
|
|
1507
|
-
await ready();
|
|
1508
|
-
await this.serialize(session.header.id, () => this.appendLiveBatch(session.header.id, batch));
|
|
1509
|
-
},
|
|
1510
|
-
reportBackgroundFailure: (error) => {
|
|
1511
|
-
this.ctx.logger.warn(`${this.backend.name}: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`);
|
|
1512
|
-
}
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
/** Append one controller-owned prefix after filtering events initialization already stored. */
|
|
1516
|
-
async appendLiveBatch(id, batch) {
|
|
1517
|
-
/* v8 ignore next -- state is always set by the awaited initialization */
|
|
1518
|
-
const cursor = this.states.get(id)?.cursor ?? 0;
|
|
1519
|
-
const fresh = batch.filter((e) => e.seq >= cursor);
|
|
1520
|
-
await this.appendCore(id, fresh);
|
|
1521
|
-
}
|
|
1522
|
-
};
|
|
204
|
+
function materializeCreateHeader(header) {
|
|
205
|
+
const snapshot = snapshotJsonValue(header);
|
|
206
|
+
if (snapshot === void 0) throw new TypeError("session metadata must be losslessly JSON-serializable");
|
|
207
|
+
if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) throw new TypeError("session metadata createdAt must be a non-negative safe integer");
|
|
208
|
+
return snapshot;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Validate and deep-snapshot one append batch in a single traversal, so the
|
|
212
|
+
* checked value is exactly the value persisted (a check followed by a copy
|
|
213
|
+
* could reread accessors into a different record).
|
|
214
|
+
* @param events - the caller's batch.
|
|
215
|
+
* @returns the detached lossless-JSON batch.
|
|
216
|
+
* @throws {TypeError} when any event data is not losslessly JSON-serializable.
|
|
217
|
+
*/
|
|
218
|
+
function materializeAppendBatch(events) {
|
|
219
|
+
const batch = snapshotJsonValue(events);
|
|
220
|
+
if (batch === void 0) throw new TypeError("session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data");
|
|
221
|
+
return batch;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Refuse a batch that does not contiguously continue the stored log.
|
|
225
|
+
* @param id - the session the batch belongs to.
|
|
226
|
+
* @param events - the batch, in seq order.
|
|
227
|
+
* @param cursor - the stored next-seq.
|
|
228
|
+
*/
|
|
229
|
+
function assertContiguous(id, events, cursor) {
|
|
230
|
+
for (const [index, event] of events.entries()) if (event.seq !== cursor + index) throw new Error(`append seq mismatch for "${id}": expected ${cursor + index} at index ${index}, got ${event.seq}`);
|
|
231
|
+
}
|
|
1523
232
|
//#endregion
|
|
1524
233
|
//#region lib/types/index.js
|
|
1525
234
|
/**
|
|
1526
235
|
* Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
|
|
1527
236
|
* {@link SessionEvent}s as the event-sourced log and carry non-replayable
|
|
1528
|
-
* {@link SessionHeader} metadata separately
|
|
237
|
+
* {@link SessionHeader} metadata separately; callers address one stored
|
|
238
|
+
* session through a {@link SessionHandle} obtained from `create`/`open`.
|
|
1529
239
|
* @module @deepseek-ai/dsh-session-persistence
|
|
1530
240
|
*/
|
|
1531
241
|
/**
|
|
1532
|
-
* Durable append-only session storage
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
242
|
+
* Durable append-only session storage addressed through per-session handles.
|
|
243
|
+
*
|
|
244
|
+
* Storage semantics shared by every backend: events are contiguous from seq 0
|
|
245
|
+
* and never rewritten; a torn physical tail is never returned to a reader and
|
|
246
|
+
* is truncated by the write path before its first append; reads validate
|
|
247
|
+
* current-format records only and refuse unknown vocabulary fail-closed.
|
|
248
|
+
* `append` persists best-effort; `flush` — per handle or service-wide — is
|
|
249
|
+
* the durability barrier.
|
|
250
|
+
*
|
|
251
|
+
* Visibility: a created session is observable through `stat`/`list`/`open`
|
|
252
|
+
* in this process from the moment `create` resolves, even while a backend
|
|
253
|
+
* defers physical materialization (a pure optimization); other processes see
|
|
254
|
+
* the session only once it materializes, and a session that never
|
|
255
|
+
* materialized before a crash never existed. `SessionHandle.flush` forces
|
|
256
|
+
* materialization.
|
|
257
|
+
*
|
|
258
|
+
* Freshness: once an `append` or `flush` resolves, reads started afterwards
|
|
259
|
+
* on this backend instance observe at least that prefix.
|
|
1536
260
|
*/
|
|
1537
261
|
var SessionPersistence = class extends Service {
|
|
1538
262
|
constructor(ctx) {
|
|
1539
263
|
super(ctx, "sessionPersistence");
|
|
1540
264
|
}
|
|
1541
|
-
/**
|
|
1542
|
-
* Read a session's backend-owned artifact text verbatim — the exact durable
|
|
1543
|
-
* bytes the backend wrote (decoded from its physical encoding, e.g. a
|
|
1544
|
-
* decompressed JSONL). The returned `content` is the raw text, not a
|
|
1545
|
-
* reconstruction from parsed events, so it preserves backend-specific
|
|
1546
|
-
* serialization (chunk packing, key order, line breaks). Callers first test
|
|
1547
|
-
* {@link supportsRawArtifacts}; `undefined` then means only that the requested
|
|
1548
|
-
* session has no materialized artifact.
|
|
1549
|
-
* @param _id - the persisted session to read (unused by the default: no
|
|
1550
|
-
* per-session artifact).
|
|
1551
|
-
* @param signal - optional cancellation for backend read work.
|
|
1552
|
-
* @returns the raw artifact plus its parsed header, or `undefined` when the
|
|
1553
|
-
* session is absent.
|
|
1554
|
-
* @throws when this backend does not expose per-session raw artifacts.
|
|
1555
|
-
*/
|
|
1556
|
-
readRaw(_id, signal) {
|
|
1557
|
-
if (signal?.aborted === true) return Promise.reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("aborted"));
|
|
1558
|
-
return Promise.reject(/* @__PURE__ */ new Error("this session persistence backend does not expose raw artifacts"));
|
|
1559
|
-
}
|
|
1560
|
-
/**
|
|
1561
|
-
* Ensure a live session has a durable header even when it has no events.
|
|
1562
|
-
* Ordinary sessions remain lazily materialized; lifecycle frontends call
|
|
1563
|
-
* this only when an empty session itself is a durable resumable resource.
|
|
1564
|
-
* @param _session - exact live session whose registered header is materialized.
|
|
1565
|
-
*/
|
|
1566
|
-
ensureMaterialized(_session) {
|
|
1567
|
-
return Promise.reject(/* @__PURE__ */ new Error("this session persistence backend cannot materialize an empty session"));
|
|
1568
|
-
}
|
|
1569
|
-
/**
|
|
1570
|
-
* Prepare the exact unpublished Session used by resume. Implementations may
|
|
1571
|
-
* reuse object graphs retained by an earlier {@link inspect} after confirming
|
|
1572
|
-
* their durable revision is still current; disposal releases an unpublished
|
|
1573
|
-
* reservation. Revision retries require the durable log to remain unchanged
|
|
1574
|
-
* for one read/check round trip; continuous external writers may delay completion.
|
|
1575
|
-
* @param id - persisted session to prepare.
|
|
1576
|
-
* @param signal - optional cancellation for preparation work.
|
|
1577
|
-
* @returns one owned unpublished Session preparation.
|
|
1578
|
-
*/
|
|
1579
|
-
async prepare(id, signal) {
|
|
1580
|
-
signal?.throwIfAborted();
|
|
1581
|
-
const loaded = await this.load(id);
|
|
1582
|
-
signal?.throwIfAborted();
|
|
1583
|
-
const sessions = this.ctx.get("sessions");
|
|
1584
|
-
if (sessions === void 0) throw new Error("cannot prepare a session: SessionStore is not configured");
|
|
1585
|
-
return SessionPreparation.create(sessions.prepare(id, {
|
|
1586
|
-
seed: loaded.events.map((event) => structuredClone(event)),
|
|
1587
|
-
meta: structuredClone(loaded.meta),
|
|
1588
|
-
inheritedEventCount: SessionLogOffset(loaded.inheritedEventCount),
|
|
1589
|
-
seedSource: "persistence"
|
|
1590
|
-
}));
|
|
1591
|
-
}
|
|
1592
265
|
};
|
|
1593
266
|
//#endregion
|
|
1594
|
-
export {
|
|
267
|
+
export { SessionAlreadyExistsError, SessionAlreadyOwnedError, SessionFormatUnsupportedError, SessionHandleClosedError, SessionOwnershipLostError, SessionPersistence, SessionPersistence as default, SessionPersistenceCorruptionError, SessionPersistenceNotFoundError, SessionPersistenceRevision, SessionReadOnlyError, assertContiguous, assertStoredId, assertVersion, materializeAppendBatch, materializeCreateHeader, sessionFormatVersionRefusal, validateStoredEvents };
|