@devicerail/recorder 0.1.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,993 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { link, lstat, open, rename, unlink } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { fromCanonicalJson, sha256Hex, sha256Matches, toCanonicalJson, toCanonicalJsonChecksumEnvelope, } from "./canonical.js";
7
+ import { RecorderError } from "./errors.js";
8
+ import { EventLog } from "./event-log.js";
9
+ import { RECORDER_CHECKPOINT_FORMAT, RECORDER_CHECKPOINT_VERSION, } from "./types.js";
10
+ /** Fixed metadata/checksum room above the complete 8 MiB BundleSource. */
11
+ export const RECORDER_CHECKPOINT_HEADROOM_BYTES = 64 * 1024;
12
+ export const RECORDER_CHECKPOINT_MAX_BYTES = 8 * 1024 * 1024 + RECORDER_CHECKPOINT_HEADROOM_BYTES;
13
+ const CHECKPOINT_ENVELOPE_KEYS = ["checkpoint", "sha256"];
14
+ const CHECKPOINT_BASE_KEYS = [
15
+ "eventProtocolVersion",
16
+ "events",
17
+ "format",
18
+ "phase",
19
+ "revision",
20
+ "sessionId",
21
+ "version",
22
+ ];
23
+ const SESSION_KEYS = [
24
+ "endedAtMs",
25
+ "eventCount",
26
+ "id",
27
+ "lastSequence",
28
+ "startedAtMs",
29
+ "state",
30
+ ];
31
+ const BUNDLE_KEYS = ["assetBytes", "assetCount", "eventCount", "sessionId"];
32
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
33
+ const LOCK_MAX_BYTES = 1024;
34
+ const JOURNAL_HEAD_MAX_BYTES = 4096;
35
+ const JOURNAL_FORMAT = "devicerail.execution-recorder-journal";
36
+ const JOURNAL_VERSION = 1;
37
+ const JOURNAL_SIZE_MULTIPLIER = 4;
38
+ function isNodeError(error, code) {
39
+ return error instanceof Error && "code" in error && error.code === code;
40
+ }
41
+ async function pathEntryExists(path) {
42
+ try {
43
+ await lstat(path);
44
+ return true;
45
+ }
46
+ catch (cause) {
47
+ if (isNodeError(cause, "ENOENT")) {
48
+ return false;
49
+ }
50
+ throw new RecorderError("checkpoint_corrupt", "checkpoint sidecar metadata could not be read", {
51
+ cause,
52
+ });
53
+ }
54
+ }
55
+ function record(value, location) {
56
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
57
+ throw new RecorderError("checkpoint_corrupt", `${location} must be an object`);
58
+ }
59
+ return value;
60
+ }
61
+ function exactKeys(value, required, optional, location) {
62
+ const allowed = new Set([...required, ...optional]);
63
+ for (const key of required) {
64
+ if (!Object.hasOwn(value, key)) {
65
+ throw new RecorderError("checkpoint_corrupt", `${location} is missing ${key}`);
66
+ }
67
+ }
68
+ for (const key of Object.keys(value)) {
69
+ if (!allowed.has(key)) {
70
+ throw new RecorderError("checkpoint_corrupt", `${location} contains unknown field ${key}`);
71
+ }
72
+ }
73
+ }
74
+ function safeInteger(value, location, minimum = 0) {
75
+ if (!Number.isSafeInteger(value) || Object.is(value, -0) || value < minimum) {
76
+ throw new RecorderError("checkpoint_corrupt", `${location} must be a safe integer greater than or equal to ${minimum}`);
77
+ }
78
+ return value;
79
+ }
80
+ function maxBytes(options) {
81
+ const limit = safeInteger(options.maxBytes ?? RECORDER_CHECKPOINT_MAX_BYTES, "maxBytes", 1);
82
+ if (limit > RECORDER_CHECKPOINT_MAX_BYTES) {
83
+ throw new RecorderError("checkpoint_corrupt", `maxBytes cannot exceed ${RECORDER_CHECKPOINT_MAX_BYTES}`);
84
+ }
85
+ return limit;
86
+ }
87
+ function parseProtocolVersion(value) {
88
+ const candidate = record(value, "checkpoint.eventProtocolVersion");
89
+ exactKeys(candidate, ["major", "minor"], [], "checkpoint.eventProtocolVersion");
90
+ const major = safeInteger(candidate.major, "checkpoint.eventProtocolVersion.major");
91
+ const minor = safeInteger(candidate.minor, "checkpoint.eventProtocolVersion.minor");
92
+ if (major > 65_535 || minor > 65_535 || major !== 1 || minor > 5) {
93
+ throw new RecorderError("checkpoint_corrupt", "checkpoint event protocol version is unsupported");
94
+ }
95
+ return { major, minor };
96
+ }
97
+ function parseSession(value, sessionId, events) {
98
+ const candidate = record(value, "checkpoint.session");
99
+ exactKeys(candidate, SESSION_KEYS, [], "checkpoint.session");
100
+ if (candidate.id !== sessionId || candidate.state !== "ended") {
101
+ throw new RecorderError("checkpoint_corrupt", "checkpoint Session identity or state is inconsistent");
102
+ }
103
+ const startedAtMs = safeInteger(candidate.startedAtMs, "checkpoint.session.startedAtMs");
104
+ const endedAtMs = safeInteger(candidate.endedAtMs, "checkpoint.session.endedAtMs");
105
+ const eventCount = safeInteger(candidate.eventCount, "checkpoint.session.eventCount", 1);
106
+ const lastSequence = safeInteger(candidate.lastSequence, "checkpoint.session.lastSequence", 1);
107
+ if (events.length === 0 ||
108
+ eventCount !== events.length ||
109
+ lastSequence !== events.length ||
110
+ events[0]?.atMs !== startedAtMs ||
111
+ events.at(-1)?.atMs !== endedAtMs) {
112
+ throw new RecorderError("checkpoint_corrupt", "checkpoint ended Session metadata does not match its events");
113
+ }
114
+ return {
115
+ id: sessionId,
116
+ state: "ended",
117
+ startedAtMs,
118
+ endedAtMs,
119
+ eventCount,
120
+ lastSequence,
121
+ };
122
+ }
123
+ function parseBundle(value, sessionId, events) {
124
+ const candidate = record(value, "checkpoint.bundle");
125
+ exactKeys(candidate, BUNDLE_KEYS, [], "checkpoint.bundle");
126
+ const eventCount = safeInteger(candidate.eventCount, "checkpoint.bundle.eventCount", 1);
127
+ if (candidate.sessionId !== sessionId || eventCount !== events.length) {
128
+ throw new RecorderError("checkpoint_corrupt", "checkpoint Bundle receipt does not match its Session events");
129
+ }
130
+ return {
131
+ sessionId,
132
+ eventCount,
133
+ assetCount: safeInteger(candidate.assetCount, "checkpoint.bundle.assetCount"),
134
+ assetBytes: safeInteger(candidate.assetBytes, "checkpoint.bundle.assetBytes"),
135
+ };
136
+ }
137
+ /** Strictly validate and detach one in-memory checkpoint. */
138
+ export function validateRecorderCheckpoint(value) {
139
+ const candidate = record(value, "checkpoint");
140
+ const phase = candidate.phase;
141
+ if (phase !== "recording" && phase !== "sealed" && phase !== "completed") {
142
+ throw new RecorderError("checkpoint_corrupt", "checkpoint phase is invalid");
143
+ }
144
+ const optional = phase === "recording" ? [] : phase === "sealed" ? ["session"] : ["bundle", "session"];
145
+ exactKeys(candidate, CHECKPOINT_BASE_KEYS, optional, "checkpoint");
146
+ if (candidate.format !== RECORDER_CHECKPOINT_FORMAT ||
147
+ candidate.version !== RECORDER_CHECKPOINT_VERSION) {
148
+ throw new RecorderError("checkpoint_corrupt", "checkpoint format or version is unsupported");
149
+ }
150
+ const revision = safeInteger(candidate.revision, "checkpoint.revision", 1);
151
+ if (typeof candidate.sessionId !== "string" || !UUID.test(candidate.sessionId)) {
152
+ throw new RecorderError("checkpoint_corrupt", "checkpoint sessionId must be a UUID");
153
+ }
154
+ if (!Array.isArray(candidate.events)) {
155
+ throw new RecorderError("checkpoint_corrupt", "checkpoint events must be an array");
156
+ }
157
+ const protocol = parseProtocolVersion(candidate.eventProtocolVersion);
158
+ const eventLog = EventLog.replay(candidate.sessionId, candidate.events, protocol);
159
+ const events = eventLog.events;
160
+ const base = {
161
+ format: RECORDER_CHECKPOINT_FORMAT,
162
+ version: RECORDER_CHECKPOINT_VERSION,
163
+ revision,
164
+ sessionId: candidate.sessionId,
165
+ eventProtocolVersion: protocol,
166
+ events,
167
+ };
168
+ if (phase === "recording") {
169
+ return { ...base, phase };
170
+ }
171
+ if (!eventLog.terminal || eventLog.openActionCount !== 0) {
172
+ throw new RecorderError("checkpoint_corrupt", "sealed checkpoint must contain one closed terminal Session");
173
+ }
174
+ const session = parseSession(candidate.session, candidate.sessionId, events);
175
+ if (phase === "sealed") {
176
+ return { ...base, phase, session };
177
+ }
178
+ return {
179
+ ...base,
180
+ phase,
181
+ session,
182
+ bundle: parseBundle(candidate.bundle, candidate.sessionId, events),
183
+ };
184
+ }
185
+ function parseEnvelope(bytes, limit) {
186
+ let value;
187
+ try {
188
+ value = fromCanonicalJson(bytes, { maxBytes: limit });
189
+ }
190
+ catch (cause) {
191
+ throw new RecorderError("checkpoint_corrupt", "checkpoint JSON is not canonical", {
192
+ cause,
193
+ });
194
+ }
195
+ const envelope = record(value, "checkpoint envelope");
196
+ exactKeys(envelope, CHECKPOINT_ENVELOPE_KEYS, [], "checkpoint envelope");
197
+ if (typeof envelope.sha256 !== "string") {
198
+ throw new RecorderError("checkpoint_corrupt", "checkpoint checksum is invalid");
199
+ }
200
+ let payloadBytes;
201
+ try {
202
+ payloadBytes = toCanonicalJson(envelope.checkpoint, { maxBytes: limit });
203
+ }
204
+ catch (cause) {
205
+ throw new RecorderError("checkpoint_corrupt", "checkpoint payload is invalid", { cause });
206
+ }
207
+ if (!sha256Matches(payloadBytes, envelope.sha256)) {
208
+ throw new RecorderError("checkpoint_corrupt", "checkpoint checksum does not match");
209
+ }
210
+ return validateRecorderCheckpoint(envelope.checkpoint);
211
+ }
212
+ function serializeNormalizedEnvelope(normalized, limit) {
213
+ try {
214
+ return toCanonicalJsonChecksumEnvelope(normalized, { maxBytes: limit });
215
+ }
216
+ catch (cause) {
217
+ if (cause instanceof RecorderError) {
218
+ throw cause;
219
+ }
220
+ throw new RecorderError("checkpoint_corrupt", "checkpoint exceeds its encoding limits", {
221
+ cause,
222
+ });
223
+ }
224
+ }
225
+ function parseChecksummedValue(bytes, limit, location) {
226
+ let parsed;
227
+ try {
228
+ parsed = fromCanonicalJson(bytes, { maxBytes: limit });
229
+ }
230
+ catch (cause) {
231
+ throw new RecorderError("checkpoint_corrupt", `${location} JSON is not canonical`, { cause });
232
+ }
233
+ const envelope = record(parsed, `${location} envelope`);
234
+ exactKeys(envelope, CHECKPOINT_ENVELOPE_KEYS, [], `${location} envelope`);
235
+ if (typeof envelope.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(envelope.sha256)) {
236
+ throw new RecorderError("checkpoint_corrupt", `${location} checksum is invalid`);
237
+ }
238
+ let payloadBytes;
239
+ try {
240
+ payloadBytes = toCanonicalJson(envelope.checkpoint, { maxBytes: limit });
241
+ }
242
+ catch (cause) {
243
+ throw new RecorderError("checkpoint_corrupt", `${location} payload is invalid`, { cause });
244
+ }
245
+ if (!sha256Matches(payloadBytes, envelope.sha256)) {
246
+ throw new RecorderError("checkpoint_corrupt", `${location} checksum does not match`);
247
+ }
248
+ return { value: envelope.checkpoint, sha256: envelope.sha256 };
249
+ }
250
+ function journalHeadPath(path) {
251
+ return `${path}.journal-head`;
252
+ }
253
+ function journalPath(path) {
254
+ return `${path}.journal`;
255
+ }
256
+ function parseJournalHead(bytes) {
257
+ const { value } = parseChecksummedValue(bytes, JOURNAL_HEAD_MAX_BYTES, "checkpoint journal head");
258
+ const candidate = record(value, "checkpoint journal head");
259
+ exactKeys(candidate, [
260
+ "baseRevision",
261
+ "committedBytes",
262
+ "eventBytes",
263
+ "eventCount",
264
+ "eventProtocolVersion",
265
+ "format",
266
+ "lastSegmentLength",
267
+ "lastSegmentOffset",
268
+ "lastSha256",
269
+ "revision",
270
+ "sessionId",
271
+ "version",
272
+ ], [], "checkpoint journal head");
273
+ if (candidate.format !== JOURNAL_FORMAT
274
+ || candidate.version !== JOURNAL_VERSION
275
+ || typeof candidate.sessionId !== "string"
276
+ || !UUID.test(candidate.sessionId)
277
+ || typeof candidate.lastSha256 !== "string"
278
+ || !/^[0-9a-f]{64}$/u.test(candidate.lastSha256)) {
279
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal head identity is invalid");
280
+ }
281
+ const committedBytes = safeInteger(candidate.committedBytes, "journal committedBytes", 1);
282
+ const lastSegmentOffset = safeInteger(candidate.lastSegmentOffset, "journal lastSegmentOffset");
283
+ const lastSegmentLength = safeInteger(candidate.lastSegmentLength, "journal lastSegmentLength", 1);
284
+ if (lastSegmentOffset + lastSegmentLength !== committedBytes) {
285
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal head offsets are inconsistent");
286
+ }
287
+ return {
288
+ format: JOURNAL_FORMAT,
289
+ version: JOURNAL_VERSION,
290
+ baseRevision: safeInteger(candidate.baseRevision, "journal baseRevision", 1),
291
+ revision: safeInteger(candidate.revision, "journal revision", 2),
292
+ sessionId: candidate.sessionId,
293
+ eventProtocolVersion: parseProtocolVersion(candidate.eventProtocolVersion),
294
+ committedBytes,
295
+ lastSegmentOffset,
296
+ lastSegmentLength,
297
+ lastSha256: candidate.lastSha256,
298
+ eventCount: safeInteger(candidate.eventCount, "journal eventCount", 1),
299
+ eventBytes: safeInteger(candidate.eventBytes, "journal eventBytes", 1),
300
+ };
301
+ }
302
+ function parseJournalSegment(bytes, limit) {
303
+ const { value, sha256 } = parseChecksummedValue(bytes, limit, "checkpoint journal segment");
304
+ const candidate = record(value, "checkpoint journal segment");
305
+ exactKeys(candidate, [
306
+ "eventBytes",
307
+ "eventCount",
308
+ "events",
309
+ "firstSequence",
310
+ "format",
311
+ "previousSha256",
312
+ "revision",
313
+ "version",
314
+ ], [], "checkpoint journal segment");
315
+ if (candidate.format !== JOURNAL_FORMAT
316
+ || candidate.version !== JOURNAL_VERSION
317
+ || (candidate.previousSha256 !== null
318
+ && (typeof candidate.previousSha256 !== "string"
319
+ || !/^[0-9a-f]{64}$/u.test(candidate.previousSha256)))
320
+ || !Array.isArray(candidate.events)
321
+ || candidate.events.length === 0) {
322
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal segment is invalid");
323
+ }
324
+ return {
325
+ segment: {
326
+ format: JOURNAL_FORMAT,
327
+ version: JOURNAL_VERSION,
328
+ revision: safeInteger(candidate.revision, "journal segment revision", 2),
329
+ previousSha256: candidate.previousSha256,
330
+ firstSequence: safeInteger(candidate.firstSequence, "journal firstSequence", 1),
331
+ eventCount: safeInteger(candidate.eventCount, "journal eventCount", 1),
332
+ eventBytes: safeInteger(candidate.eventBytes, "journal eventBytes", 1),
333
+ events: candidate.events,
334
+ },
335
+ sha256,
336
+ };
337
+ }
338
+ function encodedEventsBytes(events) {
339
+ let total = 0;
340
+ for (const event of events) {
341
+ total += toCanonicalJson(event, { maxBytes: RECORDER_CHECKPOINT_MAX_BYTES }).length - 1;
342
+ }
343
+ return total;
344
+ }
345
+ function requireNotCancelled(signal) {
346
+ if (signal?.aborted) {
347
+ throw new RecorderError("operation_cancelled", "checkpoint operation was cancelled", {
348
+ details: { reason: signal.reason instanceof Error ? signal.reason.name : "aborted" },
349
+ });
350
+ }
351
+ }
352
+ async function requireRealParent(path) {
353
+ const parent = dirname(path);
354
+ const name = basename(path);
355
+ if (name.length === 0 || name === "." || name === "..") {
356
+ throw new RecorderError("checkpoint_corrupt", "checkpoint path has no safe file name");
357
+ }
358
+ let metadata;
359
+ try {
360
+ metadata = await lstat(parent);
361
+ }
362
+ catch (cause) {
363
+ throw new RecorderError("checkpoint_corrupt", "checkpoint parent does not exist", { cause });
364
+ }
365
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
366
+ throw new RecorderError("checkpoint_corrupt", "checkpoint parent must be a real directory");
367
+ }
368
+ return parent;
369
+ }
370
+ function requireOwnerOnly(metadata) {
371
+ if (process.platform === "win32") {
372
+ return;
373
+ }
374
+ if ((metadata.mode & 0o077) !== 0) {
375
+ throw new RecorderError("checkpoint_corrupt", "checkpoint file must be owner-only");
376
+ }
377
+ const effectiveUser = process.geteuid?.();
378
+ if (effectiveUser !== undefined && metadata.uid !== effectiveUser) {
379
+ throw new RecorderError("checkpoint_corrupt", "checkpoint file must be owned by this user");
380
+ }
381
+ }
382
+ async function readBoundedPrivateFile(path, limit) {
383
+ let pathMetadata;
384
+ try {
385
+ pathMetadata = await lstat(path);
386
+ }
387
+ catch (cause) {
388
+ if (isNodeError(cause, "ENOENT")) {
389
+ return null;
390
+ }
391
+ throw new RecorderError("checkpoint_corrupt", "checkpoint metadata could not be read", {
392
+ cause,
393
+ });
394
+ }
395
+ if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink()) {
396
+ throw new RecorderError("checkpoint_corrupt", "checkpoint must be a regular file");
397
+ }
398
+ requireOwnerOnly(pathMetadata);
399
+ if (pathMetadata.size > limit) {
400
+ throw new RecorderError("checkpoint_corrupt", `checkpoint exceeds its ${limit}-byte limit`);
401
+ }
402
+ let handle;
403
+ try {
404
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
405
+ }
406
+ catch (cause) {
407
+ throw new RecorderError("checkpoint_corrupt", "checkpoint could not be opened safely", {
408
+ cause,
409
+ });
410
+ }
411
+ try {
412
+ const opened = await handle.stat();
413
+ if (!opened.isFile() ||
414
+ opened.dev !== pathMetadata.dev ||
415
+ opened.ino !== pathMetadata.ino) {
416
+ throw new RecorderError("checkpoint_corrupt", "checkpoint changed while it was opened");
417
+ }
418
+ requireOwnerOnly(opened);
419
+ const bytes = Buffer.allocUnsafe(limit + 1);
420
+ let offset = 0;
421
+ while (offset < bytes.length) {
422
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null);
423
+ if (bytesRead === 0) {
424
+ break;
425
+ }
426
+ offset += bytesRead;
427
+ }
428
+ if (offset > limit) {
429
+ throw new RecorderError("checkpoint_corrupt", `checkpoint exceeds its ${limit}-byte limit`);
430
+ }
431
+ return bytes.subarray(0, offset);
432
+ }
433
+ finally {
434
+ await handle.close().catch(() => { });
435
+ }
436
+ }
437
+ async function readJournalHead(path) {
438
+ const bytes = await readBoundedPrivateFile(journalHeadPath(path), JOURNAL_HEAD_MAX_BYTES);
439
+ return bytes === null ? null : parseJournalHead(bytes);
440
+ }
441
+ function validateJournalIdentity(base, head) {
442
+ if (head.baseRevision !== base.revision
443
+ || head.sessionId !== base.sessionId
444
+ || !isDeepStrictEqual(head.eventProtocolVersion, base.eventProtocolVersion)
445
+ || head.revision <= base.revision
446
+ || head.eventCount <= base.events.length) {
447
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal identity is inconsistent");
448
+ }
449
+ }
450
+ async function loadCheckpointWithJournal(path, limit) {
451
+ const baseBytes = await readBoundedPrivateFile(path, limit);
452
+ if (baseBytes === null) {
453
+ return null;
454
+ }
455
+ const base = parseEnvelope(baseBytes, limit);
456
+ if (base.phase !== "recording") {
457
+ // A sealed/completed base is the compaction boundary. Journal sidecars
458
+ // from an interrupted best-effort cleanup are obsolete and untrusted.
459
+ return base;
460
+ }
461
+ const head = await readJournalHead(path);
462
+ if (head === null || head.revision <= base.revision) {
463
+ return base;
464
+ }
465
+ validateJournalIdentity(base, head);
466
+ const journalLimit = Math.min(Number.MAX_SAFE_INTEGER, limit * JOURNAL_SIZE_MULTIPLIER);
467
+ if (head.committedBytes > journalLimit) {
468
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal exceeds its size limit");
469
+ }
470
+ const journal = await readBoundedPrivateFile(journalPath(path), journalLimit);
471
+ if (journal === null || journal.length < head.committedBytes) {
472
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal is missing or truncated");
473
+ }
474
+ const log = EventLog.replay(base.sessionId, base.events, base.eventProtocolVersion);
475
+ let offset = 0;
476
+ let previousSha256 = null;
477
+ let expectedRevision = base.revision + 1;
478
+ let eventBytes = encodedEventsBytes(base.events);
479
+ while (offset < head.committedBytes) {
480
+ const newline = journal.indexOf(0x0a, offset);
481
+ if (newline < offset || newline >= head.committedBytes) {
482
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal record is truncated");
483
+ }
484
+ const end = newline + 1;
485
+ const { segment, sha256 } = parseJournalSegment(journal.subarray(offset, end), Math.min(limit, end - offset));
486
+ if (segment.revision !== expectedRevision
487
+ || segment.previousSha256 !== previousSha256
488
+ || segment.firstSequence !== log.nextSequence) {
489
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal chain is inconsistent");
490
+ }
491
+ const accepted = log.acceptBatch(segment.events);
492
+ eventBytes += encodedEventsBytes(segment.events);
493
+ if (accepted.accepted !== segment.events.length
494
+ || accepted.duplicates !== 0
495
+ || segment.eventCount !== (log.lastSequence ?? 0)
496
+ || segment.eventBytes !== eventBytes) {
497
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal counters are inconsistent");
498
+ }
499
+ previousSha256 = sha256;
500
+ expectedRevision += 1;
501
+ offset = end;
502
+ }
503
+ if (offset !== head.committedBytes
504
+ || head.lastSegmentOffset + head.lastSegmentLength !== offset
505
+ || head.lastSha256 !== previousSha256
506
+ || head.revision !== expectedRevision - 1
507
+ || head.eventCount !== (log.lastSequence ?? 0)
508
+ || head.eventBytes !== eventBytes) {
509
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal head does not match its records");
510
+ }
511
+ const logical = {
512
+ ...base,
513
+ revision: head.revision,
514
+ events: log.events,
515
+ };
516
+ // Preserve the existing exact snapshot bound at recovery and compaction.
517
+ serializeNormalizedEnvelope(logical, limit);
518
+ return logical;
519
+ }
520
+ function parseLockRecord(value) {
521
+ const candidate = record(value, "checkpoint lock");
522
+ exactKeys(candidate, ["pid", "token"], [], "checkpoint lock");
523
+ const pid = safeInteger(candidate.pid, "checkpoint lock pid", 1);
524
+ if (pid > 2_147_483_647 || typeof candidate.token !== "string" || !UUID.test(candidate.token)) {
525
+ throw new RecorderError("checkpoint_locked", "checkpoint lock owner is invalid");
526
+ }
527
+ return { pid, token: candidate.token };
528
+ }
529
+ async function readLockRecord(path) {
530
+ try {
531
+ const bytes = await readBoundedPrivateFile(path, LOCK_MAX_BYTES);
532
+ if (bytes === null) {
533
+ throw new RecorderError("checkpoint_locked", "checkpoint lock disappeared");
534
+ }
535
+ return parseLockRecord(fromCanonicalJson(bytes, { maxBytes: LOCK_MAX_BYTES }));
536
+ }
537
+ catch (cause) {
538
+ if (cause instanceof RecorderError && cause.code === "checkpoint_locked") {
539
+ throw cause;
540
+ }
541
+ throw new RecorderError("checkpoint_locked", "checkpoint lock is damaged and cannot be reclaimed automatically", { cause });
542
+ }
543
+ }
544
+ function processIsAlive(pid) {
545
+ try {
546
+ process.kill(pid, 0);
547
+ return true;
548
+ }
549
+ catch (cause) {
550
+ if (isNodeError(cause, "ESRCH")) {
551
+ return false;
552
+ }
553
+ // EPERM and every ambiguous platform failure are treated as a live owner.
554
+ return true;
555
+ }
556
+ }
557
+ async function acquireLock(path, signal) {
558
+ const lockPath = `${path}.lock`;
559
+ const parent = dirname(path);
560
+ for (let attempt = 0; attempt < 3; attempt += 1) {
561
+ requireNotCancelled(signal);
562
+ const token = randomUUID();
563
+ const temporary = join(parent, `.${basename(path)}.lock.${token}.tmp`);
564
+ const bytes = toCanonicalJson({ pid: process.pid, token }, { maxBytes: LOCK_MAX_BYTES });
565
+ let handle;
566
+ try {
567
+ handle = await open(temporary, "wx", 0o600);
568
+ await handle.writeFile(bytes);
569
+ await handle.sync();
570
+ await handle.close();
571
+ handle = undefined;
572
+ try {
573
+ await link(temporary, lockPath);
574
+ }
575
+ catch (cause) {
576
+ if (!isNodeError(cause, "EEXIST")) {
577
+ throw cause;
578
+ }
579
+ const owner = await readLockRecord(lockPath);
580
+ if (processIsAlive(owner.pid)) {
581
+ throw new RecorderError("checkpoint_locked", "checkpoint is locked by a live writer", {
582
+ details: { pid: owner.pid },
583
+ });
584
+ }
585
+ // No age heuristic is used: only an OS-confirmed ESRCH owner is stale.
586
+ await unlink(lockPath);
587
+ continue;
588
+ }
589
+ await unlink(temporary);
590
+ try {
591
+ await syncParent(parent);
592
+ }
593
+ catch (cause) {
594
+ await unlink(lockPath).catch(() => { });
595
+ throw new RecorderError("checkpoint_locked", "checkpoint lock durability is unknown", {
596
+ cause,
597
+ });
598
+ }
599
+ return { path: lockPath, token };
600
+ }
601
+ catch (cause) {
602
+ if (cause instanceof RecorderError) {
603
+ throw cause;
604
+ }
605
+ throw new RecorderError("checkpoint_corrupt", "checkpoint lock could not be published", {
606
+ cause,
607
+ });
608
+ }
609
+ finally {
610
+ await handle?.close().catch(() => { });
611
+ await unlink(temporary).catch(() => { });
612
+ }
613
+ }
614
+ throw new RecorderError("checkpoint_locked", "checkpoint stale-lock recovery did not converge");
615
+ }
616
+ async function releaseLock(lock) {
617
+ const current = await readLockRecord(lock.path);
618
+ if (current.pid !== process.pid || current.token !== lock.token) {
619
+ throw new RecorderError("checkpoint_locked", "checkpoint writer no longer owns its lock");
620
+ }
621
+ await unlink(lock.path);
622
+ }
623
+ async function syncParent(parent) {
624
+ // Node cannot portably open and fsync directory handles on Windows. The
625
+ // atomic rename still defines publication there, but crash-power durability
626
+ // is limited to the file flush and filesystem implementation.
627
+ if (process.platform === "win32") {
628
+ return;
629
+ }
630
+ const handle = await open(parent, "r");
631
+ try {
632
+ await handle.sync();
633
+ }
634
+ finally {
635
+ await handle.close().catch(() => { });
636
+ }
637
+ }
638
+ function validateTransition(current, next) {
639
+ if (current === null) {
640
+ if (next.phase !== "recording") {
641
+ throw new RecorderError("checkpoint_conflict", "the first durable checkpoint revision must be recording");
642
+ }
643
+ return;
644
+ }
645
+ if (current.sessionId !== next.sessionId ||
646
+ !isDeepStrictEqual(current.eventProtocolVersion, next.eventProtocolVersion)) {
647
+ throw new RecorderError("checkpoint_conflict", "checkpoint identity or event protocol version cannot change");
648
+ }
649
+ if (current.phase === "completed") {
650
+ throw new RecorderError("checkpoint_conflict", "completed checkpoint is immutable");
651
+ }
652
+ if (current.phase === "sealed" && next.phase === "sealed") {
653
+ throw new RecorderError("checkpoint_conflict", "sealed checkpoint cannot be sealed again");
654
+ }
655
+ const phaseOrder = { recording: 0, sealed: 1, completed: 2 };
656
+ const phaseDelta = phaseOrder[next.phase] - phaseOrder[current.phase];
657
+ if (phaseDelta < 0 || phaseDelta > 1) {
658
+ throw new RecorderError("checkpoint_conflict", "checkpoint phase must advance one step at a time");
659
+ }
660
+ if (current.events.length > next.events.length) {
661
+ throw new RecorderError("checkpoint_conflict", "checkpoint events cannot be removed");
662
+ }
663
+ for (let index = 0; index < current.events.length; index += 1) {
664
+ if (!isDeepStrictEqual(current.events[index], next.events[index])) {
665
+ throw new RecorderError("checkpoint_conflict", "confirmed checkpoint events cannot change");
666
+ }
667
+ }
668
+ if (current.phase === "sealed") {
669
+ if (next.events.length !== current.events.length ||
670
+ !isDeepStrictEqual(current.session, next.session)) {
671
+ throw new RecorderError("checkpoint_conflict", "sealed Session data is immutable");
672
+ }
673
+ }
674
+ }
675
+ /** Load a canonical checkpoint; Unix additionally enforces owner-only file metadata. */
676
+ export async function loadRecorderCheckpoint(path, options = {}) {
677
+ const limit = maxBytes(options);
678
+ return await loadCheckpointWithJournal(path, limit);
679
+ }
680
+ async function readPrivateRange(path, offset, length, sizeLimit) {
681
+ const metadata = await lstat(path).catch((cause) => {
682
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal metadata could not be read", {
683
+ cause,
684
+ });
685
+ });
686
+ if (!metadata.isFile()
687
+ || metadata.isSymbolicLink()
688
+ || metadata.size > sizeLimit
689
+ || offset + length > metadata.size) {
690
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal range is invalid");
691
+ }
692
+ requireOwnerOnly(metadata);
693
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
694
+ try {
695
+ const opened = await handle.stat();
696
+ if (opened.dev !== metadata.dev || opened.ino !== metadata.ino || !opened.isFile()) {
697
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal changed while opening");
698
+ }
699
+ requireOwnerOnly(opened);
700
+ const bytes = Buffer.allocUnsafe(length);
701
+ let read = 0;
702
+ while (read < length) {
703
+ const result = await handle.read(bytes, read, length - read, offset + read);
704
+ if (result.bytesRead === 0) {
705
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal range is truncated");
706
+ }
707
+ read += result.bytesRead;
708
+ }
709
+ return bytes;
710
+ }
711
+ finally {
712
+ await handle.close().catch(() => { });
713
+ }
714
+ }
715
+ async function openJournalForAppend(path) {
716
+ try {
717
+ const metadata = await lstat(path);
718
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
719
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal must be a regular file");
720
+ }
721
+ requireOwnerOnly(metadata);
722
+ const handle = await open(path, constants.O_RDWR | constants.O_NOFOLLOW);
723
+ const opened = await handle.stat();
724
+ if (opened.dev !== metadata.dev || opened.ino !== metadata.ino || !opened.isFile()) {
725
+ await handle.close().catch(() => { });
726
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal changed while opening");
727
+ }
728
+ requireOwnerOnly(opened);
729
+ return handle;
730
+ }
731
+ catch (cause) {
732
+ if (!isNodeError(cause, "ENOENT")) {
733
+ throw cause;
734
+ }
735
+ return await open(path, "wx+", 0o600);
736
+ }
737
+ }
738
+ async function writeAllAt(handle, bytes, offset) {
739
+ let written = 0;
740
+ while (written < bytes.length) {
741
+ const result = await handle.write(bytes, written, bytes.length - written, offset + written);
742
+ if (result.bytesWritten === 0) {
743
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal write made no progress");
744
+ }
745
+ written += result.bytesWritten;
746
+ }
747
+ }
748
+ /** @internal Append one already-validated Recorder page without rewriting its prefix. */
749
+ export async function appendRecorderCheckpointPage(path, expectedRevision, identity, events, options = {}) {
750
+ const expected = safeInteger(expectedRevision, "expectedRevision", 1);
751
+ if (expected === Number.MAX_SAFE_INTEGER || events.length === 0) {
752
+ throw new RecorderError("checkpoint_conflict", events.length === 0 ? "journal page must contain events" : "checkpoint revision is exhausted");
753
+ }
754
+ const limit = maxBytes(options);
755
+ const parent = await requireRealParent(path);
756
+ const lock = await acquireLock(path, options.signal);
757
+ const journal = journalPath(path);
758
+ const headPath = journalHeadPath(path);
759
+ const temporaryHead = join(parent, `.${basename(path)}.journal-head.${randomUUID()}.tmp`);
760
+ let headPublished = false;
761
+ let operationError;
762
+ let result;
763
+ try {
764
+ requireNotCancelled(options.signal);
765
+ const baseBytes = await readBoundedPrivateFile(path, limit);
766
+ if (baseBytes === null) {
767
+ throw new RecorderError("checkpoint_conflict", "checkpoint does not exist");
768
+ }
769
+ const base = parseEnvelope(baseBytes, limit);
770
+ if (base.phase !== "recording"
771
+ || base.sessionId !== identity.sessionId
772
+ || !isDeepStrictEqual(base.eventProtocolVersion, identity.eventProtocolVersion)) {
773
+ throw new RecorderError("checkpoint_conflict", "checkpoint recording identity changed");
774
+ }
775
+ let head = await readJournalHead(path);
776
+ if (head !== null && head.revision <= base.revision) {
777
+ head = null;
778
+ }
779
+ if (head !== null) {
780
+ validateJournalIdentity(base, head);
781
+ }
782
+ const currentRevision = head?.revision ?? base.revision;
783
+ const currentEventCount = head?.eventCount ?? base.events.length;
784
+ const currentEventBytes = head?.eventBytes ?? encodedEventsBytes(base.events);
785
+ const committedBytes = head?.committedBytes ?? 0;
786
+ const journalLimit = Math.min(Number.MAX_SAFE_INTEGER, limit * JOURNAL_SIZE_MULTIPLIER);
787
+ if (currentRevision !== expected) {
788
+ throw new RecorderError("checkpoint_conflict", `checkpoint revision is ${currentRevision}, expected ${expected}`, { details: { actualRevision: currentRevision, expectedRevision: expected } });
789
+ }
790
+ if (head !== null) {
791
+ const lastBytes = await readPrivateRange(journal, head.lastSegmentOffset, head.lastSegmentLength, journalLimit);
792
+ const last = parseJournalSegment(lastBytes, Math.min(limit, lastBytes.length));
793
+ if (last.sha256 !== head.lastSha256 || last.segment.revision !== head.revision) {
794
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal head is not bound to its tail");
795
+ }
796
+ }
797
+ for (const [index, event] of events.entries()) {
798
+ if (event.sessionId !== base.sessionId
799
+ || event.sequence !== currentEventCount + index + 1) {
800
+ throw new RecorderError("checkpoint_conflict", "journal page sequence or Session is invalid");
801
+ }
802
+ }
803
+ const eventBytes = currentEventBytes + encodedEventsBytes(events);
804
+ const eventCount = currentEventCount + events.length;
805
+ const emptyEnvelopeBytes = serializeNormalizedEnvelope({ ...base, revision: expected + 1, events: [] }, limit).length;
806
+ const projectedSnapshotBytes = emptyEnvelopeBytes + eventBytes + Math.max(0, eventCount - 1);
807
+ if (projectedSnapshotBytes > limit) {
808
+ throw new RecorderError("checkpoint_corrupt", "checkpoint exceeds its encoding limits");
809
+ }
810
+ const segment = {
811
+ format: JOURNAL_FORMAT,
812
+ version: JOURNAL_VERSION,
813
+ revision: expected + 1,
814
+ previousSha256: head?.lastSha256 ?? null,
815
+ firstSequence: currentEventCount + 1,
816
+ eventCount,
817
+ eventBytes,
818
+ events,
819
+ };
820
+ const segmentBytes = toCanonicalJsonChecksumEnvelope(segment, { maxBytes: limit });
821
+ if (committedBytes + segmentBytes.length > journalLimit) {
822
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal exceeds its size limit");
823
+ }
824
+ const segmentSha256 = sha256Hex(toCanonicalJson(segment, { maxBytes: limit }));
825
+ requireNotCancelled(options.signal);
826
+ const journalHandle = await openJournalForAppend(journal);
827
+ try {
828
+ const metadata = await journalHandle.stat();
829
+ if (metadata.size < committedBytes || metadata.size > journalLimit) {
830
+ throw new RecorderError("checkpoint_corrupt", "checkpoint journal size is inconsistent");
831
+ }
832
+ if (metadata.size !== committedBytes) {
833
+ await journalHandle.truncate(committedBytes);
834
+ }
835
+ await writeAllAt(journalHandle, segmentBytes, committedBytes);
836
+ await journalHandle.sync();
837
+ }
838
+ finally {
839
+ await journalHandle.close().catch(() => { });
840
+ }
841
+ const nextHead = {
842
+ format: JOURNAL_FORMAT,
843
+ version: JOURNAL_VERSION,
844
+ baseRevision: base.revision,
845
+ revision: expected + 1,
846
+ sessionId: base.sessionId,
847
+ eventProtocolVersion: base.eventProtocolVersion,
848
+ committedBytes: committedBytes + segmentBytes.length,
849
+ lastSegmentOffset: committedBytes,
850
+ lastSegmentLength: segmentBytes.length,
851
+ lastSha256: segmentSha256,
852
+ eventCount,
853
+ eventBytes,
854
+ };
855
+ const headBytes = toCanonicalJsonChecksumEnvelope(nextHead, {
856
+ maxBytes: JOURNAL_HEAD_MAX_BYTES,
857
+ });
858
+ requireNotCancelled(options.signal);
859
+ const headHandle = await open(temporaryHead, "wx", 0o600);
860
+ try {
861
+ await headHandle.writeFile(headBytes);
862
+ await headHandle.sync();
863
+ }
864
+ finally {
865
+ await headHandle.close().catch(() => { });
866
+ }
867
+ requireNotCancelled(options.signal);
868
+ await rename(temporaryHead, headPath);
869
+ headPublished = true;
870
+ try {
871
+ await syncParent(parent);
872
+ }
873
+ catch (cause) {
874
+ throw new RecorderError("checkpoint_durability_unknown", "checkpoint journal head was published but directory durability is unknown", { cause, details: { committed: true, revision: nextHead.revision } });
875
+ }
876
+ result = Object.freeze({ revision: nextHead.revision, eventCount });
877
+ }
878
+ catch (cause) {
879
+ operationError = cause instanceof RecorderError
880
+ ? cause
881
+ : new RecorderError(headPublished ? "checkpoint_durability_unknown" : "checkpoint_corrupt", headPublished
882
+ ? "checkpoint journal was published but final durability is unknown"
883
+ : "checkpoint journal append failed before publication", { cause });
884
+ }
885
+ await unlink(temporaryHead).catch(() => { });
886
+ try {
887
+ await releaseLock(lock);
888
+ }
889
+ catch (cause) {
890
+ operationError ??= new RecorderError(headPublished ? "checkpoint_durability_unknown" : "checkpoint_locked", "checkpoint journal writer lock could not be released", { cause });
891
+ }
892
+ if (operationError !== undefined) {
893
+ throw operationError;
894
+ }
895
+ return result;
896
+ }
897
+ /**
898
+ * CAS-commit one checkpoint revision through a same-directory atomic replace.
899
+ *
900
+ * `expectedRevision === 0` means the target must not exist and `next.revision`
901
+ * must be 1. The rename is the publication linearization point; cancellation
902
+ * is no longer observed after it.
903
+ */
904
+ export async function commitRecorderCheckpoint(path, expectedRevision, next, options = {}) {
905
+ const expected = safeInteger(expectedRevision, "expectedRevision");
906
+ if (expected === Number.MAX_SAFE_INTEGER) {
907
+ throw new RecorderError("checkpoint_conflict", "checkpoint revision is exhausted");
908
+ }
909
+ const normalized = validateRecorderCheckpoint(next);
910
+ if (normalized.revision !== expected + 1) {
911
+ throw new RecorderError("checkpoint_conflict", "next checkpoint revision must equal expectedRevision + 1");
912
+ }
913
+ const limit = maxBytes(options);
914
+ const bytes = serializeNormalizedEnvelope(normalized, limit);
915
+ const parent = await requireRealParent(path);
916
+ const lock = await acquireLock(path, options.signal);
917
+ const temporary = join(parent, `.${basename(path)}.${randomUUID()}.tmp`);
918
+ let published = false;
919
+ let operationError;
920
+ try {
921
+ requireNotCancelled(options.signal);
922
+ const current = await loadCheckpointWithJournal(path, limit);
923
+ if (current === null
924
+ && (await pathEntryExists(journalHeadPath(path))
925
+ || await pathEntryExists(journalPath(path)))) {
926
+ throw new RecorderError("checkpoint_corrupt", "checkpoint sidecars exist without their base checkpoint");
927
+ }
928
+ const currentRevision = current?.revision ?? 0;
929
+ if (currentRevision !== expected) {
930
+ throw new RecorderError("checkpoint_conflict", `checkpoint revision is ${currentRevision}, expected ${expected}`, { details: { actualRevision: currentRevision, expectedRevision: expected } });
931
+ }
932
+ validateTransition(current, normalized);
933
+ requireNotCancelled(options.signal);
934
+ const temporaryHandle = await open(temporary, "wx", 0o600);
935
+ try {
936
+ await temporaryHandle.writeFile(bytes);
937
+ await temporaryHandle.sync();
938
+ }
939
+ finally {
940
+ await temporaryHandle.close().catch(() => { });
941
+ }
942
+ requireNotCancelled(options.signal);
943
+ await rename(temporary, path);
944
+ published = true;
945
+ try {
946
+ await syncParent(parent);
947
+ }
948
+ catch (cause) {
949
+ throw new RecorderError("checkpoint_durability_unknown", "checkpoint was published but parent-directory durability is unknown", { cause, details: { committed: true, revision: normalized.revision } });
950
+ }
951
+ // The full snapshot now supersedes every sidecar revision. Cleanup is
952
+ // intentionally best-effort: the checkpoint is already durable and loads
953
+ // ignore obsolete sidecars at or below its revision.
954
+ const removedHead = expected > 0 && await unlink(journalHeadPath(path)).then(() => true, () => false);
955
+ const removedJournal = expected > 0 && await unlink(journalPath(path)).then(() => true, () => false);
956
+ if (removedHead || removedJournal) {
957
+ await syncParent(parent).catch(() => { });
958
+ }
959
+ }
960
+ catch (cause) {
961
+ operationError =
962
+ cause instanceof RecorderError
963
+ ? cause
964
+ : new RecorderError(published ? "checkpoint_durability_unknown" : "checkpoint_corrupt", published
965
+ ? "checkpoint was published but final durability is unknown"
966
+ : "checkpoint commit failed before publication", {
967
+ cause,
968
+ ...(published
969
+ ? { details: { committed: true, revision: normalized.revision } }
970
+ : {}),
971
+ });
972
+ }
973
+ if (!published) {
974
+ await unlink(temporary).catch(() => { });
975
+ }
976
+ try {
977
+ await releaseLock(lock);
978
+ }
979
+ catch (cause) {
980
+ operationError ??= new RecorderError(published ? "checkpoint_durability_unknown" : "checkpoint_locked", published
981
+ ? "checkpoint was published but its writer lock could not be released"
982
+ : "checkpoint writer lock could not be released", {
983
+ cause,
984
+ ...(published
985
+ ? { details: { committed: true, revision: normalized.revision } }
986
+ : {}),
987
+ });
988
+ }
989
+ if (operationError !== undefined) {
990
+ throw operationError;
991
+ }
992
+ return normalized;
993
+ }