@deepseek-ai/dsh-session-persistence 0.0.1-rc.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/lib/index.js ADDED
@@ -0,0 +1,1314 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { SESSION_FORMAT_VERSION, SessionPreparation, adoptSessionEvent, interruptedTurnClosers, snapshotJsonValue, snapshotSessionEvent } from "@deepseek-ai/dsh-session";
3
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
4
+ //#region lib/types/revision.js
5
+ /** Opaque revision identity for lightweight persistence observations. */
6
+ /**
7
+ * Brand a backend revision for the provider-neutral persistence contract.
8
+ * @param value - backend-owned opaque revision representation.
9
+ * @returns the same runtime string with persistence-revision identity.
10
+ */
11
+ function SessionPersistenceRevision(value) {
12
+ return value;
13
+ }
14
+ //#endregion
15
+ //#region lib/types/preparations.js
16
+ /**
17
+ * Bounded sharing and exclusive reservation of unpublished Sessions.
18
+ * @module @deepseek-ai/dsh-session-persistence/preparations
19
+ */
20
+ /** Per-coordinator cold-read sharing, exclusive reservation, and ready-entry LRU. */
21
+ var SessionPreparations = class {
22
+ capacity;
23
+ entries = /* @__PURE__ */ new Map();
24
+ constructor(capacity) {
25
+ this.capacity = capacity;
26
+ }
27
+ /**
28
+ * Whether this pool currently knows about an unpublished identity.
29
+ * @param id - session identity.
30
+ * @returns whether an entry exists for the identity.
31
+ */
32
+ has(id) {
33
+ return this.entries.has(id);
34
+ }
35
+ /**
36
+ * Observe one prepared source, sharing an in-flight read for the same id.
37
+ * @param id - session identity.
38
+ * @param load - cold loader used when no entry exists.
39
+ * @param signal - optional cancellation signal while waiting.
40
+ * @returns the shared prepared source.
41
+ */
42
+ async inspect(id, load, signal) {
43
+ const entry = this.entryFor(id, load);
44
+ const loaded = signal === void 0 ? await entry.result : await observeQueuedAbort(entry.result, signal);
45
+ const source = entry.source ?? loaded;
46
+ if (this.entries.get(id) === entry && entry.phase === "ready") this.touch(entry);
47
+ return source;
48
+ }
49
+ /**
50
+ * Reserve one ready source after committing its pending durable repair.
51
+ * @param id - session identity.
52
+ * @param load - cold loader used when no entry exists.
53
+ * @param commit - durable repair and cursor-state commit.
54
+ * @param signal - optional cancellation signal while waiting.
55
+ * @returns the exclusive reservation, or undefined if its entry was invalidated.
56
+ */
57
+ async reserve(id, load, commit, signal) {
58
+ const entry = this.entryFor(id, load);
59
+ await (signal === void 0 ? entry.result : observeQueuedAbort(entry.result, signal));
60
+ while (this.entries.get(id) === entry && entry.phase !== "ready") {
61
+ const settled = entry.reservationSettled;
62
+ /* v8 ignore next -- committing/reserved transitions install this waiter synchronously. */
63
+ if (settled === void 0) throw new Error(`session "${id}" preparation lost its reservation waiter`);
64
+ if (signal === void 0) await settled;
65
+ else await observeQueuedAbort(settled, signal);
66
+ }
67
+ if (this.entries.get(id) !== entry) return void 0;
68
+ const source = entry.source;
69
+ const reservationSettled = Promise.withResolvers();
70
+ entry.phase = "committing";
71
+ entry.reservationSettled = reservationSettled.promise;
72
+ entry.settleReservation = reservationSettled.resolve;
73
+ let committed;
74
+ try {
75
+ committed = await commit(source);
76
+ } catch (error) {
77
+ this.remove(entry);
78
+ throw error;
79
+ }
80
+ if (committed === void 0) {
81
+ this.remove(entry);
82
+ return;
83
+ }
84
+ entry.source = committed.source;
85
+ try {
86
+ signal?.throwIfAborted();
87
+ } catch (error) {
88
+ this.makeReady(entry);
89
+ throw error;
90
+ }
91
+ if (this.entries.get(id) !== entry) return void 0;
92
+ const reservation = {
93
+ entry,
94
+ source: committed.source,
95
+ state: committed.state
96
+ };
97
+ entry.phase = "reserved";
98
+ entry.reservation = reservation;
99
+ return reservation;
100
+ }
101
+ /**
102
+ * Return the exact reservation for Session publication, rejecting aliases.
103
+ * @param session - exact Session candidate for publication.
104
+ * @returns its reservation, or undefined when no preparation exists.
105
+ */
106
+ reservationFor(session) {
107
+ const entry = this.entries.get(session.id);
108
+ if (entry === void 0) return void 0;
109
+ if (entry.phase === "reserved" && entry.source?.session === session && entry.reservation !== void 0) return entry.reservation;
110
+ throw new Error(`cannot publish session "${session.id}": persisted state already owns this identity`);
111
+ }
112
+ /**
113
+ * Consume a reservation after its exact Session has attached.
114
+ * @param reservation - reservation to consume.
115
+ */
116
+ attach(reservation) {
117
+ const { entry } = reservation;
118
+ if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) throw new Error(`session "${entry.id}" preparation is no longer reserved`);
119
+ this.remove(entry);
120
+ }
121
+ /**
122
+ * Consume a reservation whose caller only needs the committed inspection.
123
+ * @param reservation - reservation to consume.
124
+ */
125
+ discard(reservation) {
126
+ const { entry } = reservation;
127
+ if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation) return;
128
+ this.remove(entry);
129
+ }
130
+ /**
131
+ * Return a reusable unpublished reservation to the ready LRU.
132
+ * @param reservation - reservation to release.
133
+ * @param reusable - whether the source remains valid for reuse.
134
+ */
135
+ release(reservation, reusable) {
136
+ const { entry } = reservation;
137
+ if (this.entries.get(entry.id) !== entry || entry.reservation !== reservation || entry.phase !== "reserved") return;
138
+ if (!reusable) {
139
+ this.remove(entry);
140
+ return;
141
+ }
142
+ delete entry.reservation;
143
+ this.makeReady(entry);
144
+ }
145
+ /**
146
+ * Discard a prepared view after the durable log changes.
147
+ * @param id - changed session identity.
148
+ */
149
+ invalidate(id) {
150
+ const entry = this.entries.get(id);
151
+ if (entry !== void 0) this.remove(entry);
152
+ }
153
+ /**
154
+ * Discard an exact stale ready source without disturbing an exclusive owner.
155
+ * @param id - changed session identity.
156
+ * @param expected - exact source observed before its revision check.
157
+ * @returns whether the source was discarded, retained by a reservation, or is absent.
158
+ */
159
+ discardReady(id, expected) {
160
+ const entry = this.entries.get(id);
161
+ if (entry === void 0 || entry.source !== expected) return "missing";
162
+ if (entry.phase !== "ready") return "retained";
163
+ this.remove(entry);
164
+ return "discarded";
165
+ }
166
+ /**
167
+ * Reject writes while an unpublished Session exclusively reserves the id.
168
+ * @param id - session identity to check.
169
+ */
170
+ assertWritable(id) {
171
+ const phase = this.entries.get(id)?.phase;
172
+ if (phase === "committing" || phase === "reserved") throw new Error(`cannot append session "${id}" while its persisted preparation is reserved`);
173
+ }
174
+ /**
175
+ * Remove a completed entry for an already-serialized append adoption.
176
+ * @param id - adopted session identity.
177
+ * @returns the prepared source, or undefined when no ready entry exists.
178
+ */
179
+ takeReady(id) {
180
+ const entry = this.entries.get(id);
181
+ if (entry === void 0 || entry.phase !== "ready" || entry.source === void 0) return void 0;
182
+ this.remove(entry);
183
+ return entry.source;
184
+ }
185
+ entryFor(id, load) {
186
+ const existing = this.entries.get(id);
187
+ if (existing !== void 0) return existing;
188
+ const deferred = Promise.withResolvers();
189
+ const entry = {
190
+ id,
191
+ result: deferred.promise,
192
+ phase: "loading"
193
+ };
194
+ this.entries.set(id, entry);
195
+ let loading;
196
+ try {
197
+ loading = load();
198
+ } catch (error) {
199
+ this.remove(entry);
200
+ deferred.reject(error);
201
+ return entry;
202
+ }
203
+ loading.then((source) => {
204
+ if (this.entries.get(id) === entry) {
205
+ entry.source = source;
206
+ this.makeReady(entry);
207
+ }
208
+ deferred.resolve(source);
209
+ }, (error) => {
210
+ this.remove(entry);
211
+ deferred.reject(error);
212
+ });
213
+ return entry;
214
+ }
215
+ makeReady(entry) {
216
+ if (this.entries.get(entry.id) !== entry) return;
217
+ entry.phase = "ready";
218
+ const settle = entry.settleReservation;
219
+ delete entry.reservationSettled;
220
+ delete entry.settleReservation;
221
+ settle?.();
222
+ this.touch(entry);
223
+ }
224
+ remove(entry) {
225
+ if (this.entries.get(entry.id) !== entry) return;
226
+ this.entries.delete(entry.id);
227
+ const settle = entry.settleReservation;
228
+ delete entry.reservationSettled;
229
+ delete entry.settleReservation;
230
+ settle?.();
231
+ }
232
+ touch(entry) {
233
+ this.entries.delete(entry.id);
234
+ this.entries.set(entry.id, entry);
235
+ let readyCount = 0;
236
+ for (const candidate of this.entries.values()) if (candidate.phase === "ready") readyCount += 1;
237
+ if (readyCount <= this.capacity) return;
238
+ for (const [id, candidate] of this.entries) {
239
+ if (candidate.phase !== "ready") continue;
240
+ this.entries.delete(id);
241
+ return;
242
+ }
243
+ }
244
+ };
245
+ /**
246
+ * Give a queued observer a prompt cancellation view without cancelling shared work.
247
+ * @param operation - shared operation whose settlement remains authoritative.
248
+ * @param signal - observer-local cancellation signal.
249
+ * @param started - whether the operation has crossed its cancellation cutoff.
250
+ * @returns the operation result or the observer's prompt cancellation.
251
+ */
252
+ function observeQueuedAbort(operation, signal, started = () => false) {
253
+ return new Promise((resolve, reject) => {
254
+ let settled = false;
255
+ const finish = (callback) => {
256
+ if (settled) return;
257
+ settled = true;
258
+ signal.removeEventListener("abort", onAbort);
259
+ callback();
260
+ };
261
+ const onAbort = () => {
262
+ if (started()) return;
263
+ finish(() => {
264
+ try {
265
+ signal.throwIfAborted();
266
+ } catch (reason) {
267
+ rejectObservation(reject, reason);
268
+ return;
269
+ }
270
+ /* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted. */
271
+ reject(/* @__PURE__ */ new Error("queued observation abort event lacked an aborted signal"));
272
+ });
273
+ };
274
+ signal.addEventListener("abort", onAbort, { once: true });
275
+ operation.then((value) => {
276
+ finish(() => {
277
+ resolve(value);
278
+ });
279
+ }, (reason) => {
280
+ finish(() => {
281
+ rejectObservation(reject, reason);
282
+ });
283
+ });
284
+ if (signal.aborted) onAbort();
285
+ });
286
+ }
287
+ /** Preserve an exact loader or AbortSignal reason, including legacy non-Error values. */
288
+ function rejectObservation(reject, reason) {
289
+ reject(reason);
290
+ }
291
+ //#endregion
292
+ //#region lib/types/write-behind.js
293
+ /**
294
+ * Bounded per-session write batching for the shared persistence coordinator.
295
+ * @module @deepseek-ai/dsh-session-persistence/write-behind
296
+ */
297
+ /**
298
+ * Owns one live session's pending events, fixed batching deadline, active write,
299
+ * failure retention, and explicit quiescence barrier.
300
+ */
301
+ var SessionWriteBehind = class {
302
+ options;
303
+ pending = [];
304
+ timer;
305
+ active;
306
+ barrier;
307
+ deadlineExpired = false;
308
+ automaticPaused = false;
309
+ /**
310
+ * @param options - fixed scheduling policy and durable batch sink.
311
+ */
312
+ constructor(options) {
313
+ this.options = options;
314
+ }
315
+ /** Whether this controller owns queued events or an active durable write. */
316
+ get hasWork() {
317
+ return this.pending.length > 0 || this.active !== void 0;
318
+ }
319
+ /**
320
+ * Copy one event into the persistence-owned queue and start a fixed deadline
321
+ * when the automatic path is idle.
322
+ * @param event - frozen live event to retain independently of its producer.
323
+ */
324
+ enqueue(event) {
325
+ const wasEmpty = this.pending.length === 0;
326
+ this.pending.push(structuredClone(event));
327
+ if (this.barrier !== void 0) return;
328
+ if (this.automaticPaused) {
329
+ this.automaticPaused = false;
330
+ this.deadlineExpired = false;
331
+ this.armTimer();
332
+ } else if (wasEmpty) this.armTimer();
333
+ }
334
+ /**
335
+ * Cancel the batching wait and durably drain through a quiescent point.
336
+ * Concurrent callers join the same barrier.
337
+ * @returns a promise that rejects if the barrier's durable retry fails.
338
+ */
339
+ flush() {
340
+ if (this.barrier !== void 0) return this.barrier;
341
+ this.cancelTimer();
342
+ this.deadlineExpired = false;
343
+ this.automaticPaused = false;
344
+ const barrier = Promise.withResolvers();
345
+ this.barrier = barrier.promise;
346
+ this.drainBarrier(barrier.resolve, barrier.reject);
347
+ return barrier.promise;
348
+ }
349
+ /** Cancel the current automatic deadline without draining retained work. */
350
+ cancelAutomaticWait() {
351
+ this.cancelTimer();
352
+ this.deadlineExpired = false;
353
+ }
354
+ /** Start the one fixed window for the current pending prefix. */
355
+ armTimer() {
356
+ this.timer = setTimeout(() => {
357
+ this.onDeadline();
358
+ }, this.options.maxDelayMs);
359
+ }
360
+ /** Cancel any pending automatic deadline. */
361
+ cancelTimer() {
362
+ if (this.timer === void 0) return;
363
+ clearTimeout(this.timer);
364
+ this.timer = void 0;
365
+ }
366
+ /** Start a background write now, or remember that an active write used the budget. */
367
+ onDeadline() {
368
+ this.timer = void 0;
369
+ if (this.active !== void 0) {
370
+ this.deadlineExpired = true;
371
+ return;
372
+ }
373
+ this.startBackground();
374
+ }
375
+ /** Start one detached write whose failure is reported and retained. */
376
+ startBackground() {
377
+ this.startWrite(true).then(() => {
378
+ this.continueAutomatic();
379
+ }, () => {});
380
+ }
381
+ /** Continue immediately after an over-budget active write, otherwise keep its timer. */
382
+ continueAutomatic() {
383
+ if (this.barrier !== void 0 || this.pending.length === 0) return;
384
+ if (this.deadlineExpired) {
385
+ this.deadlineExpired = false;
386
+ this.startBackground();
387
+ }
388
+ }
389
+ /** Await overlapping work, drain to quiescence, and settle the shared barrier. */
390
+ async drainBarrier(resolve, reject) {
391
+ try {
392
+ const overlapping = this.active;
393
+ if (overlapping !== void 0) {
394
+ await Promise.allSettled([overlapping]);
395
+ this.automaticPaused = false;
396
+ }
397
+ while (this.pending.length > 0) await this.startWrite(false);
398
+ } catch (error) {
399
+ this.barrier = void 0;
400
+ reject(error);
401
+ return;
402
+ }
403
+ this.barrier = void 0;
404
+ resolve();
405
+ }
406
+ /** Start one stable pending prefix, retaining it in order if durability fails. */
407
+ startWrite(background) {
408
+ const batch = this.pending.splice(0);
409
+ this.cancelTimer();
410
+ this.deadlineExpired = false;
411
+ const active = Promise.resolve().then(() => this.options.write(batch)).catch((error) => {
412
+ this.pending = batch.concat(this.pending);
413
+ this.cancelTimer();
414
+ this.deadlineExpired = false;
415
+ this.automaticPaused = true;
416
+ if (background) this.options.reportBackgroundFailure(error);
417
+ throw error;
418
+ }).finally(() => {
419
+ this.active = void 0;
420
+ });
421
+ this.active = active;
422
+ return active;
423
+ }
424
+ };
425
+ //#endregion
426
+ //#region lib/types/coordinator.js
427
+ /**
428
+ * Shared buffering, serialization, adoption, repair, and disposal orchestration
429
+ * for first-party backends. Third-party backends may implement the public
430
+ * persistence seam directly.
431
+ * @module @deepseek-ai/dsh-session-persistence/coordinator
432
+ */
433
+ /** Default number of detached session preparations retained by a coordinator. */
434
+ const DEFAULT_PREPARED_SESSION_CACHE_SIZE = 5;
435
+ /** Default maximum intentional wait before a live session batch starts writing. */
436
+ const DEFAULT_WRITE_BATCH_MAX_DELAY_MS = 200;
437
+ /** Largest write batching delay accepted by Node's timer implementation. */
438
+ const MAX_WRITE_BATCH_DELAY_MS = MAX_TIMER_DELAY_MS;
439
+ /** Durable session contents failed validation after a successful backend read. */
440
+ var SessionPersistenceCorruptionError = class extends Error {
441
+ /**
442
+ * @param message - stable corruption context.
443
+ * @param options - original validation failure.
444
+ */
445
+ constructor(message, options) {
446
+ super(message, options);
447
+ this.name = "SessionPersistenceCorruptionError";
448
+ }
449
+ };
450
+ /** Collect the rejection reasons from a set of promises (none-throwing). */
451
+ async function settledErrors(promises) {
452
+ const settled = await Promise.allSettled([...promises]);
453
+ const errors = [];
454
+ for (const result of settled) if (result.status === "rejected") errors.push(result.reason);
455
+ return errors;
456
+ }
457
+ /** Whether a live session seed reproduces a persisted prefix exactly. */
458
+ function seedCoversPrefix(seed, prefix) {
459
+ return prefix.length <= seed.length && prefix.every((event, index) => {
460
+ const seedEvent = seed[index];
461
+ return seedEvent !== void 0 && JSON.stringify(seedEvent) === JSON.stringify(event);
462
+ });
463
+ }
464
+ /** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
465
+ function assertSupportedEvents(events, id) {
466
+ const legacyType = "request/header-delta";
467
+ const legacy = events.find((event) => event.type === legacyType);
468
+ if (legacy !== void 0) throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`);
469
+ const legacyModeType = "mode/set";
470
+ const legacyMode = events.find((event) => event.type === legacyModeType);
471
+ if (legacyMode !== void 0) throw new Error(`session "${id}" contains unsupported legacy mode/set event at seq ${legacyMode.seq}`);
472
+ const fallback = events.find((event) => event.type === "request/header" && event.data.reason === "fallback");
473
+ if (fallback !== void 0) throw new Error(`session "${id}" contains unsupported legacy request/header reason "fallback" at seq ${fallback.seq}`);
474
+ }
475
+ /** Return an object record without widening arrays into message payloads. */
476
+ function asRecord(value) {
477
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
478
+ }
479
+ /** Whether a record contains every required key and no key outside the optional extension set. */
480
+ function hasOnlyKeys(record, required, optional = []) {
481
+ const allowed = [...required, ...optional];
482
+ return Object.keys(record).every((key) => allowed.includes(key)) && required.every((key) => Object.hasOwn(record, key));
483
+ }
484
+ /** Mint the stable import identity for a message persisted before identities existed. */
485
+ function legacyMessageId(id, seq) {
486
+ return `legacy-message:${id}:${seq}`;
487
+ }
488
+ /** Read a replacement target while leaving malformed surface metadata to the session validator. */
489
+ function replacementStart(event) {
490
+ const op = asRecord(event.surfaceOp);
491
+ return op?.["op"] === "replace" && typeof op["start"] === "number" ? op["start"] : void 0;
492
+ }
493
+ /** Whether one suffix event needs facts available only from the preceding stored prefix. */
494
+ function needsLegacyPrefix(event) {
495
+ const data = asRecord(event.data);
496
+ if (event.type === "steering/message") return true;
497
+ if (data === void 0) return false;
498
+ switch (event.type) {
499
+ case "user/message": return !Object.hasOwn(data, "id") && Object.hasOwn(data, "content");
500
+ case "assistant/message": return !Object.hasOwn(data, "message") && Object.hasOwn(data, "content");
501
+ case "tool/result": return !Object.hasOwn(data, "message") && Object.hasOwn(data, "callId");
502
+ default: return false;
503
+ }
504
+ }
505
+ /** Upgrade the removed steering surface event into its current user-message equivalent. */
506
+ function migrateLegacySteeringEvent(event, id) {
507
+ if (event.type !== "steering/message") return event;
508
+ const data = asRecord(event.data);
509
+ if (data === void 0) throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`);
510
+ const wrapped = asRecord(data["message"]);
511
+ if (wrapped !== void 0 && Number.isSafeInteger(data["turn"]) && hasOnlyKeys(data, ["turn", "message"])) return {
512
+ ...event,
513
+ type: "user/message",
514
+ data: wrapped
515
+ };
516
+ if (!Number.isSafeInteger(data["turn"]) || !hasOnlyKeys(data, [
517
+ "turn",
518
+ "content",
519
+ "source"
520
+ ])) throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`);
521
+ const { turn: _turn, ...message } = data;
522
+ return {
523
+ ...event,
524
+ type: "user/message",
525
+ data: {
526
+ ...message,
527
+ id: legacyMessageId(id, event.seq),
528
+ role: "user"
529
+ }
530
+ };
531
+ }
532
+ /** Remove the obsolete trigger after verifying the complete old turn-start envelope. */
533
+ function migrateLegacyTurnStartEvent(event, id) {
534
+ if (event.type !== "turn/start") return event;
535
+ const data = asRecord(event.data);
536
+ if (data === void 0 || !Object.hasOwn(data, "trigger")) return event;
537
+ const trigger = asRecord(data["trigger"]);
538
+ if (!Number.isSafeInteger(data["turn"]) || data["turn"] < 1 || !hasOnlyKeys(data, ["turn", "trigger"]) || trigger === void 0 || typeof trigger["kind"] !== "string" || trigger["kind"].length === 0) throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`);
539
+ return {
540
+ ...event,
541
+ data: { turn: data["turn"] }
542
+ };
543
+ }
544
+ /** Upgrade an obsolete turn ending while preserving the latest-master envelope. */
545
+ function migrateLegacyTurnEndEvent(event, id) {
546
+ if (event.type !== "turn/end") return event;
547
+ const data = asRecord(event.data);
548
+ /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */
549
+ if (data === void 0) return event;
550
+ const malformed = () => {
551
+ throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`);
552
+ };
553
+ const reason = asRecord(data["reason"]);
554
+ if (!Number.isSafeInteger(data["turn"]) || data["turn"] < 1 || !hasOnlyKeys(data, ["turn", "reason"]) || reason === void 0 || typeof reason["kind"] !== "string") return malformed();
555
+ let currentReason;
556
+ switch (reason["kind"]) {
557
+ case "completed":
558
+ case "blocked":
559
+ case "max-tokens":
560
+ case "interrupted":
561
+ if (!hasOnlyKeys(reason, ["kind"])) return malformed();
562
+ return event;
563
+ case "aborted":
564
+ if (Object.hasOwn(reason, "reason")) return event;
565
+ if (!hasOnlyKeys(reason, ["kind"])) return malformed();
566
+ currentReason = {
567
+ kind: "aborted",
568
+ reason: { kind: "legacy" }
569
+ };
570
+ break;
571
+ case "disposed":
572
+ if (!hasOnlyKeys(reason, ["kind"])) return malformed();
573
+ currentReason = {
574
+ kind: "aborted",
575
+ reason: { kind: "disposed" }
576
+ };
577
+ break;
578
+ case "error": {
579
+ if (Object.hasOwn(reason, "error")) return event;
580
+ if (!Number.isSafeInteger(reason["step"]) || reason["step"] < 0) return malformed();
581
+ const failure = asRecord(reason["failure"]);
582
+ if (failure !== void 0 && hasOnlyKeys(reason, [
583
+ "kind",
584
+ "step",
585
+ "failure"
586
+ ]) && hasOnlyKeys(failure, ["message", "code"], [
587
+ "status",
588
+ "providerRetryAfterMs",
589
+ "requestId"
590
+ ]) && 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")) {
591
+ currentReason = {
592
+ kind: "error",
593
+ error: failure
594
+ };
595
+ break;
596
+ }
597
+ if (!hasOnlyKeys(reason, reason["code"] === void 0 ? [
598
+ "kind",
599
+ "step",
600
+ "message"
601
+ ] : [
602
+ "kind",
603
+ "step",
604
+ "message",
605
+ "code"
606
+ ]) || typeof reason["message"] !== "string" || reason["code"] !== void 0 && typeof reason["code"] !== "string") return malformed();
607
+ currentReason = {
608
+ kind: "error",
609
+ error: {
610
+ message: reason["message"],
611
+ code: typeof reason["code"] === "string" ? reason["code"] : "UNKNOWN"
612
+ }
613
+ };
614
+ break;
615
+ }
616
+ default: return event;
617
+ }
618
+ return {
619
+ ...event,
620
+ data: {
621
+ ...data,
622
+ reason: currentReason
623
+ }
624
+ };
625
+ }
626
+ /**
627
+ * Upgrade one pre-identity message event into the current wrapper shape.
628
+ * Current-looking malformed events remain untouched so validation rejects them
629
+ * instead of disguising corruption as legacy data.
630
+ */
631
+ function migrateLegacyMessageEvent(event, id, messageIds) {
632
+ const data = asRecord(event.data);
633
+ if (data === void 0) return event;
634
+ switch (event.type) {
635
+ case "user/message":
636
+ if (Object.hasOwn(data, "id") || Object.hasOwn(data, "role") || Object.hasOwn(data, "message") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "source")) return event;
637
+ return {
638
+ ...event,
639
+ data: {
640
+ ...data,
641
+ id: legacyMessageId(id, event.seq),
642
+ role: "user"
643
+ }
644
+ };
645
+ case "assistant/message": {
646
+ if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "provenance")) return event;
647
+ const { content, provenance, ...eventData } = data;
648
+ return {
649
+ ...event,
650
+ data: {
651
+ ...eventData,
652
+ message: {
653
+ id: legacyMessageId(id, event.seq),
654
+ role: "assistant",
655
+ content,
656
+ source: {
657
+ ...asRecord(provenance),
658
+ kind: "model"
659
+ }
660
+ }
661
+ }
662
+ };
663
+ }
664
+ case "tool/result": {
665
+ if (Object.hasOwn(data, "message") || !Object.hasOwn(data, "callId") || !Object.hasOwn(data, "content") || !Object.hasOwn(data, "isError")) return event;
666
+ const { callId, content, isError, ...eventData } = data;
667
+ const inheritedId = replacementStart(event);
668
+ return {
669
+ ...event,
670
+ data: {
671
+ ...eventData,
672
+ message: {
673
+ id: inheritedId === void 0 ? legacyMessageId(id, event.seq) : messageIds.get(inheritedId),
674
+ role: "user",
675
+ content: [{
676
+ type: "tool-result",
677
+ toolCallId: callId,
678
+ content,
679
+ isError
680
+ }],
681
+ source: {
682
+ kind: "tool",
683
+ callId
684
+ }
685
+ }
686
+ }
687
+ };
688
+ }
689
+ default: return event;
690
+ }
691
+ }
692
+ /** Read the identified message carried by one validated current event. */
693
+ function eventMessageId(event) {
694
+ const data = asRecord(event.data);
695
+ const message = event.type === "user/message" ? data : asRecord(data?.["message"]);
696
+ return typeof message?.["id"] === "string" ? message["id"] : void 0;
697
+ }
698
+ /** Materialize stored events as upgraded, validated snapshots with immutable messages. */
699
+ function snapshotStoredEvents(events, id) {
700
+ assertSupportedEvents(events, id);
701
+ const messageIds = /* @__PURE__ */ new Map();
702
+ return events.map((event) => {
703
+ const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migrateLegacySteeringEvent(migrateLegacyTurnEndEvent(migrateLegacyTurnStartEvent(event, id), id), id), id, messageIds));
704
+ const messageId = eventMessageId(snapshot);
705
+ if (messageId !== void 0) messageIds.set(snapshot.seq, messageId);
706
+ return snapshot;
707
+ });
708
+ }
709
+ /** Upgrade and validate an exclusively owned backend result without copying it. */
710
+ function adoptStoredEvents(events, id) {
711
+ assertSupportedEvents(events, id);
712
+ const messageIds = /* @__PURE__ */ new Map();
713
+ for (const [index, event] of events.entries()) {
714
+ const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migrateLegacySteeringEvent(migrateLegacyTurnEndEvent(migrateLegacyTurnStartEvent(event, id), id), id), id, messageIds));
715
+ events[index] = adopted;
716
+ const messageId = eventMessageId(adopted);
717
+ if (messageId !== void 0) messageIds.set(adopted.seq, messageId);
718
+ }
719
+ return events;
720
+ }
721
+ /**
722
+ * Owns the backend-agnostic session write-path orchestration. A backend
723
+ * constructs one (`new PersistenceCoordinator(ctx, this)`), implements
724
+ * {@link PersistenceBackend}, and delegates its write/read service methods to
725
+ * the matching coordinator methods.
726
+ *
727
+ * All per-id operations are serialized (a per-id promise chain) so concurrent
728
+ * flushes / a flush racing a load never interleave storage writes. The
729
+ * constructor installs the write-path listeners, per-session retirement, and
730
+ * the backend dispose effect.
731
+ *
732
+ * @typeParam TornMarker - the backend's opaque torn-tail repair token.
733
+ */
734
+ var PersistenceCoordinator = class {
735
+ ctx;
736
+ backend;
737
+ /** Backend bookkeeping keyed by session id (NOT the live Session object). */
738
+ states = /* @__PURE__ */ new Map();
739
+ /** Lifecycle and write-behind state keyed by the exact live Session. */
740
+ live = /* @__PURE__ */ new Map();
741
+ /** Exact disposed lifecycles whose buffered tail is still draining. */
742
+ retirements = /* @__PURE__ */ new Map();
743
+ /** Shared cold reads, unpublished reservations, and completed LRU entries. */
744
+ preparations;
745
+ /**
746
+ * Per-session serialization: every operation chains onto the prior one for the
747
+ * same id, so writes for one session never interleave. Keyed by session id.
748
+ */
749
+ chains = /* @__PURE__ */ new Map();
750
+ /** Resolved fixed write-batching window shared by per-session controllers. */
751
+ writeBatchMaxDelayMs;
752
+ constructor(ctx, backend, options = {
753
+ preparedSessionCacheSize: 5,
754
+ writeBatchMaxDelayMs: 200
755
+ }) {
756
+ this.ctx = ctx;
757
+ this.backend = backend;
758
+ if (!Number.isSafeInteger(options.preparedSessionCacheSize) || options.preparedSessionCacheSize < 1) throw new TypeError("preparedSessionCacheSize must be a positive safe integer");
759
+ if (!Number.isSafeInteger(options.writeBatchMaxDelayMs) || options.writeBatchMaxDelayMs < 1 || options.writeBatchMaxDelayMs > MAX_WRITE_BATCH_DELAY_MS) throw new TypeError(`writeBatchMaxDelayMs must be an integer between 1 and ${MAX_WRITE_BATCH_DELAY_MS}`);
760
+ this.writeBatchMaxDelayMs = options.writeBatchMaxDelayMs;
761
+ this.preparations = new SessionPreparations(options.preparedSessionCacheSize);
762
+ this.installWritePath();
763
+ }
764
+ /**
765
+ * Register detached session metadata for lazy creation on the first append.
766
+ * @param meta - header to snapshot; duplicate tracked or persisted ids reject.
767
+ */
768
+ create(meta) {
769
+ const snapshot = snapshotJsonValue(meta);
770
+ if (snapshot === void 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata must be losslessly JSON-serializable"));
771
+ if (!Number.isSafeInteger(snapshot.createdAt) || snapshot.createdAt < 0) return Promise.reject(/* @__PURE__ */ new TypeError("session metadata createdAt must be a non-negative safe integer"));
772
+ return this.serialize(snapshot.id, () => this.createCore(snapshot));
773
+ }
774
+ async createCore(meta) {
775
+ if (this.states.has(meta.id) || this.preparations.has(meta.id)) throw new Error(`session "${meta.id}" already exists in this backend`);
776
+ 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`);
777
+ this.states.set(meta.id, {
778
+ meta,
779
+ cursor: 0,
780
+ materialized: false
781
+ });
782
+ }
783
+ /**
784
+ * Durably persist a batch of events. Honors the append-only and contiguous-seq
785
+ * contracts; rejects non-JSON-serializable `event.data`.
786
+ * @param id - the session the batch belongs to.
787
+ * @param events - the contiguous batch to persist, in seq order; materialized
788
+ * as a detached lossless-JSON snapshot at call time.
789
+ */
790
+ async append(id, events) {
791
+ const batch = snapshotJsonValue(events);
792
+ if (batch === void 0) throw new TypeError("session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data");
793
+ return this.serialize(id, () => this.appendCore(id, batch));
794
+ }
795
+ async appendCore(id, events) {
796
+ assertSupportedEvents(events, id);
797
+ if (events.length === 0) return;
798
+ this.preparations.assertWritable(id);
799
+ let state = this.states.get(id);
800
+ if (state === void 0) state = await this.adopt(id);
801
+ 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}`);
802
+ await this.backend.appendBatch(state.meta, events, state.materialized);
803
+ state.materialized = true;
804
+ state.cursor += events.length;
805
+ this.preparations.invalidate(id);
806
+ }
807
+ /**
808
+ * Prepare and reserve the exact unpublished Session used by resume.
809
+ * Revision retries converge once the durable log remains unchanged for one
810
+ * read/check round trip; continuous external writers may delay completion.
811
+ * @param id - persisted session to prepare.
812
+ * @param signal - optional cancellation for reading and repair.
813
+ * @returns an owned preparation released after publication or rollback.
814
+ */
815
+ async prepare(id, signal) {
816
+ for (;;) {
817
+ await this.waitForRetirement(id, signal);
818
+ if (this.ctx.sessions.get(id) !== void 0) throw new Error(`cannot prepare session "${id}" while it is live`);
819
+ const reservation = await this.preparations.reserve(id, () => this.serialize(id, () => this.prepareCore(id)), (source) => this.serialize(id, () => this.commitPrepared(source), signal), signal);
820
+ if (reservation === void 0) continue;
821
+ if (this.ctx.sessions.get(id) !== void 0) {
822
+ this.preparations.release(reservation, false);
823
+ throw new Error(`cannot prepare session "${id}" while it is live`);
824
+ }
825
+ return SessionPreparation.create(reservation.source.session, { release: () => {
826
+ this.preparations.release(reservation, reservation.state.owner === void 0 && reservation.source.session.events.length === reservation.source.sessionLength);
827
+ } });
828
+ }
829
+ }
830
+ /**
831
+ * Commit recovery and return its immutable logical view without publication.
832
+ * Revision retries converge once the durable log remains unchanged for one
833
+ * read/check round trip; continuous external writers may delay completion.
834
+ * @param id - persisted session to load.
835
+ * @returns prepared header and balanced events.
836
+ */
837
+ async load(id) {
838
+ for (;;) {
839
+ await this.waitForRetirement(id);
840
+ const live = this.ctx.sessions.get(id);
841
+ if (live !== void 0) return this.loadLiveSnapshot(live);
842
+ const reservation = await this.preparations.reserve(id, () => this.serialize(id, () => this.prepareCore(id)), (source) => this.serialize(id, () => this.commitPrepared(source)));
843
+ if (reservation === void 0) continue;
844
+ const attached = this.ctx.sessions.get(id);
845
+ if (attached !== void 0) {
846
+ this.preparations.discard(reservation);
847
+ return this.loadLiveSnapshot(attached);
848
+ }
849
+ this.preparations.discard(reservation);
850
+ return reservation.source.inspection;
851
+ }
852
+ }
853
+ /**
854
+ * Inspect a logical session without publishing it or committing recovery.
855
+ * A stale ready source is reloaded. A source already committing or reserved
856
+ * for resume remains exclusive, and inspection may borrow its immutable view.
857
+ * Revision retries converge once the log is stable for one read/check round
858
+ * trip; continuous external writers may delay completion.
859
+ * @param id - persisted session to inspect.
860
+ * @param signal - optional cancellation for preparation work.
861
+ * @returns immutable prepared metadata and events; a live view may have an open turn.
862
+ */
863
+ async inspect(id, signal) {
864
+ for (;;) {
865
+ signal?.throwIfAborted();
866
+ if (this.retirements.has(id)) await this.waitForRetirement(id, signal);
867
+ const live = this.ctx.sessions.get(id);
868
+ if (live !== void 0) return this.inspectLive(live);
869
+ try {
870
+ const source = await this.preparations.inspect(id, () => this.serialize(id, () => this.prepareCore(id)), signal);
871
+ const attached = this.ctx.sessions.get(id);
872
+ if (attached !== void 0) return this.inspectLive(attached);
873
+ const current = await this.serialize(id, () => this.isPreparedSourceCurrent(source, signal), signal);
874
+ const published = this.ctx.sessions.get(id);
875
+ if (published !== void 0) return this.inspectLive(published);
876
+ if (current) return source.inspection;
877
+ if (this.preparations.discardReady(id, source) === "retained") return source.inspection;
878
+ } catch (error) {
879
+ signal?.throwIfAborted();
880
+ const attached = this.ctx.sessions.get(id);
881
+ if (attached !== void 0) return this.inspectLive(attached);
882
+ throw error;
883
+ }
884
+ }
885
+ }
886
+ /**
887
+ * Read the stored events from `fromSeq` onward, detached and non-mutating
888
+ * (the read-from-seq primitive behind the service's `readFrom`). Runs on
889
+ * the same per-id chain as writes; a backend with the seek-capable
890
+ * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix,
891
+ * every other backend reads its stored prefix and skips forward here.
892
+ * @param id - persisted session to read.
893
+ * @param fromSeq - first event seq to include; a non-negative safe integer.
894
+ * @param signal - optional cancellation for queued and backend read work.
895
+ * @returns stored header and the valid stored events with `seq >= fromSeq`.
896
+ */
897
+ readFrom(id, fromSeq, signal) {
898
+ if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) return Promise.reject(/* @__PURE__ */ new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`));
899
+ const retired = Promise.resolve(this.retirements.get(id));
900
+ return (signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false)).then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal));
901
+ }
902
+ async readFromCore(id, fromSeq, signal) {
903
+ signal?.throwIfAborted();
904
+ if (this.backend.loadStoredFrom !== void 0) {
905
+ let suffix;
906
+ try {
907
+ suffix = await this.backend.loadStoredFrom(id, fromSeq, signal);
908
+ } catch (error) {
909
+ if (signal?.aborted) signal.throwIfAborted();
910
+ throw error;
911
+ }
912
+ signal?.throwIfAborted();
913
+ if (suffix === void 0) throw new Error(`session "${id}" not found`);
914
+ this.assertStoredId(id, suffix.meta);
915
+ this.assertVersion(suffix.meta);
916
+ if (suffix.events.some(needsLegacyPrefix)) {
917
+ const whole = await this.readStoredPrefix(id, signal);
918
+ return {
919
+ meta: whole.meta,
920
+ events: whole.events.filter((event) => event.seq >= fromSeq)
921
+ };
922
+ }
923
+ return {
924
+ meta: structuredClone(suffix.meta),
925
+ events: snapshotStoredEvents(suffix.events, id)
926
+ };
927
+ }
928
+ const whole = await this.readStoredPrefix(id, signal);
929
+ return {
930
+ meta: whole.meta,
931
+ events: whole.events.slice(fromSeq)
932
+ };
933
+ }
934
+ /** Read one detached physical prefix without logical recovery or caching. */
935
+ async readStoredPrefix(id, signal) {
936
+ signal?.throwIfAborted();
937
+ const stored = await this.backend.loadStored(id, signal);
938
+ signal?.throwIfAborted();
939
+ if (stored === void 0) throw new Error(`session "${id}" not found`);
940
+ this.assertStoredId(id, stored.meta);
941
+ this.assertVersion(stored.meta);
942
+ return {
943
+ meta: structuredClone(stored.meta),
944
+ events: snapshotStoredEvents(stored.events, id)
945
+ };
946
+ }
947
+ /** Read, repair in memory, validate, and freeze one cold source once. */
948
+ async prepareCore(id) {
949
+ const stored = await this.backend.loadStored(id);
950
+ if (stored === void 0) throw new Error(`session "${id}" not found`);
951
+ try {
952
+ const { meta, events, revision, tornMarker } = stored;
953
+ this.assertStoredId(id, meta);
954
+ this.assertVersion(meta);
955
+ const storedEvents = adoptStoredEvents(events, id);
956
+ const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent);
957
+ const balanced = [...storedEvents, ...closers];
958
+ const session = this.ctx.sessions.prepare(id, {
959
+ seed: balanced,
960
+ meta,
961
+ seedSource: "persistence"
962
+ });
963
+ return {
964
+ inspection: Object.freeze({
965
+ meta: session.header,
966
+ events: Object.freeze(balanced)
967
+ }),
968
+ session,
969
+ revision,
970
+ sessionLength: session.events.length,
971
+ tornMarker,
972
+ closers
973
+ };
974
+ } catch (error) {
975
+ throw new SessionPersistenceCorruptionError(`stored session "${id}" failed validation: ${String(error)}`, { cause: error });
976
+ }
977
+ }
978
+ /** Commit one prepared repair and establish its ownerless durable cursor. */
979
+ async commitPrepared(source) {
980
+ const id = source.inspection.meta.id;
981
+ const cursor = source.inspection.events.length;
982
+ const existing = this.states.get(id);
983
+ if (existing?.owner !== void 0) throw new Error(`session "${id}" already has a live persistence owner`);
984
+ if (!await this.isPreparedSourceCurrent(source)) return void 0;
985
+ if (source.tornMarker !== void 0 || source.closers.length > 0) {
986
+ await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers);
987
+ return;
988
+ }
989
+ const state = existing ?? {
990
+ meta: source.inspection.meta,
991
+ cursor,
992
+ materialized: true
993
+ };
994
+ state.meta = source.inspection.meta;
995
+ state.cursor = cursor;
996
+ state.materialized = true;
997
+ this.states.set(id, state);
998
+ return {
999
+ source,
1000
+ state
1001
+ };
1002
+ }
1003
+ /** Whether one cached source still names the current durable log revision. */
1004
+ async isPreparedSourceCurrent(source, signal) {
1005
+ return await this.backend.readStoredRevision(source.inspection.meta.id, signal) === source.revision;
1006
+ }
1007
+ /** Return one durable immutable view of an already-live Session. */
1008
+ async loadLiveSnapshot(session) {
1009
+ const events = session.events;
1010
+ await this.flush(session);
1011
+ const state = this.states.get(session.id);
1012
+ /* v8 ignore next -- successful flush always publishes this live session's durable state */
1013
+ if (state === void 0) throw new Error(`session "${session.id}" lost persistence state during load`);
1014
+ if (events.length === 0) throw new Error(`session "${session.id}" not found`);
1015
+ 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`);
1016
+ return Object.freeze({
1017
+ meta: state.meta,
1018
+ events
1019
+ });
1020
+ }
1021
+ /** Borrow one immutable view from an already-live Session. */
1022
+ inspectLive(session) {
1023
+ return Object.freeze({
1024
+ meta: session.header,
1025
+ events: session.events
1026
+ });
1027
+ }
1028
+ /** Await one retiring lifecycle with caller cancellation. */
1029
+ waitForRetirement(id, signal) {
1030
+ const retired = Promise.resolve(this.retirements.get(id));
1031
+ return signal === void 0 ? retired : observeQueuedAbort(retired, signal, () => false);
1032
+ }
1033
+ /**
1034
+ * Run `op` after any in-flight operation for the same session id, so writes for
1035
+ * one session never interleave. Errors do not poison the chain. NOTE: serialized
1036
+ * public methods must NOT call each other (deadlock); they call the unserialized
1037
+ * `*Core` helpers instead.
1038
+ */
1039
+ serialize(id, op, signal) {
1040
+ const prior = this.chains.get(id) ?? Promise.resolve();
1041
+ let started = false;
1042
+ const run = () => {
1043
+ signal?.throwIfAborted();
1044
+ started = true;
1045
+ return op();
1046
+ };
1047
+ const next = prior.then(run, run);
1048
+ const tail = next.then(() => void 0, () => void 0);
1049
+ this.chains.set(id, tail);
1050
+ tail.then(() => {
1051
+ if (this.chains.get(id) === tail) this.chains.delete(id);
1052
+ });
1053
+ return signal === void 0 ? next : observeQueuedAbort(next, signal, () => started);
1054
+ }
1055
+ /** Build a state for a session discovered in storage but not yet in memory. */
1056
+ async adopt(id) {
1057
+ for (;;) {
1058
+ const source = this.preparations.takeReady(id) ?? await this.prepareCore(id);
1059
+ const committed = await this.commitPrepared(source);
1060
+ if (committed !== void 0) return committed.state;
1061
+ }
1062
+ }
1063
+ assertVersion(meta) {
1064
+ if (meta.version !== SESSION_FORMAT_VERSION) throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`);
1065
+ }
1066
+ /** Reject backend metadata that is not bound to the requested session id. */
1067
+ assertStoredId(id, meta) {
1068
+ if (meta.id !== id) throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`);
1069
+ }
1070
+ installWritePath() {
1071
+ const ctx = this.ctx;
1072
+ ctx.effect(() => async () => {
1073
+ let disposeError;
1074
+ try {
1075
+ const errors = await settledErrors([...this.live.keys()].map((session) => this.flush(session)));
1076
+ while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]);
1077
+ if (errors.length > 0) throw new AggregateError(errors, `${this.backend.name} dispose failed`);
1078
+ } catch (error) {
1079
+ disposeError = error;
1080
+ throw error;
1081
+ } finally {
1082
+ try {
1083
+ await this.backend.close?.();
1084
+ } catch (closeError) {
1085
+ /* v8 ignore start -- close failure racing disposal is a defensive teardown edge */
1086
+ if (disposeError === void 0) throw closeError;
1087
+ }
1088
+ }
1089
+ }, `${this.backend.name} write path`);
1090
+ ctx.on("session/created", (session) => {
1091
+ this.initFor(session);
1092
+ });
1093
+ ctx.on("session/event", (session, event) => {
1094
+ this.initFor(session).writes.enqueue(event);
1095
+ });
1096
+ ctx.on("session/flush", (session) => this.flush(session));
1097
+ ctx.on("session/disposed", (session) => {
1098
+ this.retire(session);
1099
+ });
1100
+ for (const session of ctx.sessions.list()) this.initFor(session);
1101
+ }
1102
+ /** Start and observe one disposed session's final drain. */
1103
+ retire(session) {
1104
+ if (!this.live.has(session)) return;
1105
+ const retirement = this.retireCore(session);
1106
+ this.retirements.set(session.id, retirement);
1107
+ const forget = () => {
1108
+ if (this.retirements.get(session.id) === retirement) this.retirements.delete(session.id);
1109
+ };
1110
+ retirement.then(forget, forget);
1111
+ retirement.catch((error) => {
1112
+ this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`);
1113
+ });
1114
+ }
1115
+ /** Drain and release state owned by one exact disposed Session lifecycle. */
1116
+ async retireCore(session) {
1117
+ await this.flush(session);
1118
+ const id = session.header.id;
1119
+ await this.serialize(id, () => {
1120
+ this.live.delete(session);
1121
+ if (this.states.get(id)?.owner === session) this.states.delete(id);
1122
+ });
1123
+ }
1124
+ /** Return the one lifecycle controller for a live session, creating it if needed. */
1125
+ initFor(session) {
1126
+ const existing = this.live.get(session);
1127
+ if (existing) return existing;
1128
+ const reservation = this.preparations.reservationFor(session);
1129
+ if (reservation !== void 0) {
1130
+ const restored = this.attachPrepared(session, reservation);
1131
+ this.live.set(session, restored);
1132
+ return restored;
1133
+ }
1134
+ const seed = session.events.map((e) => structuredClone(e));
1135
+ const live = {
1136
+ init: Promise.resolve(),
1137
+ writes: this.createWriteBehind(session, () => live.init)
1138
+ };
1139
+ this.live.set(session, live);
1140
+ live.init = this.serialize(session.header.id, () => this.onCreated(session, seed));
1141
+ live.init.catch(() => {});
1142
+ return live;
1143
+ }
1144
+ /** Bind one exact prepared Session and persist only its unpublished suffix. */
1145
+ attachPrepared(session, reservation) {
1146
+ const { source, state } = reservation;
1147
+ 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`);
1148
+ const suffix = session.events.slice(state.cursor).map((event) => structuredClone(event));
1149
+ this.preparations.attach(reservation);
1150
+ state.owner = session;
1151
+ const live = {
1152
+ init: Promise.resolve(),
1153
+ writes: this.createWriteBehind(session, () => live.init)
1154
+ };
1155
+ if (suffix.length > 0) {
1156
+ live.init = this.serialize(session.id, () => this.appendCore(session.id, suffix));
1157
+ live.init.catch(() => {});
1158
+ }
1159
+ return live;
1160
+ }
1161
+ /**
1162
+ * Whether a live session's `seed` reproduces the first `cursor` persisted
1163
+ * events. A `cursor` of 0 (nothing persisted yet) trivially matches. Used when
1164
+ * a live session claims ownerless state left by a prior `load()`/`create()`.
1165
+ */
1166
+ async seedMatchesPersisted(id, seed, cursor) {
1167
+ if (cursor === 0) return true;
1168
+ const stored = await this.backend.loadStored(id);
1169
+ /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
1170
+ if (stored === void 0) return false;
1171
+ this.assertStoredId(id, stored.meta);
1172
+ return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor));
1173
+ }
1174
+ /**
1175
+ * On session/created: sync the backend's in-memory state to a live Session.
1176
+ *
1177
+ * Cases, by whether this backend tracks the id and whether an artifact exists:
1178
+ * 1. Already tracked → no-op (or claim ownerless state if the seed matches,
1179
+ * or reclaim a truly-abandoned id, else reject as a collision).
1180
+ * 2. Not tracked, an artifact EXISTS at the same cwd and is a seq-aligned
1181
+ * PREFIX of the live events → ADOPT it, persisting any live suffix.
1182
+ * 3. Not tracked, an artifact EXISTS at another cwd or is NOT a prefix →
1183
+ * REJECT (collision).
1184
+ * 4. Not tracked and NO artifact → a genuinely new session: register meta
1185
+ * (lazy) and persist its seed once.
1186
+ */
1187
+ async onCreated(session, seed) {
1188
+ const id = session.header.id;
1189
+ const tracked = this.states.get(id);
1190
+ if (tracked !== void 0) {
1191
+ /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
1192
+ if (tracked.owner === session) return;
1193
+ if (tracked.owner === void 0) {
1194
+ if (tracked.meta.cwd !== session.header.cwd) throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`);
1195
+ 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)`);
1196
+ tracked.owner = session;
1197
+ const suffix = seed.slice(tracked.cursor);
1198
+ if (suffix.length > 0) await this.appendCore(id, suffix);
1199
+ return;
1200
+ }
1201
+ const owner = this.live.get(tracked.owner);
1202
+ if (!tracked.materialized && !owner?.writes.hasWork) this.states.delete(id);
1203
+ else throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`);
1204
+ }
1205
+ const live = await this.backend.loadStored(id);
1206
+ if (live !== void 0) {
1207
+ await this.adoptLivePrefix(session, seed, live);
1208
+ return;
1209
+ }
1210
+ const meta = { ...session.header };
1211
+ await this.createCore(meta);
1212
+ const created = this.states.get(id);
1213
+ /* v8 ignore next -- create() always sets the state for the id */
1214
+ if (created !== void 0) created.owner = session;
1215
+ if (seed.length > 0) await this.appendCore(id, seed);
1216
+ }
1217
+ /**
1218
+ * Adopt a stored prefix as a live session's history (HMR/reload): verify the
1219
+ * seed covers the stored prefix, truncate any torn tail (NOT the open turn —
1220
+ * the live Session is still the authority), bind ownership, and persist the
1221
+ * live suffix that was ahead of the stored prefix.
1222
+ */
1223
+ async adoptLivePrefix(session, seed, stored) {
1224
+ const { meta, events, tornMarker } = stored;
1225
+ this.assertStoredId(session.header.id, meta);
1226
+ 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)`);
1227
+ this.assertVersion(meta);
1228
+ const storedEvents = snapshotStoredEvents(events, session.header.id);
1229
+ 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)`);
1230
+ if (tornMarker !== void 0) await this.backend.commitRepair(meta, tornMarker, []);
1231
+ this.states.set(session.header.id, {
1232
+ meta: { ...meta },
1233
+ cursor: storedEvents.length,
1234
+ materialized: true,
1235
+ owner: session
1236
+ });
1237
+ const suffix = seed.slice(storedEvents.length);
1238
+ if (suffix.length > 0) await this.appendCore(session.header.id, suffix);
1239
+ }
1240
+ async flush(session) {
1241
+ const live = this.initFor(session);
1242
+ live.writes.cancelAutomaticWait();
1243
+ try {
1244
+ await live.init;
1245
+ } catch (error) {
1246
+ live.writes.cancelAutomaticWait();
1247
+ throw error;
1248
+ }
1249
+ await live.writes.flush();
1250
+ }
1251
+ /** Build one package-private write controller around initialization and id serialization. */
1252
+ createWriteBehind(session, ready) {
1253
+ return new SessionWriteBehind({
1254
+ maxDelayMs: this.writeBatchMaxDelayMs,
1255
+ write: async (batch) => {
1256
+ await ready();
1257
+ await this.serialize(session.header.id, () => this.appendLiveBatch(session.header.id, batch));
1258
+ },
1259
+ reportBackgroundFailure: (error) => {
1260
+ this.ctx.logger.warn(`${this.backend.name}: background write for session "${session.id}" failed (buffered events retained): ${String(error)}`);
1261
+ }
1262
+ });
1263
+ }
1264
+ /** Append one controller-owned prefix after filtering events initialization already stored. */
1265
+ async appendLiveBatch(id, batch) {
1266
+ /* v8 ignore next -- state is always set by the awaited initialization */
1267
+ const cursor = this.states.get(id)?.cursor ?? 0;
1268
+ const fresh = batch.filter((e) => e.seq >= cursor);
1269
+ await this.appendCore(id, fresh);
1270
+ }
1271
+ };
1272
+ //#endregion
1273
+ //#region lib/types/index.js
1274
+ /**
1275
+ * Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
1276
+ * {@link SessionEvent}s as the event-sourced log and carry non-replayable
1277
+ * {@link SessionHeader} metadata separately.
1278
+ * @module @deepseek-ai/dsh-session-persistence
1279
+ */
1280
+ /**
1281
+ * Durable append-only session storage. Implementations preserve contiguous,
1282
+ * losslessly JSON-serializable events; {@link append} resolves only after
1283
+ * durability, and {@link load} balances a complete interrupted tail without
1284
+ * rewriting committed events.
1285
+ */
1286
+ var SessionPersistence = class extends Service {
1287
+ constructor(ctx) {
1288
+ super(ctx, "sessionPersistence");
1289
+ }
1290
+ /**
1291
+ * Prepare the exact unpublished Session used by resume. Implementations may
1292
+ * reuse object graphs retained by an earlier {@link inspect} after confirming
1293
+ * their durable revision is still current; disposal releases an unpublished
1294
+ * reservation. Revision retries require the durable log to remain unchanged
1295
+ * for one read/check round trip; continuous external writers may delay completion.
1296
+ * @param id - persisted session to prepare.
1297
+ * @param signal - optional cancellation for preparation work.
1298
+ * @returns one owned unpublished Session preparation.
1299
+ */
1300
+ async prepare(id, signal) {
1301
+ signal?.throwIfAborted();
1302
+ const loaded = await this.load(id);
1303
+ signal?.throwIfAborted();
1304
+ const sessions = this.ctx.get("sessions");
1305
+ if (sessions === void 0) throw new Error("cannot prepare a session: SessionStore is not configured");
1306
+ return SessionPreparation.create(sessions.prepare(id, {
1307
+ seed: loaded.events.map((event) => structuredClone(event)),
1308
+ meta: structuredClone(loaded.meta),
1309
+ seedSource: "persistence"
1310
+ }));
1311
+ }
1312
+ };
1313
+ //#endregion
1314
+ export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, SessionPersistence, SessionPersistence as default, SessionPersistenceCorruptionError, SessionPersistenceRevision };