@deepseek-ai/dsh-session-persistence-jsonl 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,1389 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { readdirSync } from "node:fs";
3
+ import { link, mkdir, mkdtemp, open, readFile, readdir, realpath, rm, stat, truncate } from "node:fs/promises";
4
+ import { dirname, join, parse, resolve, toNamespacedPath } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { scheduler } from "node:timers/promises";
7
+ import { randomBytes } from "node:crypto";
8
+ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, SessionPersistence, SessionPersistenceRevision } from "@deepseek-ai/dsh-session-persistence";
9
+ import { decodeStorageRecord, packChunkRuns } from "@deepseek-ai/dsh-session";
10
+ import { constants, createZstdDecompress, zstdCompress, zstdDecompress, zstdDecompressSync } from "node:zlib";
11
+ import { promisify } from "node:util";
12
+ import { constants as constants$1 } from "node:buffer";
13
+ //#region lib/types/format.js
14
+ /**
15
+ * On-disk format helpers for the JSONL session-persistence backend: path
16
+ * sanitization (a {@link SessionId} is an unvalidated branded string, so it
17
+ * MUST be encoded before use in a path — no traversal, no collision), the
18
+ * per-project/session directory layout, header-line (de)serialization, and the
19
+ * truncation-repair offset computation.
20
+ *
21
+ * @module dsh-session-persistence-jsonl/format
22
+ */
23
+ /**
24
+ * Return the artifact suffix for one physical encoding.
25
+ * @param compression - configured JSONL artifact encoding.
26
+ * @returns `.jsonl.zstd` for Zstandard or `.jsonl` for plaintext.
27
+ */
28
+ function logSuffix(compression) {
29
+ return compression === "zstd" ? ".jsonl.zstd" : ".jsonl";
30
+ }
31
+ /**
32
+ * Build the header line object from a {@link SessionHeader}.
33
+ * @param header - the immutable session metadata to serialize.
34
+ * @returns the `type: 'session'`-tagged line object, absent optional fields omitted (never null).
35
+ */
36
+ function toHeaderLine(header) {
37
+ return {
38
+ type: "session",
39
+ version: header.version,
40
+ id: header.id,
41
+ createdAt: header.createdAt,
42
+ ...header.cwd !== void 0 ? { cwd: header.cwd } : {},
43
+ ...header.parentSession !== void 0 ? { parentSession: header.parentSession } : {},
44
+ ...header.seedLength !== void 0 ? { seedLength: header.seedLength } : {},
45
+ ...header.origin !== void 0 ? { origin: header.origin } : {},
46
+ delegationDepth: header.delegationDepth ?? 0,
47
+ ...header.agentPreset !== void 0 ? { agentPreset: header.agentPreset } : {}
48
+ };
49
+ }
50
+ /**
51
+ * Parse a header line back into a {@link SessionHeader}.
52
+ * @param line - the shape-checked first line of a log (see the `isHeaderLine` guard).
53
+ * @returns the header, absent optional fields omitted.
54
+ */
55
+ function fromHeaderLine(line) {
56
+ if (Object.hasOwn(line, "sandboxMode") || Object.hasOwn(line, "approvalPolicy")) throw new Error("session header uses retired policy baseline fields");
57
+ return {
58
+ version: line.version,
59
+ id: line.id,
60
+ createdAt: line.createdAt,
61
+ ...line.cwd !== void 0 ? { cwd: line.cwd } : {},
62
+ ...line.parentSession !== void 0 ? { parentSession: line.parentSession } : {},
63
+ ...line.seedLength !== void 0 ? { seedLength: line.seedLength } : {},
64
+ ...line.origin !== void 0 ? { origin: line.origin } : {},
65
+ delegationDepth: line.delegationDepth,
66
+ ...line.agentPreset !== void 0 ? { agentPreset: line.agentPreset } : {}
67
+ };
68
+ }
69
+ /** Type guard: a parsed first line is a well-formed session header. */
70
+ function isHeaderLine(value) {
71
+ return typeof value === "object" && value !== null && value.type === "session" && typeof value.version === "number" && typeof value.id === "string" && typeof value.createdAt === "number" && Number.isSafeInteger(value.createdAt) && value.createdAt >= 0 && !Object.is(value.createdAt, -0) && typeof value.delegationDepth === "number" && Number.isSafeInteger(value.delegationDepth) && value.delegationDepth >= 0 && !Object.is(value.delegationDepth, -0) && (value.origin === void 0 || value.origin === "subagent") && (value.agentPreset === void 0 || typeof value.agentPreset === "string");
72
+ }
73
+ /**
74
+ * Encode an arbitrary string as a single safe path segment, injectively over ALL JS (UTF-16)
75
+ * strings — including lone surrogates. A {@link SessionId} is an unvalidated branded string,
76
+ * so this neutralizes `../`, absolute paths, NUL, and separators before any filesystem use.
77
+ * Safe code units remain literal; every other unit, including `~`, becomes
78
+ * `~XXXX`. Operating on code units preserves lone surrogates, while special-
79
+ * casing `.` and `..` prevents traversal by an otherwise safe whole segment.
80
+ *
81
+ * @param raw - the string to encode; must be non-empty (throws on `''`).
82
+ * @returns the escaped single path segment, decodable back to `raw`.
83
+ */
84
+ function encodeSegment(raw) {
85
+ if (raw.length === 0) throw new Error("cannot encode an empty path segment");
86
+ if (raw === ".") return "~002E";
87
+ if (raw === "..") return "~002E~002E";
88
+ let out = "";
89
+ for (let i = 0; i < raw.length; i++) {
90
+ const code = raw.charCodeAt(i);
91
+ const ch = String.fromCharCode(code);
92
+ if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
93
+ else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
94
+ }
95
+ return out;
96
+ }
97
+ /**
98
+ * Build the readable directory key for a project path.
99
+ * Filesystem separators and drive separators become `-`; unsafe code units use
100
+ * the same `~XXXX` escape as session ids. The key is bounded for filesystem
101
+ * component limits. Separator replacement and truncation are intentionally
102
+ * lossy, following the common human-navigable project-directory convention.
103
+ * @param cwd - the session's project directory.
104
+ * @returns a single filesystem-safe project directory name.
105
+ */
106
+ function projectKey(cwd) {
107
+ if (cwd.length === 0) throw new Error("cannot encode an empty project path");
108
+ let readable = "";
109
+ let separatorRun = false;
110
+ for (let i = 0; i < cwd.length; i++) {
111
+ const code = cwd.charCodeAt(i);
112
+ const ch = String.fromCharCode(code);
113
+ if (ch === "/" || ch === "\\" || ch === ":") {
114
+ if (!separatorRun) readable += "-";
115
+ separatorRun = true;
116
+ } else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
117
+ readable += ch;
118
+ separatorRun = false;
119
+ } else {
120
+ readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
121
+ separatorRun = false;
122
+ }
123
+ }
124
+ return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
125
+ }
126
+ /**
127
+ * The configured root's human-navigable project directory. A configured root
128
+ * may be local or shared; this grouping does not prescribe its deployment.
129
+ * @param root - the backend's session root directory.
130
+ * @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
131
+ * @returns the project directory path under `root`.
132
+ */
133
+ function projectDir(root, cwd) {
134
+ if (cwd === void 0) return join(root, "_no-cwd");
135
+ return join(root, projectKey(cwd));
136
+ }
137
+ /**
138
+ * The directory owned by one session and available for future session-local
139
+ * artifacts.
140
+ * @param root - the backend's session root directory.
141
+ * @param cwd - the session's project directory.
142
+ * @param id - the session id, encoded to one safe path segment.
143
+ * @returns the session directory beneath its project directory.
144
+ */
145
+ function sessionDir(root, cwd, id) {
146
+ return join(projectDir(root, cwd), encodeSegment(id));
147
+ }
148
+ /**
149
+ * The append-only event-log file path for a session.
150
+ * @param root - the backend's session root directory.
151
+ * @param cwd - the session's project directory (`undefined` → `_no-cwd`).
152
+ * @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
153
+ * @param compression - physical artifact encoding and filename suffix.
154
+ * @returns the session's configured JSONL artifact path.
155
+ */
156
+ function logPath(root, cwd, id, compression) {
157
+ return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`);
158
+ }
159
+ /**
160
+ * Serialize an event batch as JSONL lines (no trailing newline). With
161
+ * `packChunks` on, delta-chunk runs pack into `text-chunks` /
162
+ * `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
163
+ * per line, byte-identical to the pre-packing layout. Reading is layout-blind
164
+ * either way ({@link scanLog} always decodes rows), so the switch changes only
165
+ * newly written bytes.
166
+ * @param events - the batch to serialize, in log order.
167
+ * @param packChunks - whether to pack delta runs into storage rows.
168
+ * @returns the batch's JSONL text; the writer adds the final newline.
169
+ */
170
+ function eventLines(events, packChunks) {
171
+ return (packChunks ? packChunkRuns(events) : events).map((record) => JSON.stringify(record)).join("\n");
172
+ }
173
+ /** Parse one complete header record supplied independently from event rows. */
174
+ function parseHeaderRecord(record) {
175
+ if (record.length === 0 || record.at(-1) !== 10 || record.indexOf(10) !== record.length - 1) throw new Error("empty or header-less session log");
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(record.subarray(0, -1).toString("utf8"));
179
+ } catch {
180
+ throw new Error("corrupt session log: header line is not valid JSON");
181
+ }
182
+ if (!isHeaderLine(parsed)) throw new Error("corrupt session log: first line is not a session header");
183
+ return fromHeaderLine(parsed);
184
+ }
185
+ /**
186
+ * Incrementally scan complete JSONL event records after an independently
187
+ * supplied header record. Newline search and byte offsets stay on raw buffers;
188
+ * only complete records are decoded to UTF-8. A fragment crossing writes is
189
+ * copied because a decoder may reuse its output buffer after `write()` returns.
190
+ */
191
+ var SessionLogScanner = class {
192
+ meta;
193
+ events = [];
194
+ fragments = [];
195
+ fragmentBytes = 0;
196
+ inputBytes;
197
+ committedBytes;
198
+ eventLine = 0;
199
+ issue;
200
+ finished = false;
201
+ /**
202
+ * Create an event scanner from exactly one newline-terminated header record.
203
+ * @param headerRecord - the complete first JSONL record, including its newline.
204
+ */
205
+ constructor(headerRecord) {
206
+ this.meta = parseHeaderRecord(headerRecord);
207
+ this.inputBytes = headerRecord.length;
208
+ this.committedBytes = headerRecord.length;
209
+ }
210
+ /**
211
+ * Consume the next raw plaintext chunk, retaining only an incomplete final record.
212
+ * @param chunk - bytes immediately following all previously supplied bytes.
213
+ */
214
+ write(chunk) {
215
+ if (this.finished) throw new Error("cannot write to a finished session log scanner");
216
+ const chunkStart = this.inputBytes;
217
+ this.inputBytes += chunk.length;
218
+ let lineStart = 0;
219
+ for (let newline = chunk.indexOf(10); newline !== -1; newline = chunk.indexOf(10, lineStart)) {
220
+ const fragment = chunk.subarray(lineStart, newline);
221
+ let line = fragment;
222
+ if (this.fragments.length > 0) {
223
+ if (fragment.length > 0) this.fragments.push(fragment);
224
+ line = Buffer.concat(this.fragments, this.fragmentBytes + fragment.length);
225
+ this.fragments = [];
226
+ this.fragmentBytes = 0;
227
+ }
228
+ this.consumeEventLine(line, chunkStart + newline + 1);
229
+ lineStart = newline + 1;
230
+ }
231
+ if (lineStart < chunk.length) {
232
+ const fragment = Buffer.from(chunk.subarray(lineStart));
233
+ this.fragments.push(fragment);
234
+ this.fragmentBytes += fragment.length;
235
+ }
236
+ }
237
+ /**
238
+ * Snapshot progress before appending a recoverable torn-frame prefix.
239
+ * @returns byte, committed-prefix, and expanded-event cursors.
240
+ */
241
+ checkpoint() {
242
+ return {
243
+ inputBytes: this.inputBytes,
244
+ committedBytes: this.committedBytes,
245
+ eventCount: this.events.length
246
+ };
247
+ }
248
+ /**
249
+ * Finish scanning, ignoring a final record without a newline as a torn tail.
250
+ * @returns the header, contiguous event prefix, and safe truncation offset.
251
+ */
252
+ finish() {
253
+ this.finished = true;
254
+ return {
255
+ meta: this.meta,
256
+ events: this.events,
257
+ committedBytes: this.committedBytes
258
+ };
259
+ }
260
+ /** Decode one complete event row and update the contiguous prefix. */
261
+ consumeEventLine(line, endByte) {
262
+ this.eventLine += 1;
263
+ let decoded;
264
+ try {
265
+ decoded = decodeStorageRecord(JSON.parse(line.toString("utf8")));
266
+ } catch {
267
+ this.issue ??= /* @__PURE__ */ new Error(`corrupt session log: unparsable committed event at line ${this.eventLine}`);
268
+ return;
269
+ }
270
+ if (this.issue !== void 0) {
271
+ if (decoded.some((event) => event.type === "turn/end")) throw this.issue;
272
+ return;
273
+ }
274
+ const rowStart = this.events.length;
275
+ for (const event of decoded) {
276
+ if (event.seq !== this.events.length) {
277
+ const expected = this.events.length;
278
+ this.events.length = rowStart;
279
+ this.issue = /* @__PURE__ */ new Error(`corrupt session log: seq gap in committed region at line ${this.eventLine} (expected ${expected}, got ${event.seq})`);
280
+ if (decoded.some((candidate) => candidate.type === "turn/end")) throw this.issue;
281
+ return;
282
+ }
283
+ this.events.push(event);
284
+ }
285
+ this.committedBytes = endByte;
286
+ }
287
+ };
288
+ /**
289
+ * Parse a complete or torn JSONL buffer into its preserved event prefix. This
290
+ * compatibility wrapper supplies the first record separately, then delegates
291
+ * event rows to {@link SessionLogScanner}.
292
+ *
293
+ * @param buffer - the raw bytes of the log file (header line first).
294
+ * @returns the header, preserved event prefix, and byte offset safe to append at.
295
+ */
296
+ function scanLog(buffer) {
297
+ const headerEnd = buffer.indexOf(10);
298
+ if (headerEnd === -1) throw new Error("empty or header-less session log");
299
+ const scanner = new SessionLogScanner(buffer.subarray(0, headerEnd + 1));
300
+ scanner.write(buffer.subarray(headerEnd + 1));
301
+ return scanner.finish();
302
+ }
303
+ /**
304
+ * Parse just the header line of a log into a {@link SessionHeader}, or
305
+ * `undefined` if it is missing/not a header. Used by `list()` to read session
306
+ * metadata WITHOUT parsing the whole log: a session picker scales with the
307
+ * number of sessions, not the total size of every conversation.
308
+ * @param firstLine - the first line of a log file (without its trailing newline).
309
+ * @returns the parsed header, or `undefined` when the line is not a well-formed session header.
310
+ */
311
+ function parseHeaderMeta(firstLine) {
312
+ let parsed;
313
+ try {
314
+ parsed = JSON.parse(firstLine);
315
+ } catch {
316
+ return;
317
+ }
318
+ if (!isHeaderLine(parsed)) return void 0;
319
+ return fromHeaderLine(parsed);
320
+ }
321
+ //#endregion
322
+ //#region lib/types/zstd-private-decoder.js
323
+ /**
324
+ * Node-private synchronous Zstandard frame decoder optimization.
325
+ * @module dsh-session-persistence-jsonl/zstd-private-decoder
326
+ */
327
+ const DECODE_CHUNK_SIZE = 1024 * 1024;
328
+ /** Return the stream with its observed private Node contract, or reject that optimization. */
329
+ function privateZstdStream(stream) {
330
+ const candidate = stream;
331
+ const handle = candidate._handle;
332
+ const errorKey = Reflect.ownKeys(stream).find((key) => typeof key === "symbol" && key.description === "kError");
333
+ /* v8 ignore next -- one test runtime exposes one Node-private shape; the Node 22/24/26 matrix checks compatibility. */
334
+ if (typeof handle !== "object" || handle === null || typeof handle.writeSync !== "function" || !(candidate._writeState instanceof Uint32Array) || candidate._writeState.length < 2 || typeof candidate._defaultFlushFlag !== "number" || errorKey === void 0 || candidate[errorKey] !== null) return void 0;
335
+ return {
336
+ stream,
337
+ errorKey
338
+ };
339
+ }
340
+ /**
341
+ * Synchronous multi-frame decoder backed by one Node Zstd stream handle. Node
342
+ * exposes synchronous decoding only as a one-shot API, so this adapter uses
343
+ * the stream's private handle contract to reuse its native context and output
344
+ * chunks across frames.
345
+ */
346
+ var NodePrivateZstdFrameDecoder = class NodePrivateZstdFrameDecoder {
347
+ stream;
348
+ errorKey;
349
+ output = Buffer.allocUnsafe(DECODE_CHUNK_SIZE);
350
+ decoderError;
351
+ started = false;
352
+ closed = false;
353
+ constructor(stream, errorKey) {
354
+ this.stream = stream;
355
+ this.errorKey = errorKey;
356
+ this.stream.on("error", (error) => {
357
+ this.decoderError ??= error;
358
+ });
359
+ }
360
+ /**
361
+ * Create the optimized decoder when this Node release exposes the expected
362
+ * private stream shape.
363
+ * @returns a shared decoder, or `undefined` when callers must use the public fallback.
364
+ */
365
+ static create() {
366
+ const stream = createZstdDecompress({ chunkSize: DECODE_CHUNK_SIZE });
367
+ const privateAccess = privateZstdStream(stream);
368
+ /* v8 ignore next -- reached only when a supported Node release changes its private stream shape. */
369
+ if (privateAccess !== void 0) return new NodePrivateZstdFrameDecoder(privateAccess.stream, privateAccess.errorKey);
370
+ /* v8 ignore next -- the active Node runtime passed the private-shape probe above. */
371
+ stream.close();
372
+ }
373
+ /** @inheritdoc */
374
+ *decode(source, frames) {
375
+ if (this.started) throw new Error("Zstandard frame decoder was already started");
376
+ if (this.closed) throw new Error("cannot start a closed Zstandard frame decoder");
377
+ this.started = true;
378
+ try {
379
+ for (const frame of frames) try {
380
+ yield this.decodeFrame(source.subarray(frame.start, frame.end));
381
+ } catch (error) {
382
+ throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error });
383
+ }
384
+ } finally {
385
+ this.close();
386
+ }
387
+ }
388
+ /** Decode one frame; its returned scratch view remains valid until the next call. */
389
+ decodeFrame(input) {
390
+ const handle = this.stream._handle;
391
+ /* v8 ignore next -- decode() rejects closed instances before entering this private frame operation. */
392
+ if (this.closed || handle === null) throw new Error("cannot decode with a closed Zstandard frame decoder");
393
+ let inputOffset = 0;
394
+ let inputRemaining = input.length;
395
+ let outputBytes = 0;
396
+ const fullChunks = [];
397
+ for (;;) {
398
+ handle.writeSync(this.stream._defaultFlushFlag, input, inputOffset, inputRemaining, this.output, 0, this.output.length);
399
+ if (this.decoderError !== void 0) throw this.decoderError;
400
+ const internalError = this.stream[this.errorKey];
401
+ if (internalError !== null) {
402
+ if (internalError instanceof Error) throw internalError;
403
+ throw new Error("Zstandard decoder exposed a non-Error internal failure");
404
+ }
405
+ const outputAfter = this.stream._writeState[0];
406
+ const inputAfter = this.stream._writeState[1];
407
+ const consumed = inputRemaining - inputAfter;
408
+ const produced = this.output.length - outputAfter;
409
+ if (produced > 0) {
410
+ outputBytes += produced;
411
+ /* v8 ignore next -- Buffer cannot materialize a frame beyond its own process-wide maximum length. */
412
+ if (outputBytes > constants$1.MAX_LENGTH) throw new Error(`Zstandard frame output exceeds ${constants$1.MAX_LENGTH} bytes`);
413
+ }
414
+ if (outputAfter !== 0) {
415
+ /* v8 ignore next -- structurally scanned ranges contain exactly one complete frame and no trailing bytes. */
416
+ if (inputAfter !== 0) throw new Error("Zstandard frame decoder left trailing input");
417
+ const finalChunk = this.output.subarray(0, produced);
418
+ if (fullChunks.length === 0) return finalChunk;
419
+ if (produced > 0) fullChunks.push(Buffer.from(finalChunk));
420
+ const onlyChunk = fullChunks[0];
421
+ return fullChunks.length === 1 ? onlyChunk : Buffer.concat(fullChunks, outputBytes);
422
+ }
423
+ fullChunks.push(Buffer.from(this.output));
424
+ inputOffset += consumed;
425
+ inputRemaining = inputAfter;
426
+ }
427
+ }
428
+ /** @inheritdoc */
429
+ close() {
430
+ if (this.closed) return;
431
+ this.closed = true;
432
+ this.stream.close();
433
+ }
434
+ };
435
+ //#endregion
436
+ //#region lib/types/zstd-public-decoder.js
437
+ /**
438
+ * Public-API synchronous Zstandard frame decoder fallback.
439
+ * @module dsh-session-persistence-jsonl/zstd-public-decoder
440
+ */
441
+ /** Multi-frame adapter built exclusively from Node's supported one-shot API. */
442
+ var PublicZstdFrameDecoder = class {
443
+ started = false;
444
+ closed = false;
445
+ /** @inheritdoc */
446
+ *decode(source, frames) {
447
+ if (this.started) throw new Error("Zstandard frame decoder was already started");
448
+ if (this.closed) throw new Error("cannot start a closed Zstandard frame decoder");
449
+ this.started = true;
450
+ try {
451
+ for (const { start, end } of frames) {
452
+ let decoded;
453
+ try {
454
+ decoded = zstdDecompressSync(source.subarray(start, end));
455
+ } catch (error) {
456
+ throw new Error(`corrupt Zstandard session log: frame at byte ${start} failed validation`, { cause: error });
457
+ }
458
+ yield decoded;
459
+ }
460
+ } finally {
461
+ this.close();
462
+ }
463
+ }
464
+ /** @inheritdoc */
465
+ close() {
466
+ this.closed = true;
467
+ }
468
+ };
469
+ //#endregion
470
+ //#region lib/types/zstd.js
471
+ /**
472
+ * Zstandard frame primitives for the JSONL persistence backend. The backend
473
+ * owns a concatenated-frame container so it can append and recover batches
474
+ * without exposing compression mechanics through the persistence seam.
475
+ * @module dsh-session-persistence-jsonl/zstd
476
+ */
477
+ const ZSTD_MAGIC = 4247762216;
478
+ const zstdCompressAsync = promisify(zstdCompress);
479
+ const zstdDecompressAsync = promisify(zstdDecompress);
480
+ const CHECKSUM_OPTIONS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
481
+ const INCOMPLETE_FRAME_OPTIONS = { finishFlush: constants.ZSTD_e_flush };
482
+ /**
483
+ * Locate complete frames without decompressing their blocks. Invalid complete
484
+ * structure rejects; EOF inside the final frame returns its start for repair.
485
+ * @param buffer - complete bytes currently present in the session artifact.
486
+ * @param maxFrames - optional complete-frame limit for metadata-only readers.
487
+ * @returns complete frame ranges and an optional incomplete-final-frame start.
488
+ */
489
+ function scanZstdFrames(buffer, maxFrames = Number.POSITIVE_INFINITY) {
490
+ const frames = [];
491
+ let offset = 0;
492
+ while (offset < buffer.length) {
493
+ const start = offset;
494
+ if (buffer.length - offset < 4) return {
495
+ frames,
496
+ tornStart: start
497
+ };
498
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`);
499
+ offset += 4;
500
+ if (offset === buffer.length) return {
501
+ frames,
502
+ tornStart: start
503
+ };
504
+ const descriptor = buffer.readUInt8(offset);
505
+ offset += 1;
506
+ if ((descriptor & 24) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`);
507
+ const contentSizeFlag = descriptor >>> 6;
508
+ const singleSegment = (descriptor & 32) !== 0;
509
+ const checksum = (descriptor & 4) !== 0;
510
+ const dictionaryFlag = descriptor & 3;
511
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
512
+ const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
513
+ const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
514
+ if (buffer.length - offset < remainingHeaderBytes) return {
515
+ frames,
516
+ tornStart: start
517
+ };
518
+ offset += remainingHeaderBytes;
519
+ for (;;) {
520
+ if (buffer.length - offset < 3) return {
521
+ frames,
522
+ tornStart: start
523
+ };
524
+ const blockHeader = buffer.readUIntLE(offset, 3);
525
+ offset += 3;
526
+ const lastBlock = (blockHeader & 1) !== 0;
527
+ const blockType = blockHeader >>> 1 & 3;
528
+ const blockSize = blockHeader >>> 3;
529
+ if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`);
530
+ const payloadBytes = blockType === 1 ? 1 : blockSize;
531
+ if (buffer.length - offset < payloadBytes) return {
532
+ frames,
533
+ tornStart: start
534
+ };
535
+ offset += payloadBytes;
536
+ if (lastBlock) break;
537
+ }
538
+ if (checksum) {
539
+ if (buffer.length - offset < 4) return {
540
+ frames,
541
+ tornStart: start
542
+ };
543
+ offset += 4;
544
+ }
545
+ frames.push({
546
+ start,
547
+ end: offset
548
+ });
549
+ if (frames.length === maxFrames) return { frames };
550
+ }
551
+ return { frames };
552
+ }
553
+ /**
554
+ * Compress one independently decodable, checksummed Zstandard frame.
555
+ * @param input - JSONL bytes for a header or durable event batch.
556
+ * @returns the complete encoded frame.
557
+ */
558
+ async function compressZstdFrame(input) {
559
+ return zstdCompressAsync(input, CHECKSUM_OPTIONS);
560
+ }
561
+ /**
562
+ * Decompress one complete frame and validate its checksum.
563
+ * @param input - one structurally complete Zstandard frame.
564
+ * @returns the frame plaintext.
565
+ */
566
+ async function decompressZstdFrame(input) {
567
+ return zstdDecompressAsync(input);
568
+ }
569
+ /**
570
+ * Select the shared private decoder when the running Node 22/24/26 shape is
571
+ * compatible, otherwise preserve correctness with the public one-shot API.
572
+ * @returns a synchronous decoder with an implementation-independent lifecycle.
573
+ */
574
+ function createZstdFrameDecoder() {
575
+ return NodePrivateZstdFrameDecoder.create() ?? new PublicZstdFrameDecoder();
576
+ }
577
+ /**
578
+ * Recover available plaintext from a structurally incomplete final frame.
579
+ * `ZSTD_e_flush` deliberately suppresses final-frame and checksum completion;
580
+ * callers must establish the torn frame boundary before using this helper.
581
+ * @param input - available bytes from a known incomplete Zstandard frame.
582
+ * @returns plaintext produced from the available input.
583
+ */
584
+ async function decompressZstdPrefix(input) {
585
+ return zstdDecompressAsync(input, INCOMPLETE_FRAME_OPTIONS);
586
+ }
587
+ //#endregion
588
+ //#region lib/types/win32.js
589
+ /**
590
+ * Windows durable namespace helpers for the JSONL backend.
591
+ *
592
+ * POSIX publishes a newly-created log by creating a directory entry and then
593
+ * fsyncing the parent directory. Windows does not expose that parent-directory
594
+ * fsync contract through Node, so the Windows path uses the native durable
595
+ * namespace primitive instead: create a staging object in the target directory
596
+ * and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
597
+ * replacement or cross-volume copy fallback.
598
+ *
599
+ * @module dsh-session-persistence-jsonl/win32
600
+ */
601
+ const MOVEFILE_WRITE_THROUGH = 8;
602
+ const ERROR_FILE_NOT_FOUND = 2;
603
+ const ERROR_PATH_NOT_FOUND = 3;
604
+ const ERROR_ACCESS_DENIED = 5;
605
+ const ERROR_NOT_SAME_DEVICE = 17;
606
+ const ERROR_FILE_EXISTS = 80;
607
+ const ERROR_INVALID_NAME = 123;
608
+ const ERROR_ALREADY_EXISTS = 183;
609
+ let bindings;
610
+ /** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */
611
+ async function win32() {
612
+ if (bindings !== void 0) return bindings;
613
+ const kernel32 = (await import("koffi")).default.load("kernel32.dll");
614
+ bindings = {
615
+ moveFileExW: kernel32.func("__stdcall", "MoveFileExW", "int", [
616
+ "str16",
617
+ "str16",
618
+ "uint"
619
+ ]),
620
+ getLastError: kernel32.func("__stdcall", "GetLastError", "uint", [])
621
+ };
622
+ return bindings;
623
+ }
624
+ function errnoCode(win32Code) {
625
+ switch (win32Code) {
626
+ case ERROR_FILE_NOT_FOUND:
627
+ case ERROR_PATH_NOT_FOUND: return "ENOENT";
628
+ case ERROR_ACCESS_DENIED: return "EACCES";
629
+ case ERROR_NOT_SAME_DEVICE: return "EXDEV";
630
+ case ERROR_FILE_EXISTS:
631
+ case ERROR_ALREADY_EXISTS: return "EEXIST";
632
+ case ERROR_INVALID_NAME: return "EINVAL";
633
+ default: return "EIO";
634
+ }
635
+ }
636
+ function win32Error(syscall, win32Code, path, dest) {
637
+ const code = errnoCode(win32Code);
638
+ const error = /* @__PURE__ */ new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`);
639
+ error.code = code;
640
+ error.errno = win32Code;
641
+ error.syscall = syscall;
642
+ error.path = path;
643
+ error.dest = dest;
644
+ error.win32Code = win32Code;
645
+ return error;
646
+ }
647
+ function isENOENT$1(error) {
648
+ return error?.code === "ENOENT";
649
+ }
650
+ function isEEXIST(error) {
651
+ return error?.code === "EEXIST";
652
+ }
653
+ async function assertDirectory(path) {
654
+ try {
655
+ if ((await stat(path === parse(path).root ? path : toNamespacedPath(path))).isDirectory()) return true;
656
+ const error = /* @__PURE__ */ new Error(`path exists but is not a directory: ${path}`);
657
+ error.code = "ENOTDIR";
658
+ error.path = path;
659
+ throw error;
660
+ } catch (error) {
661
+ if (isENOENT$1(error)) return false;
662
+ throw error;
663
+ }
664
+ }
665
+ /**
666
+ * Publish `existing` at `replacement` with Windows write-through rename
667
+ * semantics. The destination must not already exist; the move must stay within
668
+ * the volume (no copy fallback flag is set).
669
+ * @param existing - the synced staging path to move.
670
+ * @param replacement - the final path, which must not already exist.
671
+ */
672
+ async function publishNewFileWin32(existing, replacement) {
673
+ const api = await win32();
674
+ if (api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH) === 0) throw win32Error("MoveFileExW", api.getLastError(), existing, replacement);
675
+ }
676
+ /**
677
+ * Create `target` and its missing ancestors with durable Windows namespace
678
+ * publication. Each missing directory is first created as a random staging
679
+ * sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
680
+ * with another creator are accepted only after verifying the winner is a
681
+ * directory.
682
+ * @param target - the absolute directory path to create durably when absent.
683
+ */
684
+ async function ensureDurableDirectoryWin32(target) {
685
+ const absolute = resolve(target);
686
+ const root = parse(absolute).root;
687
+ await assertDirectory(root);
688
+ const segments = absolute.slice(root.length).split(/[\\/]+/).filter((part) => part.length > 0);
689
+ let current = root;
690
+ for (const segment of segments) {
691
+ const next = join(current, segment);
692
+ if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next);
693
+ current = next;
694
+ }
695
+ }
696
+ async function createLeafDirectoryWin32(parent, target) {
697
+ const staging = await mkdtemp(toNamespacedPath(join(parent, ".dsh-mkdir-")));
698
+ try {
699
+ await publishNewFileWin32(staging, target);
700
+ } catch (error) {
701
+ await rm(staging, {
702
+ recursive: true,
703
+ force: true
704
+ });
705
+ if (isEEXIST(error) && await assertDirectory(target)) return;
706
+ throw error;
707
+ }
708
+ }
709
+ //#endregion
710
+ //#region lib/types/index.js
711
+ /**
712
+ * JSONL durable session-persistence backend. It stores a header and contiguous
713
+ * events in one append-only file per session, and delegates orchestration to
714
+ * {@link PersistenceCoordinator}. Its side-effect-free locator returns the
715
+ * absolute per-session log target before materialization.
716
+ * @module @deepseek-ai/dsh-session-persistence-jsonl
717
+ */
718
+ const DEFAULT_PACK_CHUNKS = true;
719
+ const DEFAULT_COMPRESSION = "zstd";
720
+ /**
721
+ * Internal scheduling constant, not deployment configuration: balance
722
+ * frame-boundary event-loop yields against `setImmediate` overhead. One frame
723
+ * remains an indivisible synchronous decode.
724
+ */
725
+ const ZSTD_DECODE_YIELD_INTERVAL_MS = 500;
726
+ /** Assert that the independently decodable first frame contains only the header record. */
727
+ function assertZstdHeaderFrame(plaintext) {
728
+ if (plaintext.length === 0 || plaintext.indexOf(10) !== plaintext.length - 1) throw new Error("corrupt Zstandard session log: first frame is not exactly one header line");
729
+ }
730
+ /** Loader schema for the JSONL artifact's physical encoding. */
731
+ const JsonlCompressionSchema = z.union([z.const("zstd"), z.const("none")]).default(DEFAULT_COMPRESSION);
732
+ /** Build the source-qualified revision shared by full and lightweight reads. */
733
+ function fileRevision(identity) {
734
+ return SessionPersistenceRevision([
735
+ identity.dev,
736
+ identity.ino,
737
+ identity.size,
738
+ identity.mtimeNs,
739
+ identity.ctimeNs
740
+ ].join(":"));
741
+ }
742
+ /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
743
+ function isENOENT(error) {
744
+ return error?.code === "ENOENT";
745
+ }
746
+ /**
747
+ * The JSONL persistence backend. Load as a plugin; it registers as
748
+ * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
749
+ * listeners. Its torn-tail marker carries the byte offset and any events
750
+ * recovered from an incomplete final Zstandard frame.
751
+ */
752
+ var SessionPersistenceJsonl = class extends SessionPersistence {
753
+ config;
754
+ static inject = ["sessions"];
755
+ static Config = z.object({
756
+ root: z.string().required(),
757
+ packChunks: z.boolean().default(DEFAULT_PACK_CHUNKS),
758
+ compression: JsonlCompressionSchema,
759
+ preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
760
+ writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS).default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS)
761
+ });
762
+ /**
763
+ * Backend label for coordinator diagnostics and effects. It shadows
764
+ * `Service.name` without changing the service key captured by the base
765
+ * constructor.
766
+ */
767
+ name = "session-persistence-jsonl";
768
+ root;
769
+ packChunks;
770
+ compression;
771
+ coordinator;
772
+ rootEncodingCheck;
773
+ constructor(ctx, config) {
774
+ super(ctx);
775
+ this.config = config;
776
+ this.root = resolve(config.root);
777
+ const preparedSessionCacheSize = config.preparedSessionCacheSize ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE;
778
+ const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS;
779
+ this.packChunks = config.packChunks ?? DEFAULT_PACK_CHUNKS;
780
+ this.compression = config.compression ?? DEFAULT_COMPRESSION;
781
+ this.assertUsableRoot();
782
+ this.coordinator = new PersistenceCoordinator(this.ctx, this, {
783
+ preparedSessionCacheSize,
784
+ writeBatchMaxDelayMs
785
+ });
786
+ }
787
+ /** Resolve the absolute target path without touching the filesystem. */
788
+ locate(meta) {
789
+ return {
790
+ kind: "jsonl",
791
+ path: logPath(this.root, meta.cwd, meta.id, this.compression)
792
+ };
793
+ }
794
+ create(meta) {
795
+ return this.coordinator.create(meta);
796
+ }
797
+ append(id, events) {
798
+ return this.coordinator.append(id, events);
799
+ }
800
+ prepare(id, signal) {
801
+ return this.coordinator.prepare(id, signal);
802
+ }
803
+ load(id) {
804
+ return this.coordinator.load(id);
805
+ }
806
+ inspect(id, signal) {
807
+ return this.coordinator.inspect(id, signal);
808
+ }
809
+ readFrom(id, fromSeq, signal) {
810
+ return this.coordinator.readFrom(id, fromSeq, signal);
811
+ }
812
+ /** Read a stored prefix by id across all project directories when cwd is unknown. */
813
+ async loadStored(id, signal) {
814
+ signal?.throwIfAborted();
815
+ await this.ensureRootEncoding();
816
+ signal?.throwIfAborted();
817
+ const path = await this.findLog(id, signal);
818
+ if (path === void 0) return void 0;
819
+ return this.readPrefix(path, id, signal);
820
+ }
821
+ /**
822
+ * Read one log's stat-derived revision without loading its event bytes.
823
+ * Resolving an id with unknown cwd still scans the project directories.
824
+ */
825
+ async readStoredRevision(id, signal) {
826
+ signal?.throwIfAborted();
827
+ await this.ensureRootEncoding();
828
+ signal?.throwIfAborted();
829
+ const path = await this.findLog(id, signal);
830
+ if (path === void 0) return void 0;
831
+ try {
832
+ const identity = await stat(path, { bigint: true });
833
+ signal?.throwIfAborted();
834
+ return fileRevision(identity);
835
+ } catch (error) {
836
+ signal?.throwIfAborted();
837
+ if (isENOENT(error)) return void 0;
838
+ throw error;
839
+ }
840
+ }
841
+ /**
842
+ * Read a stored prefix and convert torn-tail state to the opaque marker the
843
+ * coordinator can round-trip without knowing the physical encoding.
844
+ */
845
+ async readPrefix(path, expectedId, signal) {
846
+ let buffer;
847
+ let revision;
848
+ for (;;) {
849
+ signal?.throwIfAborted();
850
+ const before = fileRevision(await stat(path, { bigint: true }));
851
+ buffer = await readFile(path, { signal });
852
+ signal?.throwIfAborted();
853
+ const after = fileRevision(await stat(path, { bigint: true }));
854
+ if (before === after) {
855
+ revision = after;
856
+ break;
857
+ }
858
+ }
859
+ let prefix;
860
+ if (this.compression === "zstd") prefix = await this.readZstdPrefix(buffer, signal);
861
+ else {
862
+ signal?.throwIfAborted();
863
+ const { meta, events, committedBytes } = scanLog(buffer);
864
+ signal?.throwIfAborted();
865
+ prefix = {
866
+ meta,
867
+ events,
868
+ ...committedBytes < buffer.byteLength ? { tornMarker: {
869
+ truncateTo: committedBytes,
870
+ recoveredEvents: []
871
+ } } : {}
872
+ };
873
+ }
874
+ signal?.throwIfAborted();
875
+ await this.assertStoredIdentity(path, prefix.meta, expectedId, signal);
876
+ signal?.throwIfAborted();
877
+ return {
878
+ ...prefix,
879
+ revision
880
+ };
881
+ }
882
+ /** Decode complete frames and retain complete JSONL records from a torn final frame. */
883
+ async readZstdPrefix(buffer, signal) {
884
+ signal?.throwIfAborted();
885
+ const { frames, tornStart } = scanZstdFrames(buffer);
886
+ signal?.throwIfAborted();
887
+ if (frames.length === 0) throw new Error("empty or header-less Zstandard session log");
888
+ const decoder = createZstdFrameDecoder();
889
+ let yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS;
890
+ try {
891
+ const decodedFrames = decoder.decode(buffer, frames);
892
+ signal?.throwIfAborted();
893
+ const headerFrame = decodedFrames.next();
894
+ signal?.throwIfAborted();
895
+ /* v8 ignore next -- a non-empty structural frame list makes the decoder yield its first frame or throw. */
896
+ if (headerFrame.done) throw new Error("empty or header-less Zstandard session log");
897
+ assertZstdHeaderFrame(headerFrame.value);
898
+ const scanner = new SessionLogScanner(headerFrame.value);
899
+ let remainingFrames = frames.length - 1;
900
+ for (const plaintext of decodedFrames) {
901
+ signal?.throwIfAborted();
902
+ scanner.write(plaintext);
903
+ remainingFrames -= 1;
904
+ if (remainingFrames > 0 && performance.now() >= yieldDeadline) {
905
+ await scheduler.yield();
906
+ signal?.throwIfAborted();
907
+ yieldDeadline = performance.now() + ZSTD_DECODE_YIELD_INTERVAL_MS;
908
+ }
909
+ }
910
+ signal?.throwIfAborted();
911
+ const complete = scanner.checkpoint();
912
+ if (complete.committedBytes !== complete.inputBytes) throw new Error("corrupt Zstandard session log: complete frame contains a torn JSONL record");
913
+ if (tornStart === void 0) {
914
+ const prefix = scanner.finish();
915
+ return {
916
+ meta: prefix.meta,
917
+ events: prefix.events
918
+ };
919
+ }
920
+ let recoveredPlaintext = Buffer.alloc(0);
921
+ try {
922
+ signal?.throwIfAborted();
923
+ recoveredPlaintext = await decompressZstdPrefix(buffer.subarray(tornStart));
924
+ } catch {
925
+ /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
926
+ if (signal?.aborted) signal.throwIfAborted();
927
+ }
928
+ signal?.throwIfAborted();
929
+ scanner.write(recoveredPlaintext);
930
+ const recoveredPrefix = scanner.finish();
931
+ signal?.throwIfAborted();
932
+ return {
933
+ meta: recoveredPrefix.meta,
934
+ events: recoveredPrefix.events,
935
+ tornMarker: {
936
+ truncateTo: tornStart,
937
+ recoveredEvents: recoveredPrefix.events.slice(complete.eventCount)
938
+ }
939
+ };
940
+ } catch (error) {
941
+ /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
942
+ if (signal?.aborted) signal.throwIfAborted();
943
+ throw error;
944
+ } finally {
945
+ decoder.close();
946
+ }
947
+ }
948
+ /** Durably append a batch, lazily materializing the file when not yet present. */
949
+ async appendBatch(meta, events, isMaterialized) {
950
+ await this.ensureRootEncoding();
951
+ if (isMaterialized) await this.appendLines(meta, events);
952
+ else await this.materialize(meta, events);
953
+ }
954
+ /**
955
+ * Make a crash repair durable: truncate a torn tail, restore complete events
956
+ * decoded from it, then append synthetic closers. Two fsync'd steps — the seam
957
+ * does not require this to be atomic.
958
+ */
959
+ async commitRepair(meta, tornMarker, closers) {
960
+ if (tornMarker !== void 0) await this.repair(meta, tornMarker.truncateTo);
961
+ const repairedEvents = [...tornMarker?.recoveredEvents ?? [], ...closers];
962
+ if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents);
963
+ }
964
+ /** List valid unique stored sessions' metadata (header line only — no full-log parse). */
965
+ async list(signal) {
966
+ return (await this.listArtifacts(signal)).map((artifact) => artifact.header);
967
+ }
968
+ /** List metadata plus a stat-derived identity for each append-only log. */
969
+ async listSnapshots(signal) {
970
+ const snapshots = [];
971
+ for (const artifact of await this.listArtifacts(signal)) {
972
+ signal?.throwIfAborted();
973
+ try {
974
+ const identity = await stat(artifact.path, { bigint: true });
975
+ signal?.throwIfAborted();
976
+ snapshots.push({
977
+ header: artifact.header,
978
+ revision: fileRevision(identity)
979
+ });
980
+ } catch (error) {
981
+ signal?.throwIfAborted();
982
+ if (!isENOENT(error)) throw error;
983
+ }
984
+ }
985
+ signal?.throwIfAborted();
986
+ return snapshots;
987
+ }
988
+ async listArtifacts(signal) {
989
+ signal?.throwIfAborted();
990
+ await this.ensureRootEncoding();
991
+ signal?.throwIfAborted();
992
+ const artifacts = [];
993
+ const ids = /* @__PURE__ */ new Set();
994
+ for (const project of await this.listProjectDirs(signal)) {
995
+ signal?.throwIfAborted();
996
+ for (const dir of await this.listSessionDirs(project, signal)) {
997
+ signal?.throwIfAborted();
998
+ const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`);
999
+ const oppositeExists = await this.exists(opposite);
1000
+ signal?.throwIfAborted();
1001
+ if (oppositeExists) throw this.encodingMismatch(opposite);
1002
+ const path = join(dir, `session${logSuffix(this.compression)}`);
1003
+ const pathExists = await this.exists(path);
1004
+ signal?.throwIfAborted();
1005
+ if (!pathExists) continue;
1006
+ const first = this.compression === "zstd" ? await this.readFirstZstdLine(path, signal) : await this.readFirstLine(path, signal);
1007
+ signal?.throwIfAborted();
1008
+ if (first === void 0) continue;
1009
+ const meta = parseHeaderMeta(first);
1010
+ if (meta === void 0) continue;
1011
+ await this.assertStoredIdentity(path, meta, void 0, signal);
1012
+ signal?.throwIfAborted();
1013
+ if (ids.has(meta.id)) throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`);
1014
+ ids.add(meta.id);
1015
+ artifacts.push({
1016
+ header: meta,
1017
+ path
1018
+ });
1019
+ }
1020
+ }
1021
+ signal?.throwIfAborted();
1022
+ return artifacts;
1023
+ }
1024
+ /** Atomically write the header line + first batch (temp-write, fsync, publish). */
1025
+ async materialize(meta, events) {
1026
+ const project = projectDir(this.root, meta.cwd);
1027
+ const dir = sessionDir(this.root, meta.cwd, meta.id);
1028
+ const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression);
1029
+ await this.rejectOppositeArtifact(meta.cwd, meta.id);
1030
+ const content = await this.encodeMaterialization(meta, events);
1031
+ /* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
1032
+ if (process.platform === "win32") await this.materializeWin32(project, dir, finalPath, meta.id, content);
1033
+ else await this.materializePosix(project, dir, finalPath, meta.id, content);
1034
+ }
1035
+ /* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
1036
+ async materializePosix(project, dir, finalPath, id, content) {
1037
+ await mkdir(this.root, {
1038
+ recursive: true,
1039
+ mode: 448
1040
+ });
1041
+ await this.syncDirPosix(dirname(this.root));
1042
+ await mkdir(project, {
1043
+ recursive: true,
1044
+ mode: 448
1045
+ });
1046
+ await this.syncDirPosix(this.root);
1047
+ await mkdir(dir, {
1048
+ recursive: true,
1049
+ mode: 448
1050
+ });
1051
+ await this.syncDirPosix(project);
1052
+ await this.rejectExistingLog(finalPath, id);
1053
+ const tmp = await this.writeSyncedTempFile(finalPath, content);
1054
+ let linked = false;
1055
+ try {
1056
+ await link(tmp, finalPath);
1057
+ linked = true;
1058
+ } finally {
1059
+ /* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
1060
+ if (!linked) await rm(tmp, { force: true });
1061
+ }
1062
+ await this.syncDirPosix(dir);
1063
+ try {
1064
+ await rm(tmp, { force: true });
1065
+ } catch {}
1066
+ }
1067
+ /* v8 ignore stop */
1068
+ /* v8 ignore start -- native Windows coverage exercises this integration path */
1069
+ async materializeWin32(project, dir, finalPath, id, content) {
1070
+ await ensureDurableDirectoryWin32(this.root);
1071
+ await ensureDurableDirectoryWin32(project);
1072
+ await ensureDurableDirectoryWin32(dir);
1073
+ await this.rejectExistingLog(finalPath, id);
1074
+ const tmp = await this.writeSyncedTempFile(finalPath, content);
1075
+ try {
1076
+ await publishNewFileWin32(tmp, finalPath);
1077
+ } catch (error) {
1078
+ await rm(tmp, { force: true });
1079
+ throw error;
1080
+ }
1081
+ }
1082
+ /* v8 ignore stop */
1083
+ async rejectExistingLog(finalPath, id) {
1084
+ /* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
1085
+ if (await this.exists(finalPath)) throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`);
1086
+ }
1087
+ async writeSyncedTempFile(finalPath, content) {
1088
+ const tmp = `${finalPath}.${randomBytes(6).toString("hex")}.tmp`;
1089
+ const handle = await open(tmp, "wx", 384);
1090
+ try {
1091
+ await handle.writeFile(content);
1092
+ await handle.sync();
1093
+ } finally {
1094
+ await handle.close();
1095
+ }
1096
+ return tmp;
1097
+ }
1098
+ /** Encode the header and first batch without combining their frame boundaries. */
1099
+ async encodeMaterialization(meta, events) {
1100
+ const header = JSON.stringify(toHeaderLine(meta)) + "\n";
1101
+ const body = eventLines(events, this.packChunks) + "\n";
1102
+ if (this.compression === "none") return header + body;
1103
+ const headerFrame = await compressZstdFrame(header);
1104
+ const eventFrame = await compressZstdFrame(body);
1105
+ return Buffer.concat([headerFrame, eventFrame]);
1106
+ }
1107
+ /** Encode one durable append batch in the configured physical representation. */
1108
+ async encodeEventBatch(events) {
1109
+ const body = eventLines(events, this.packChunks) + "\n";
1110
+ return this.compression === "zstd" ? compressZstdFrame(body) : body;
1111
+ }
1112
+ /** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
1113
+ /* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
1114
+ async syncDirPosix(dir) {
1115
+ const handle = await open(dir, "r");
1116
+ try {
1117
+ await handle.sync();
1118
+ } finally {
1119
+ await handle.close();
1120
+ }
1121
+ }
1122
+ /* v8 ignore stop */
1123
+ /**
1124
+ * Append and fsync event lines. On a partial write or sync failure, restore the
1125
+ * previous size before rethrowing because the unchanged cursor will retry the
1126
+ * batch; leaving partial bytes would create duplicate sequence numbers.
1127
+ */
1128
+ async appendLines(meta, events) {
1129
+ const content = await this.encodeEventBatch(events);
1130
+ const path = logPath(this.root, meta.cwd, meta.id, this.compression);
1131
+ const handle = await open(path, "a");
1132
+ let closed = false;
1133
+ const closeAppendHandle = async () => {
1134
+ if (closed) return;
1135
+ closed = true;
1136
+ await handle.close();
1137
+ };
1138
+ try {
1139
+ const { size: before } = await handle.stat();
1140
+ try {
1141
+ await handle.writeFile(content);
1142
+ await handle.sync();
1143
+ } catch (error) {
1144
+ try {
1145
+ await closeAppendHandle();
1146
+ await this.rollbackAppend(path, before);
1147
+ } catch (rollbackError) {
1148
+ throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`);
1149
+ }
1150
+ throw error;
1151
+ }
1152
+ } finally {
1153
+ await closeAppendHandle();
1154
+ }
1155
+ }
1156
+ async rollbackAppend(path, size) {
1157
+ const handle = await open(path, "r+");
1158
+ try {
1159
+ await handle.truncate(size);
1160
+ await handle.sync();
1161
+ } finally {
1162
+ await handle.close();
1163
+ }
1164
+ }
1165
+ /** Truncate the log file to `offset` bytes and fsync (discard the crash tail). */
1166
+ async repair(meta, offset) {
1167
+ const path = logPath(this.root, meta.cwd, meta.id, this.compression);
1168
+ await truncate(path, offset);
1169
+ const handle = await open(path, "r+");
1170
+ try {
1171
+ await handle.sync();
1172
+ } finally {
1173
+ await handle.close();
1174
+ }
1175
+ }
1176
+ /**
1177
+ * Read the first newline-terminated line of a file without loading the whole
1178
+ * file. Returns undefined if the file is empty or has no complete first line.
1179
+ * Reads in bounded chunks so a huge log costs only the header read.
1180
+ */
1181
+ async readFirstLine(path, signal) {
1182
+ signal?.throwIfAborted();
1183
+ const handle = await open(path, "r");
1184
+ try {
1185
+ signal?.throwIfAborted();
1186
+ const chunks = [];
1187
+ const buf = Buffer.alloc(8192);
1188
+ for (;;) {
1189
+ signal?.throwIfAborted();
1190
+ const { bytesRead } = await handle.read(buf, 0, buf.length, null);
1191
+ signal?.throwIfAborted();
1192
+ if (bytesRead === 0) return void 0;
1193
+ const slice = buf.subarray(0, bytesRead);
1194
+ const nl = slice.indexOf(10);
1195
+ if (nl !== -1) {
1196
+ chunks.push(slice.subarray(0, nl));
1197
+ signal?.throwIfAborted();
1198
+ return Buffer.concat(chunks).toString("utf8");
1199
+ }
1200
+ chunks.push(Buffer.from(slice));
1201
+ }
1202
+ } finally {
1203
+ await handle.close();
1204
+ }
1205
+ }
1206
+ /** Read and validate only the independently compressed header frame. */
1207
+ async readFirstZstdLine(path, signal) {
1208
+ signal?.throwIfAborted();
1209
+ const handle = await open(path, "r");
1210
+ try {
1211
+ signal?.throwIfAborted();
1212
+ let content = Buffer.alloc(0);
1213
+ const chunk = Buffer.alloc(8192);
1214
+ for (;;) {
1215
+ signal?.throwIfAborted();
1216
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
1217
+ signal?.throwIfAborted();
1218
+ if (bytesRead === 0) return void 0;
1219
+ signal?.throwIfAborted();
1220
+ content = Buffer.concat([content, chunk.subarray(0, bytesRead)]);
1221
+ signal?.throwIfAborted();
1222
+ const first = scanZstdFrames(content, 1).frames[0];
1223
+ signal?.throwIfAborted();
1224
+ if (first === void 0) continue;
1225
+ let plaintext;
1226
+ try {
1227
+ signal?.throwIfAborted();
1228
+ plaintext = await decompressZstdFrame(content.subarray(first.start, first.end));
1229
+ } catch (error) {
1230
+ /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
1231
+ if (signal?.aborted) signal.throwIfAborted();
1232
+ throw new Error("corrupt Zstandard session log: header frame failed validation", { cause: error });
1233
+ }
1234
+ signal?.throwIfAborted();
1235
+ assertZstdHeaderFrame(plaintext);
1236
+ return plaintext.subarray(0, -1).toString("utf8");
1237
+ }
1238
+ } finally {
1239
+ await handle.close();
1240
+ }
1241
+ }
1242
+ /** Find the unique physical log for an id across every project directory. */
1243
+ async findLog(id, signal) {
1244
+ const matches = [];
1245
+ for (const project of await this.listProjectDirs(signal)) {
1246
+ signal?.throwIfAborted();
1247
+ await this.rejectLegacyFlatArtifact(project, id, signal);
1248
+ signal?.throwIfAborted();
1249
+ const dir = join(project, encodeSegment(id));
1250
+ const path = join(dir, `session${logSuffix(this.compression)}`);
1251
+ const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`);
1252
+ const oppositeExists = await this.exists(opposite);
1253
+ signal?.throwIfAborted();
1254
+ if (oppositeExists) throw this.encodingMismatch(opposite);
1255
+ const pathExists = await this.exists(path);
1256
+ signal?.throwIfAborted();
1257
+ if (pathExists) matches.push(path);
1258
+ }
1259
+ if (matches.length > 1) throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`);
1260
+ signal?.throwIfAborted();
1261
+ return matches[0];
1262
+ }
1263
+ /** Require an existing configured root to be a readable directory. */
1264
+ assertUsableRoot() {
1265
+ try {
1266
+ readdirSync(this.root);
1267
+ } catch (error) {
1268
+ if (isENOENT(error)) return;
1269
+ throw error;
1270
+ }
1271
+ }
1272
+ /** Reject metadata that does not identify the selected physical log. */
1273
+ async assertStoredIdentity(path, meta, expectedId, signal) {
1274
+ signal?.throwIfAborted();
1275
+ if (expectedId !== void 0 && meta.id !== expectedId) throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`);
1276
+ let expectedPath;
1277
+ try {
1278
+ expectedPath = logPath(this.root, meta.cwd, meta.id, this.compression);
1279
+ } catch (error) {
1280
+ throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error });
1281
+ }
1282
+ if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`);
1283
+ signal?.throwIfAborted();
1284
+ }
1285
+ /**
1286
+ * Whether two path spellings resolve to the same physical file. This admits
1287
+ * case aliases on case-insensitive filesystems without weakening identity
1288
+ * checks on case-sensitive stores.
1289
+ */
1290
+ async sameFile(path, expectedPath, signal) {
1291
+ signal?.throwIfAborted();
1292
+ try {
1293
+ const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)]);
1294
+ signal?.throwIfAborted();
1295
+ return actual === expected;
1296
+ } catch (error) {
1297
+ signal?.throwIfAborted();
1298
+ /* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
1299
+ if (isENOENT(error)) return false;
1300
+ /* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
1301
+ throw error;
1302
+ }
1303
+ }
1304
+ /** The human-readable project directories under the configured root. */
1305
+ async listProjectDirs(signal) {
1306
+ try {
1307
+ signal?.throwIfAborted();
1308
+ const entries = await readdir(this.root, { withFileTypes: true });
1309
+ signal?.throwIfAborted();
1310
+ return entries.filter((e) => e.isDirectory()).map((e) => join(this.root, e.name));
1311
+ } catch (error) {
1312
+ if (isENOENT(error)) return [];
1313
+ throw error;
1314
+ }
1315
+ }
1316
+ /** List session-owned directories and reject the obsolete flat-file layout. */
1317
+ async listSessionDirs(project, signal) {
1318
+ signal?.throwIfAborted();
1319
+ const entries = await readdir(project, { withFileTypes: true });
1320
+ signal?.throwIfAborted();
1321
+ const legacy = entries.find((entry) => entry.isFile() && (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.zstd")));
1322
+ if (legacy !== void 0) throw this.legacyLayout(join(project, legacy.name));
1323
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => join(project, entry.name));
1324
+ }
1325
+ /** Reject a root that already belongs to the other physical encoding. */
1326
+ ensureRootEncoding() {
1327
+ this.rootEncodingCheck ??= this.checkRootEncoding();
1328
+ return this.rootEncodingCheck;
1329
+ }
1330
+ async checkRootEncoding() {
1331
+ for (const project of await this.listProjectDirs()) for (const dir of await this.listSessionDirs(project)) {
1332
+ const incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`);
1333
+ if (await this.exists(incompatible)) throw this.encodingMismatch(incompatible);
1334
+ }
1335
+ }
1336
+ async rejectLegacyFlatArtifact(project, id, signal) {
1337
+ signal?.throwIfAborted();
1338
+ const encoded = encodeSegment(id);
1339
+ for (const compression of ["zstd", "none"]) {
1340
+ const path = join(project, encoded + logSuffix(compression));
1341
+ const artifactExists = await this.exists(path);
1342
+ signal?.throwIfAborted();
1343
+ if (artifactExists) throw this.legacyLayout(path);
1344
+ }
1345
+ }
1346
+ async rejectOppositeArtifact(cwd, id) {
1347
+ const path = logPath(this.root, cwd, id, this.oppositeCompression());
1348
+ if (await this.exists(path)) throw this.encodingMismatch(path);
1349
+ }
1350
+ oppositeCompression() {
1351
+ return this.compression === "zstd" ? "none" : "zstd";
1352
+ }
1353
+ encodingMismatch(path) {
1354
+ return /* @__PURE__ */ new Error(`session artifact ${JSON.stringify(path)} uses ${logSuffix(this.oppositeCompression())}, but this backend is configured for compression ${JSON.stringify(this.compression)}; use a separate root or select the matching compression mode`);
1355
+ }
1356
+ legacyLayout(path) {
1357
+ return /* @__PURE__ */ new Error(`session artifact ${JSON.stringify(path)} uses the unsupported flat-file layout; use a separate root or move it into a project/session directory before loading`);
1358
+ }
1359
+ async exists(path) {
1360
+ try {
1361
+ await (await open(path, "r")).close();
1362
+ return true;
1363
+ } catch (error) {
1364
+ /* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
1365
+ if (isENOENT(error)) {
1366
+ await this.assertLogParentAllowsAbsence(path);
1367
+ return false;
1368
+ }
1369
+ /* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
1370
+ throw error;
1371
+ }
1372
+ }
1373
+ /* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
1374
+ async assertLogParentAllowsAbsence(path) {
1375
+ try {
1376
+ const parent = dirname(path);
1377
+ if ((await stat(parent)).isDirectory()) return;
1378
+ const error = /* @__PURE__ */ new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`);
1379
+ error.code = "ENOTDIR";
1380
+ error.path = parent;
1381
+ throw error;
1382
+ } catch (error) {
1383
+ if (isENOENT(error)) return;
1384
+ throw error;
1385
+ }
1386
+ }
1387
+ };
1388
+ //#endregion
1389
+ export { JsonlCompressionSchema, SessionPersistenceJsonl, SessionPersistenceJsonl as default };