dsh-llm-fallbacks 0.4.3 → 0.5.0

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.
@@ -0,0 +1,530 @@
1
+ /**
2
+ * publish.ts — publish a V3 successor generation beside a pre-V3 session log.
3
+ *
4
+ * A session log is a concatenated-Zstandard-frame container. This module owns
5
+ * the physical framing of the successor, the fail-closed publication order, and
6
+ * the read-back proof that makes `{ verified: true }` honest.
7
+ *
8
+ * FRAMING (SSOT: `@deepseek-ai/dsh-session-persistence-jsonl`, pinned checkout
9
+ * `packages/session/session-persistence-jsonl/src/{zstd.ts,generation.ts,format.ts}`):
10
+ * - frame 1 decodes to EXACTLY ONE header line (the released reader asserts
11
+ * `plaintext.indexOf(0x0A) === plaintext.length - 1` for frame 1);
12
+ * - every following frame carries an event batch;
13
+ * - every frame is independently decodable and checksummed
14
+ * (`ZSTD_c_checksumFlag`), exactly like the released writer.
15
+ * The successor's records come from the RELEASED encoder
16
+ * (`encodeCurrentHeader` / `encodeCurrentEvent`), not from the input rows, so
17
+ * the written generation is whatever the catalog considers current.
18
+ *
19
+ * COMPRESSION — Node's `node:zlib` zstd API, no `zstd` CLI (measured on
20
+ * v24.18.0: `zstdCompress`/`zstdDecompress` exist and are used here):
21
+ * - writing uses `zstdCompressSync` with the checksum parameter, the same
22
+ * frame the released writer emits;
23
+ * - READ-BACK needs a frame splitter, because Node's public zstd API decodes
24
+ * only the FIRST frame of a concatenated stream and silently ignores the
25
+ * rest (verified in this environment; the released package hits the same
26
+ * limit and therefore ships its own `scanZstdFrames` + per-frame decoder).
27
+ * The superseded `scripts/repair-fallbacks-switch-logs.ts` shelled out to the
28
+ * `zstd` CLI for the same reason; that would put a host binary on the success
29
+ * path, so this module keeps the pure-Node scanner instead. Cost: the tool
30
+ * requires Node >= 22.15 (the release that added `node:zlib` zstd); the repo
31
+ * runtime floor is Node 22 and the pinned harness runs Node 24.
32
+ *
33
+ * FAIL-CLOSED PUBLICATION ORDER (`publishSuccessor`):
34
+ * 1. refuse without a resolved catalog (no oracle -> no write);
35
+ * 2. read the original and refuse when it no longer matches the digest of the
36
+ * revision the CALLER decoded its rows from — compared BEFORE anything is
37
+ * staged, so a concurrent append can never be published as a verified
38
+ * successor;
39
+ * 3. take a defensive copy of the caller's rows — they alias `form` /
40
+ * `summary` / `sections` objects owned by the caller, and the released
41
+ * restore is documented as validating an artifact it may mutate in place;
42
+ * 4. re-run the released restore under the STRICT recovery policy: a repaired
43
+ * log must not silently truncate at a swallowed refusal (the recoverable
44
+ * policy classification uses records the first issue and drops the rows
45
+ * after it until a `turn/end` row surfaces it);
46
+ * 5. write to a noncanonical temporary name, verify it by reading the frames
47
+ * back through the same catalog with `validation: 'current'` (STRONGER than
48
+ * the host's own header restore, which runs `{ recovery: 'strict',
49
+ * validation: 'transformed' }`), then publish it exclusively with `link` so
50
+ * an existing successor is never overwritten with different bytes; the
51
+ * result records whether this call CREATED the target or ACCEPTED an
52
+ * already-identical one;
53
+ * 6. remove the temporary and re-verify the original's digest. When the source
54
+ * moved after a publication this call CREATED, the successor is unlinked
55
+ * again (the store is left as found) and the failure says so. When it moved
56
+ * after ACCEPTING a pre-existing identical successor, that file is left
57
+ * alone, the failure names it, and it is raised as
58
+ * {@link PublishedFromStaleSourceError} so no caller can report "nothing was
59
+ * published".
60
+ * A failure before step 5 writes nothing canonical; the temporary is removed on
61
+ * every error path this process handles (a SIGKILL or a failing `rm` can still
62
+ * strand it, which is why discovery reports such names).
63
+ */
64
+ import { createHash, randomBytes } from 'node:crypto'
65
+ import { link, readFile, rm, writeFile } from 'node:fs/promises'
66
+ import { dirname, join, resolve } from 'node:path'
67
+ import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
68
+ import type { CatalogHandle, ReleasedArtifact, ReleasedCatalog } from './catalog.ts'
69
+ import { restoreRows } from './catalog.ts'
70
+ import type { ParsedRow } from './rules.ts'
71
+
72
+ /** Zstandard frame magic (little-endian `0xFD2FB528`). */
73
+ const ZSTD_MAGIC = 0xFD2FB528
74
+
75
+ /** The released writer's checksummed frame options (`ZSTD_c_checksumFlag`). */
76
+ const ZSTD_CHECKSUM_OPTIONS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } }
77
+
78
+ /** What one successful publication did with the canonical target. */
79
+ export type PublicationOutcome = 'created' | 'accepted'
80
+
81
+ /** Result of one successful publication. */
82
+ export interface PublishedSuccessor {
83
+ /** Generation the successor carries (the restored artifact header version). */
84
+ generation: number
85
+ /** Always `true`: the successor was read back through the catalog. */
86
+ verified: true
87
+ /** Canonical path of the published successor (actionable for rollback). */
88
+ targetPath: string
89
+ /** `created` when this call linked the target, `accepted` when identical bytes were already there. */
90
+ outcome: PublicationOutcome
91
+ }
92
+
93
+ /**
94
+ * A publication that HAPPENED but whose source generation moved afterwards: the
95
+ * successor is on disk (and the host will prefer it) while the rows it was built
96
+ * from are a stale snapshot. Raised instead of a plain error so no caller can
97
+ * report "nothing was published" and so the message can name the file to delete.
98
+ */
99
+ export class PublishedFromStaleSourceError extends Error {
100
+ constructor(
101
+ message: string,
102
+ /** Canonical successor left on disk; deleting it rolls the publication back. */
103
+ readonly successorPath: string,
104
+ /** Whether this call created it or accepted an identical pre-existing one. */
105
+ readonly outcome: PublicationOutcome,
106
+ ) {
107
+ super(message)
108
+ this.name = 'PublishedFromStaleSourceError'
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The largest plaintext this decoder will materialize for ONE Zstandard frame.
114
+ *
115
+ * The frame scanner already reads the declared content size, so a frame whose
116
+ * declaration exceeds this ceiling is refused WITHOUT decompressing it, and the
117
+ * decoder is capped at the declared size (or at this ceiling when the frame
118
+ * declares none) so a lying frame cannot expand past what it promised.
119
+ *
120
+ * What the ceiling actually bounds is a session's TOTAL plaintext, not one event:
121
+ * `encodeZstdFrames` batches every event of a generation into a SINGLE frame, so a
122
+ * successor's event frame grows with the whole session. Measured on this machine:
123
+ * 13.9 MB is the largest COMPRESSED log file (not a frame), while the largest frame
124
+ * the host actually decodes is 21.73 MiB — this tool's own successor for that log —
125
+ * so the ceiling carries ~23.6x headroom. A session whose total plaintext
126
+ * legitimately exceeded it would be refused as `decompress-failed` with a coded
127
+ * exit instead of exhausting memory.
128
+ */
129
+ export const MAX_FRAME_PLAINTEXT_BYTES = 512 * 1024 * 1024
130
+
131
+ /** One structurally complete Zstandard frame inside a concatenated container. */
132
+ interface ZstdFrameRange {
133
+ /** Inclusive frame start. */
134
+ start: number
135
+ /** Exclusive frame end. */
136
+ end: number
137
+ /** Content size the frame header declares, or `null` when it declares none. */
138
+ declaredBytes: number | null
139
+ }
140
+
141
+ /* ------------------------------------------------------------------ */
142
+ /* physical framing */
143
+ /* ------------------------------------------------------------------ */
144
+
145
+ /** Serialize one record as one JSONL line, refusing a non-JSON value. */
146
+ function recordLine(record: Record<string, unknown>, subject: string): string {
147
+ const text: unknown = JSON.stringify(record)
148
+ if (typeof text !== 'string') throw new Error(`${subject} is not lossless JSON`)
149
+ return `${text}\n`
150
+ }
151
+
152
+ /**
153
+ * Encode one successor generation as a concatenated-frame container: the first
154
+ * frame holds EXACTLY the header line, the second (when the log has events)
155
+ * holds the event batch.
156
+ *
157
+ * @param headerRecord released current header record.
158
+ * @param eventRecords released current event records, in log order.
159
+ * @returns the complete container bytes.
160
+ */
161
+ export function encodeZstdFrames(
162
+ headerRecord: Record<string, unknown>,
163
+ eventRecords: readonly Record<string, unknown>[],
164
+ ): Buffer {
165
+ const frames = [compressFrame(recordLine(headerRecord, 'session header'))]
166
+ if (eventRecords.length > 0) {
167
+ const batch = eventRecords.map((record, index) => recordLine(record, `session event ${index}`)).join('')
168
+ frames.push(compressFrame(batch))
169
+ }
170
+ return Buffer.concat(frames)
171
+ }
172
+
173
+ /**
174
+ * Decode every frame of one concatenated container, in file order.
175
+ *
176
+ * Bounded: the scanner reports each frame's DECLARED content size, a declaration
177
+ * above {@link MAX_FRAME_PLAINTEXT_BYTES} is refused before any decompression, and
178
+ * the decompressor itself is capped so a frame that lies about its size cannot
179
+ * expand past its own promise. A refusal here is a `decompress-failed` log (a
180
+ * coded exit), never an out-of-memory crash.
181
+ *
182
+ * @param bytes container bytes (an original log or a published generation).
183
+ * @returns the plaintext of each frame.
184
+ */
185
+ export function decodeZstdFrames(bytes: Buffer): string[] {
186
+ return scanZstdFrames(bytes).map(({ start, end, declaredBytes }, index) => {
187
+ if (declaredBytes !== null && declaredBytes > MAX_FRAME_PLAINTEXT_BYTES) {
188
+ throw new Error(
189
+ `refusing to decompress Zstandard frame ${index} at byte ${start}: it declares ${declaredBytes} bytes of `
190
+ + `plaintext, above this tool's ${MAX_FRAME_PLAINTEXT_BYTES}-byte frame ceiling`,
191
+ )
192
+ }
193
+ let plaintext: Buffer
194
+ try {
195
+ plaintext = zstdDecompressSync(bytes.subarray(start, end), {
196
+ // A frame may legitimately declare zero bytes (an empty batch), and
197
+ // `maxOutputLength` must be at least 1.
198
+ maxOutputLength: Math.max(declaredBytes ?? MAX_FRAME_PLAINTEXT_BYTES, 1),
199
+ })
200
+ } catch (error) {
201
+ throw new Error(`corrupt Zstandard session log: frame ${index} at byte ${start} failed to decode`, { cause: error })
202
+ }
203
+ if (declaredBytes !== null && plaintext.length !== declaredBytes) {
204
+ throw new Error(
205
+ `corrupt Zstandard session log: frame ${index} at byte ${start} declares ${declaredBytes} bytes but decoded `
206
+ + `${plaintext.length}`,
207
+ )
208
+ }
209
+ return plaintext.toString('utf8')
210
+ })
211
+ }
212
+
213
+ /** Compress one frame with the released writer's checksummed options. */
214
+ function compressFrame(text: string): Buffer {
215
+ return zstdCompressSync(Buffer.from(text, 'utf8'), ZSTD_CHECKSUM_OPTIONS)
216
+ }
217
+
218
+ /**
219
+ * Locate every complete frame without decompressing its blocks, so a torn or
220
+ * corrupt container is refused before any row is trusted. Adapted from the
221
+ * released `scanZstdFrames` (see the module docblock); unlike the released
222
+ * metadata scanner this one has no torn-tail mode: a published generation must
223
+ * be complete.
224
+ */
225
+ function scanZstdFrames(buffer: Buffer): ZstdFrameRange[] {
226
+ if (buffer.length === 0) throw new Error('empty session log: no Zstandard frame')
227
+ const frames: ZstdFrameRange[] = []
228
+ let offset = 0
229
+ while (offset < buffer.length) {
230
+ const start = offset
231
+ if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
232
+ throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
233
+ }
234
+ offset += 4
235
+ if (offset === buffer.length) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
236
+ const descriptor = buffer.readUInt8(offset)
237
+ offset += 1
238
+ if ((descriptor & 0x18) !== 0) {
239
+ throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
240
+ }
241
+ const contentSizeFlag = descriptor >>> 6
242
+ const singleSegment = (descriptor & 0x20) !== 0
243
+ const checksum = (descriptor & 0x04) !== 0
244
+ const dictionaryFlag = descriptor & 0x03
245
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
246
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
247
+ const contentSizeOffset = offset + (singleSegment ? 0 : 1) + dictionaryBytes
248
+ offset += (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
249
+ if (offset > buffer.length) throw new Error(`torn Zstandard session log: truncated frame header at byte ${start}`)
250
+ // The declared content size is what bounds this frame's expansion; reading it
251
+ // here (rather than discarding it) is what lets a lying frame be refused.
252
+ // Spec quirk: a 2-byte field encodes `Frame_Content_Size - 256`.
253
+ const declaredValue = contentSizeBytes === 0
254
+ ? null
255
+ : sizeToNumber(buffer, contentSizeOffset, contentSizeBytes)
256
+ const declaredBytes = declaredValue === null
257
+ ? null
258
+ : contentSizeBytes === 2 ? declaredValue + 256 : declaredValue
259
+ for (;;) {
260
+ if (buffer.length - offset < 3) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
261
+ const blockHeader = buffer.readUIntLE(offset, 3)
262
+ offset += 3
263
+ const lastBlock = (blockHeader & 1) !== 0
264
+ const blockType = (blockHeader >>> 1) & 0x03
265
+ const blockSize = blockHeader >>> 3
266
+ if (blockType === 0x03) {
267
+ throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`)
268
+ }
269
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize
270
+ if (buffer.length - offset < payloadBytes) {
271
+ throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
272
+ }
273
+ offset += payloadBytes
274
+ if (lastBlock) break
275
+ }
276
+ if (checksum) {
277
+ if (buffer.length - offset < 4) throw new Error(`torn Zstandard session log: truncated frame at byte ${start}`)
278
+ offset += 4
279
+ }
280
+ frames.push({ start, end: offset, declaredBytes })
281
+ }
282
+ return frames
283
+ }
284
+
285
+ /**
286
+ * Read one frame header's content-size field (little-endian, 1/2/4/8 bytes) as a
287
+ * number, or `null` when it is not representable as one.
288
+ */
289
+ function sizeToNumber(buffer: Buffer, offset: number, width: number): number | null {
290
+ let value = 0
291
+ for (let index = 0; index < width; index += 1) value += buffer.readUInt8(offset + index) * 2 ** (8 * index)
292
+ return Number.isSafeInteger(value) && value >= 0 ? value : null
293
+ }
294
+
295
+ /**
296
+ * Canonical successor filename for one generation (SSOT:
297
+ * `generationLogFilename` in `@deepseek-ai/dsh-session-persistence-jsonl`):
298
+ * version 0 keeps the suffix-only name, later generations carry `v<N>`.
299
+ */
300
+ function successorFilename(version: number): string {
301
+ return version === 0 ? 'session.jsonl.zstd' : `session.v${version}.jsonl.zstd`
302
+ }
303
+
304
+ /* ------------------------------------------------------------------ */
305
+ /* publication */
306
+ /* ------------------------------------------------------------------ */
307
+
308
+ /**
309
+ * Require a resolved catalog: the fail-closed guard every `--apply` path must
310
+ * pass before it touches a log. Exported so the CLI can refuse with the same
311
+ * message the publisher would raise.
312
+ *
313
+ * @param catalog handle from `resolveCatalog`, or its absence.
314
+ * @returns the handle, when present.
315
+ */
316
+ export function assertCatalog(catalog: CatalogHandle | null | undefined): CatalogHandle {
317
+ if (catalog === null || catalog === undefined) {
318
+ throw new Error(
319
+ 'no released session-format catalog resolved: refusing to write a successor generation. '
320
+ + 'Pass --catalog <path> or set DSH_SESSION_FORMAT_CATALOG to the @deepseek-ai/dsh-session-format-catalog package.',
321
+ )
322
+ }
323
+ return catalog
324
+ }
325
+
326
+ /**
327
+ * Publish the successor generation of a pre-V3 log, verified by reading it back
328
+ * through the released catalog.
329
+ *
330
+ * @param logPath the ORIGINAL log file (never modified; only read).
331
+ * @param normalizedRows the log's rows after the rule registry normalized them.
332
+ * @param catalog resolved released catalog, or `null` — an absent oracle refuses.
333
+ * @param sourceDigest sha256 of the bytes the CALLER decoded `normalizedRows`
334
+ * from. Compared with the file before anything is staged, so a revision the
335
+ * rows were not built from can never be published as verified.
336
+ * @returns the published generation, its verification, its path and whether this
337
+ * call created the target or accepted an identical pre-existing one.
338
+ */
339
+ export async function publishSuccessor(
340
+ logPath: string,
341
+ normalizedRows: readonly ParsedRow[],
342
+ catalog: CatalogHandle | null,
343
+ sourceDigest: string,
344
+ ): Promise<PublishedSuccessor> {
345
+ const handle = assertCatalog(catalog)
346
+ const originalBytes = await readFile(logPath)
347
+ const originalDigest = sha256(originalBytes)
348
+ if (originalDigest !== sourceDigest) {
349
+ throw new Error(
350
+ `the source generation changed since it was read: ${logPath} is now ${originalDigest} but the rows to publish `
351
+ + `came from ${sourceDigest}. Nothing was written; re-run so the successor is built from the current revision.`,
352
+ )
353
+ }
354
+
355
+ // Defensive copy: the caller's rows alias `form` / `summary` / `sections`
356
+ // objects, and the released restore may normalize the artifact in place.
357
+ const rows = structuredClone(normalizedRows)
358
+ const artifact = restoreRows(handle.catalog, rows, { recovery: 'strict', validation: 'transformed' })
359
+ const generation = artifact.header.version
360
+ const targetPath = join(dirname(logPath), successorFilename(generation))
361
+ if (resolve(targetPath) === resolve(logPath)) {
362
+ throw new Error(`refusing to publish the successor generation over its own source: ${logPath}`)
363
+ }
364
+
365
+ const bytes = encodeZstdFrames(
366
+ handle.catalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount),
367
+ artifact.events.map((event) => handle.catalog.encodeCurrentEvent(event)),
368
+ )
369
+
370
+ const temporaryPath = join(dirname(logPath), `session.repair.${randomBytes(6).toString('hex')}.jsonl.zstd.tmp`)
371
+ let outcome: PublicationOutcome
372
+ try {
373
+ // Inside the guarded region: a failure MID-write (ENOSPC, quota) leaves a
374
+ // truncated temporary behind, and the cleanup below is what removes it. The
375
+ // cleanup covers every error path THIS PROCESS handles; a SIGKILL or a failing
376
+ // `rm` can still strand the temporary, which is why discovery reports such
377
+ // names instead of ignoring them.
378
+ await writeFile(temporaryPath, bytes, { flag: 'wx', mode: 0o600 })
379
+ // Read the staged generation back with the host's current-generation policy
380
+ // BEFORE it becomes visible under its canonical name.
381
+ verifyGeneration(handle.catalog, await readFile(temporaryPath), generation)
382
+ outcome = await publishExclusive(temporaryPath, targetPath, sha256(bytes))
383
+ } catch (error) {
384
+ try {
385
+ await rm(temporaryPath, { force: true })
386
+ } catch (cleanupFailure) {
387
+ throw new AggregateError(
388
+ [error, cleanupFailure],
389
+ `failed to remove the staged generation ${temporaryPath} after an earlier failure`,
390
+ )
391
+ }
392
+ throw error
393
+ }
394
+ // The target holds the verified bytes; a redundant temporary link cannot turn
395
+ // a successful publication into a failure.
396
+ try {
397
+ await rm(temporaryPath, { force: true })
398
+ } catch {
399
+ // The canonical target is already published and verified.
400
+ }
401
+
402
+ if (sha256(await readFile(logPath)) !== originalDigest) {
403
+ await settleStalePublication(logPath, targetPath, outcome)
404
+ }
405
+ return { generation, verified: true, targetPath, outcome }
406
+ }
407
+
408
+ /**
409
+ * Settle a publication whose source generation moved AFTER it landed.
410
+ *
411
+ * The publication HAPPENED, so reporting "nothing was published" would be a lie,
412
+ * and leaving a successor this call CREATED would leave the store changed after a
413
+ * failed run. A created target is therefore removed again (the store is as it
414
+ * was) while an ACCEPTED pre-existing one is never touched; either way the failure
415
+ * names the path so rollback is actionable. Two cases raise
416
+ * {@link PublishedFromStaleSourceError} — an ACCEPTED pre-existing successor (not
417
+ * this call's to delete) and a CREATED one whose own unlink failed — because in
418
+ * both a successor IS on disk and no caller may describe it as "nothing was
419
+ * published"; a created target that was successfully removed raises a plain error,
420
+ * where that wording is true.
421
+ *
422
+ * Always throws. Exported because this is the decision table the fix round added,
423
+ * and it is reachable in production only by an actual race.
424
+ */
425
+ export async function settleStalePublication(
426
+ logPath: string,
427
+ targetPath: string,
428
+ outcome: PublicationOutcome,
429
+ ): Promise<never> {
430
+ if (outcome === 'created') {
431
+ try {
432
+ await rm(targetPath, { force: true })
433
+ } catch (error) {
434
+ // The unlink itself failed, so the file this call created IS still on disk:
435
+ // say that (and name it) instead of letting the raw fs error surface as
436
+ // "nothing was published" while a successor exists.
437
+ throw new PublishedFromStaleSourceError(
438
+ `the source generation changed during publication: ${logPath}, and the successor this call created `
439
+ + `(${targetPath}) could NOT be removed (${error instanceof Error ? error.message : String(error)}) — `
440
+ + 'delete it manually to roll the publication back.',
441
+ targetPath,
442
+ outcome,
443
+ )
444
+ }
445
+ throw new Error(
446
+ `the source generation changed during publication: ${logPath}. The successor this call created `
447
+ + `(${targetPath}) was removed; nothing was published.`,
448
+ )
449
+ }
450
+ throw new PublishedFromStaleSourceError(
451
+ `the source generation changed during publication: ${logPath}. The successor ${targetPath} was already `
452
+ + 'published by an earlier run and now holds a stale snapshot of this session; it was left in place because '
453
+ + 'this run did not create it — delete it to roll the publication back.',
454
+ targetPath,
455
+ outcome,
456
+ )
457
+ }
458
+
459
+ /**
460
+ * Read one complete published generation back through the catalog.
461
+ *
462
+ * `validation: 'current'` runs every installed current-format check, which is
463
+ * STRICTER than the host's own header restore for a current generation: the host
464
+ * restores with `{ recovery: 'strict', validation: 'transformed' }`, while this
465
+ * read-back holds the staged bytes to the full current-format validation before
466
+ * they become visible under a canonical name.
467
+ *
468
+ * @param catalog released catalog to read with.
469
+ * @param bytes published container bytes.
470
+ * @param expectedGeneration generation the filename promises.
471
+ */
472
+ function verifyGeneration(
473
+ catalog: ReleasedCatalog,
474
+ bytes: Buffer,
475
+ expectedGeneration: number,
476
+ ): ReleasedArtifact {
477
+ const [headerFrame, ...eventFrames] = decodeZstdFrames(bytes)
478
+ if (headerFrame === undefined) throw new Error('published generation carries no Zstandard frame')
479
+ if (headerFrame.indexOf('\n') !== headerFrame.length - 1) {
480
+ throw new Error('published generation frame 1 is not exactly one header line')
481
+ }
482
+ const restore = catalog.createRestore(JSON.parse(headerFrame.slice(0, -1)), {
483
+ recovery: 'strict',
484
+ validation: 'current',
485
+ })
486
+ for (const line of eventFrames.join('').split('\n')) {
487
+ if (line.length === 0) continue
488
+ restore.decodeRow(JSON.parse(line))
489
+ }
490
+ const artifact = restore.finish()
491
+ if (artifact.header.version !== expectedGeneration) {
492
+ throw new Error(
493
+ `published generation reads back as v${artifact.header.version}, expected v${expectedGeneration}`,
494
+ )
495
+ }
496
+ return artifact
497
+ }
498
+
499
+ /**
500
+ * Publish the staged bytes exclusively: `link` never replaces an existing
501
+ * successor, and an existing target is accepted only when it already carries
502
+ * exactly these bytes (a re-run of the same repair).
503
+ *
504
+ * @returns `created` when this call linked the target, `accepted` when identical
505
+ * bytes were already published.
506
+ */
507
+ async function publishExclusive(
508
+ temporaryPath: string,
509
+ targetPath: string,
510
+ digest: string,
511
+ ): Promise<PublicationOutcome> {
512
+ try {
513
+ await link(temporaryPath, targetPath)
514
+ return 'created'
515
+ } catch (error) {
516
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
517
+ }
518
+ const existing = await readFile(targetPath)
519
+ if (sha256(existing) !== digest) {
520
+ throw new Error(
521
+ `refusing to replace the existing successor generation ${targetPath}: it holds different bytes`,
522
+ )
523
+ }
524
+ return 'accepted'
525
+ }
526
+
527
+ /** Hex sha256 of one byte buffer. */
528
+ function sha256(bytes: Buffer): string {
529
+ return createHash('sha256').update(bytes).digest('hex')
530
+ }