@smallpen/local-package 0.1.0-alpha.2 → 0.1.0-alpha.5

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.
@@ -1,19 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
3
  import {
4
- cp,
4
+ copyFile,
5
+ link,
5
6
  lstat,
6
7
  mkdir,
7
8
  mkdtemp,
9
+ open,
8
10
  readdir,
9
- readFile,
11
+ readlink,
10
12
  realpath,
11
13
  rename,
12
14
  rm,
13
15
  stat,
16
+ symlink,
17
+ utimes,
14
18
  writeFile,
15
19
  } from "node:fs/promises";
16
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
20
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
17
21
  import { setTimeout as delay } from "node:timers/promises";
18
22
  import { promisify } from "node:util";
19
23
 
@@ -27,14 +31,33 @@ import {
27
31
  SmallPenError,
28
32
  } from "@smallpen/core";
29
33
 
30
- function isWithinRoot(rootPath, candidatePath) {
31
- const child = relative(rootPath, candidatePath);
32
- return child === "" || (!child.startsWith("..") && !isAbsolute(child));
33
- }
34
+ import {
35
+ isWithinRoot,
36
+ pathExists,
37
+ readRegularFile,
38
+ syncDirectory,
39
+ writeFileDurable,
40
+ } from "./fs-utils.mjs";
34
41
 
35
42
  const execFileAsync = promisify(execFile);
36
43
 
44
+ // Writers and waiters refresh their claim file while it exists. When a claim
45
+ // owner's PID is alive but its identity cannot be verified (Windows has no
46
+ // `ps`, or the claim carries no identity), a claim that stopped refreshing
47
+ // belongs to a dead process whose PID was reused.
48
+ const CLAIM_HEARTBEAT_MS = 2000;
49
+ const UNVERIFIED_CLAIM_STALE_MS = 10000;
50
+ const MAX_BATCH_LEDGER_ENTRIES = 500;
51
+ // Side files next to a Package are read whole; a huge or endless file planted
52
+ // there must fail fast instead of exhausting memory. The ledger keeps at most
53
+ // MAX_BATCH_LEDGER_ENTRIES small records; journals and claims are a few paths.
54
+ const MAX_BATCH_LEDGER_BYTES = 32 * 1024 * 1024;
55
+ const MAX_CONTROL_FILE_BYTES = 64 * 1024;
56
+ // Errors that mean this process cannot create the lock next to the Package.
57
+ const UNWRITABLE_LOCATION_CODES = new Set(["EACCES", "EPERM", "EROFS"]);
58
+
37
59
  async function processIdentity(pid) {
60
+ if (process.platform === "win32") return undefined;
38
61
  try {
39
62
  const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
40
63
  const value = stdout.trim();
@@ -44,18 +67,17 @@ async function processIdentity(pid) {
44
67
  }
45
68
  }
46
69
 
47
- function commitJournalPath(packagePath) {
48
- return join(dirname(packagePath), `.${basename(packagePath)}.commit.json`);
70
+ let ownProcessIdentity;
71
+
72
+ // The identity of this process never changes. Resolve it once instead of
73
+ // spawning `ps` on every lock acquisition (every Package read takes the lock).
74
+ function currentProcessIdentity() {
75
+ ownProcessIdentity ??= processIdentity(process.pid);
76
+ return ownProcessIdentity;
49
77
  }
50
78
 
51
- async function pathExists(path) {
52
- try {
53
- await lstat(path);
54
- return true;
55
- } catch (error) {
56
- if (error?.code === "ENOENT") return false;
57
- throw error;
58
- }
79
+ function commitJournalPath(packagePath) {
80
+ return join(dirname(packagePath), `.${basename(packagePath)}.commit.json`);
59
81
  }
60
82
 
61
83
  function validatedCommitJournal(packagePath, journal) {
@@ -68,7 +90,11 @@ function validatedCommitJournal(packagePath, journal) {
68
90
  dirname(backupPath) !== parent ||
69
91
  !basename(backupPath).startsWith(`.${packageName}.backup-`) ||
70
92
  dirname(transactionPath) !== parent ||
71
- !basename(transactionPath).startsWith(".smallpen-transaction-") ||
93
+ !(
94
+ basename(transactionPath).startsWith(`.${packageName}.transaction-`) ||
95
+ // Journals written before transactions carried the Package name.
96
+ basename(transactionPath).startsWith(".smallpen-transaction-")
97
+ ) ||
72
98
  candidatePath !== join(transactionPath, "candidate.smallpen")
73
99
  ) {
74
100
  fail("invalid_commit_journal", "Package commit journal is invalid", {
@@ -82,7 +108,11 @@ async function recoverInterruptedCommit(packagePath) {
82
108
  const journalPath = commitJournalPath(packagePath);
83
109
  let journal;
84
110
  try {
85
- journal = JSON.parse(await readFile(journalPath, "utf8"));
111
+ journal = JSON.parse(
112
+ await readRegularFile(journalPath, "utf8", {
113
+ maxBytes: MAX_CONTROL_FILE_BYTES,
114
+ }),
115
+ );
86
116
  } catch (error) {
87
117
  if (error?.code === "ENOENT") return;
88
118
  if (await pathExists(packagePath)) {
@@ -119,6 +149,32 @@ async function recoverInterruptedCommit(packagePath) {
119
149
  await cleanup([backupPath, transactionPath, journalPath]);
120
150
  }
121
151
 
152
+ function escapeRegExp(value) {
153
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
154
+ }
155
+
156
+ // A writer killed mid-commit leaves its transaction, backup, and temporary
157
+ // journal or ledger files next to the Package. Only the holder of the write
158
+ // lock creates them, so while it is held and no commit journal remains, any
159
+ // that exist are abandoned.
160
+ async function removeAbandonedCommitFiles(packagePath) {
161
+ const parent = dirname(packagePath);
162
+ const name = escapeRegExp(basename(packagePath));
163
+ const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
164
+ const abandoned = new RegExp(
165
+ `^(?:\\.${name}\\.transaction-[A-Za-z0-9]{6}|\\.${name}\\.backup-${uuid}|\\.${name}\\.commit\\.json\\.${uuid}\\.tmp|${name}\\.batches\\.json\\.${uuid}\\.tmp)$`,
166
+ );
167
+ let names;
168
+ try {
169
+ names = await readdir(parent);
170
+ } catch {
171
+ return;
172
+ }
173
+ await cleanup(
174
+ names.filter((entry) => abandoned.test(entry)).map((entry) => join(parent, entry)),
175
+ );
176
+ }
177
+
122
178
  async function resolvePackageRoot(locator) {
123
179
  let packagePath;
124
180
  try {
@@ -165,12 +221,23 @@ async function resolveEntry(packagePath, entry) {
165
221
  `Canonical entry resolves outside the Package: ${entry}`,
166
222
  );
167
223
  }
224
+ // Case- and normalization-insensitive file systems (macOS, Windows) resolve
225
+ // an entry that differs from the file name only in case or Unicode form; a
226
+ // case-sensitive system (Linux, CI) would not. Require the exact name.
227
+ const onDisk = relative(packagePath, entryPath).split(sep).join("/");
228
+ if (onDisk !== entry) {
229
+ fail(
230
+ "invalid_entry_path",
231
+ `Canonical entry ${entry} does not match the file name ${onDisk}`,
232
+ { entry, onDisk },
233
+ );
234
+ }
168
235
  return entryPath;
169
236
  }
170
237
 
171
238
  async function readJson(path, code, details) {
172
239
  try {
173
- return JSON.parse(await readFile(path, "utf8"));
240
+ return JSON.parse(await readRegularFile(path, "utf8"));
174
241
  } catch (error) {
175
242
  if (error instanceof SmallPenError) throw error;
176
243
  fail(code, `Unable to read ${basename(path)}: ${error.message}`, details);
@@ -217,7 +284,10 @@ async function readPackageValues(packagePath) {
217
284
  );
218
285
  }
219
286
  const path = `blobs/${entry.name}`;
220
- values.set(path, await readFile(await resolveEntry(packagePath, path)));
287
+ values.set(
288
+ path,
289
+ await readRegularFile(await resolveEntry(packagePath, path)),
290
+ );
221
291
  }),
222
292
  );
223
293
  return values;
@@ -237,35 +307,45 @@ async function cleanup(paths) {
237
307
  );
238
308
  }
239
309
 
310
+ async function claimOlderThan(claimPath, milliseconds) {
311
+ let info;
312
+ try {
313
+ info = await lstat(claimPath);
314
+ } catch (error) {
315
+ if (error?.code === "ENOENT") return true;
316
+ throw error;
317
+ }
318
+ return Date.now() - info.mtimeMs > milliseconds;
319
+ }
320
+
240
321
  async function abandonedWriteClaim(claimPath) {
241
322
  let owner;
242
323
  try {
243
- owner = JSON.parse(await readFile(claimPath, "utf8"));
324
+ owner = JSON.parse(
325
+ await readRegularFile(claimPath, "utf8", {
326
+ maxBytes: MAX_CONTROL_FILE_BYTES,
327
+ }),
328
+ );
244
329
  } catch {
245
- let info;
246
- try {
247
- info = await lstat(claimPath);
248
- } catch (error) {
249
- if (error?.code === "ENOENT") return true;
250
- throw error;
251
- }
252
- return Date.now() - info.mtimeMs > 1000;
330
+ return claimOlderThan(claimPath, 1000);
253
331
  }
254
332
  if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 0) {
255
- const info = await lstat(claimPath);
256
- return Date.now() - info.mtimeMs > 1000;
333
+ return claimOlderThan(claimPath, 1000);
257
334
  }
258
335
  try {
259
336
  process.kill(owner.pid, 0);
260
- const currentIdentity = await processIdentity(owner.pid);
261
- return Boolean(
262
- owner.processIdentity &&
263
- currentIdentity &&
264
- owner.processIdentity !== currentIdentity
265
- );
266
337
  } catch (error) {
267
- return error?.code === "ESRCH";
338
+ if (error?.code === "ESRCH") return true;
339
+ // EPERM: the PID is alive but owned by another user; verify it below.
268
340
  }
341
+ if (owner.processIdentity) {
342
+ const currentIdentity =
343
+ owner.pid === process.pid
344
+ ? await currentProcessIdentity()
345
+ : await processIdentity(owner.pid);
346
+ if (currentIdentity) return owner.processIdentity !== currentIdentity;
347
+ }
348
+ return claimOlderThan(claimPath, UNVERIFIED_CLAIM_STALE_MS);
269
349
  }
270
350
 
271
351
  function orderedWriteClaims(names) {
@@ -274,13 +354,17 @@ function orderedWriteClaims(names) {
274
354
  .sort((left, right) => {
275
355
  if (left === "owner.json") return -1;
276
356
  if (right === "owner.json") return 1;
277
- return left.localeCompare(right);
357
+ return left < right ? -1 : left > right ? 1 : 0;
278
358
  });
279
359
  }
280
360
 
281
361
  async function activeWriteClaim(activePath) {
282
362
  try {
283
- const value = JSON.parse(await readFile(activePath, "utf8"));
363
+ const value = JSON.parse(
364
+ await readRegularFile(activePath, "utf8", {
365
+ maxBytes: MAX_CONTROL_FILE_BYTES,
366
+ }),
367
+ );
284
368
  return typeof value?.claimName === "string" ? value.claimName : undefined;
285
369
  } catch (error) {
286
370
  if (error?.code === "ENOENT") return undefined;
@@ -301,24 +385,60 @@ async function releaseWriteClaim(activePath, claimName, claimPath) {
301
385
  }
302
386
 
303
387
  async function acquireWriteLock(packagePath) {
388
+ // Library and Foundation paths come from Package manifests: never create a
389
+ // lock directory beside a path that cannot be a Package.
390
+ if (!packagePath.endsWith(".smallpen")) {
391
+ fail(
392
+ "invalid_package_path",
393
+ "SmallPen Package must be a directory with the .smallpen extension",
394
+ { packagePath },
395
+ );
396
+ }
304
397
  const lockPath = join(
305
398
  dirname(packagePath),
306
399
  `.${basename(packagePath)}.write-lock`,
307
400
  );
308
- const ownerProcessIdentity = await processIdentity(process.pid);
309
- await mkdir(lockPath, { recursive: true });
401
+ const ownerProcessIdentity = await currentProcessIdentity();
310
402
  const claimName = `${String(Date.now()).padStart(16, "0")}-${randomUUID()}.json`;
311
403
  const claimPath = join(lockPath, claimName);
312
404
  const activePath = join(lockPath, "active");
313
- await writeFile(
314
- claimPath,
315
- JSON.stringify({
316
- createdAt: new Date().toISOString(),
317
- pid: process.pid,
318
- processIdentity: ownerProcessIdentity,
319
- }),
320
- { flag: "wx" },
321
- );
405
+ try {
406
+ await mkdir(lockPath, { recursive: true });
407
+ // Claims are read, written and removed by name inside the lock directory:
408
+ // a symlink there (an archive can carry one) would aim that at any folder.
409
+ if ((await lstat(lockPath)).isSymbolicLink()) {
410
+ fail(
411
+ "invalid_write_lock",
412
+ "Package write lock must be a directory, not a symlink",
413
+ { lockPath, packagePath },
414
+ );
415
+ }
416
+ await writeFile(
417
+ claimPath,
418
+ JSON.stringify({
419
+ createdAt: new Date().toISOString(),
420
+ pid: process.pid,
421
+ processIdentity: ownerProcessIdentity,
422
+ }),
423
+ { flag: "wx" },
424
+ );
425
+ } catch (error) {
426
+ if (!UNWRITABLE_LOCATION_CODES.has(error?.code)) throw error;
427
+ fail(
428
+ "package_not_writable",
429
+ `Package location is not writable: ${error.message}`,
430
+ { lockPath, packagePath },
431
+ );
432
+ }
433
+ const heartbeat = setInterval(() => {
434
+ const now = new Date();
435
+ utimes(claimPath, now, now).catch(() => {});
436
+ }, CLAIM_HEARTBEAT_MS);
437
+ heartbeat.unref?.();
438
+ const release = () => {
439
+ clearInterval(heartbeat);
440
+ return releaseWriteClaim(activePath, claimName, claimPath);
441
+ };
322
442
  const deadline = Date.now() + 30000;
323
443
  try {
324
444
  while (true) {
@@ -333,11 +453,7 @@ async function acquireWriteLock(packagePath) {
333
453
  if (activeClaims[0] === claimName) {
334
454
  try {
335
455
  await writeFile(activePath, JSON.stringify({ claimName }), { flag: "wx" });
336
- return () => releaseWriteClaim(
337
- activePath,
338
- claimName,
339
- claimPath,
340
- );
456
+ return release;
341
457
  } catch (error) {
342
458
  if (error?.code !== "EEXIST") throw error;
343
459
  const activeClaimName = await activeWriteClaim(activePath);
@@ -363,7 +479,7 @@ async function acquireWriteLock(packagePath) {
363
479
  await delay(10);
364
480
  }
365
481
  } catch (error) {
366
- await releaseWriteClaim(activePath, claimName, claimPath);
482
+ await release();
367
483
  throw error;
368
484
  }
369
485
  }
@@ -380,7 +496,28 @@ async function lockTarget(locator) {
380
496
 
381
497
  export async function openPackage(locator) {
382
498
  const target = await lockTarget(locator);
383
- const release = await acquireWriteLock(target);
499
+ // A missing Package without a commit journal fails without creating a
500
+ // lock directory (or its missing parents) for a mistyped path.
501
+ if (
502
+ !(await pathExists(target)) &&
503
+ !(await pathExists(commitJournalPath(target)))
504
+ ) {
505
+ return openPackageUnlocked(target);
506
+ }
507
+ let release;
508
+ try {
509
+ release = await acquireWriteLock(target);
510
+ } catch (error) {
511
+ // This process cannot write here, so it cannot commit here either: read
512
+ // the Package as is. An interrupted commit still needs a writable owner.
513
+ if (
514
+ error?.code !== "package_not_writable" ||
515
+ (await pathExists(commitJournalPath(target)))
516
+ ) {
517
+ throw error;
518
+ }
519
+ return openPackageUnlocked(target);
520
+ }
384
521
  try {
385
522
  await recoverInterruptedCommit(target);
386
523
  return await openPackageUnlocked(target);
@@ -389,6 +526,75 @@ export async function openPackage(locator) {
389
526
  }
390
527
  }
391
528
 
529
+ async function copyFileDurable(source, target) {
530
+ await copyFile(source, target);
531
+ const handle = await open(target, "r+");
532
+ try {
533
+ await handle.sync();
534
+ } finally {
535
+ await handle.close();
536
+ }
537
+ }
538
+
539
+ // Builds the candidate Package from hard links to the live files instead of
540
+ // copying every byte. A linked file already sits on stable storage, so only
541
+ // new directory entries need a sync. This is safe with the commit journal:
542
+ // commits never write through a link (changed files are unlinked and written
543
+ // as new inodes), recovery only renames whole directories, and removing the
544
+ // backup or transaction directory only drops one link to each shared inode.
545
+ // File systems without hard links fall back to a synced copy.
546
+ async function linkPackageTree(source, target, directories) {
547
+ await mkdir(target);
548
+ directories.add(target);
549
+ for (const entry of await readdir(source, { withFileTypes: true })) {
550
+ const from = join(source, entry.name);
551
+ const to = join(target, entry.name);
552
+ if (entry.isDirectory()) {
553
+ await linkPackageTree(from, to, directories);
554
+ } else if (entry.isSymbolicLink()) {
555
+ await symlink(await readlink(from), to);
556
+ } else if (entry.isFile()) {
557
+ try {
558
+ await link(from, to);
559
+ } catch (error) {
560
+ if (
561
+ !["EPERM", "EXDEV", "ENOTSUP", "EMLINK", "EACCES", "ENOSYS"].includes(
562
+ error?.code,
563
+ )
564
+ ) {
565
+ throw error;
566
+ }
567
+ await copyFileDurable(from, to);
568
+ }
569
+ }
570
+ }
571
+ }
572
+
573
+ // Stray symlinks in a Package are carried into the candidate unchanged, so a
574
+ // write must never pass through one: a symlinked directory would make the
575
+ // commit delete and write files outside the Package before validation
576
+ // rejects the candidate.
577
+ async function assertNoSymlinkAncestors(candidatePath, entry) {
578
+ let current = candidatePath;
579
+ for (const segment of entry.split("/").slice(0, -1)) {
580
+ current = join(current, segment);
581
+ let info;
582
+ try {
583
+ info = await lstat(current);
584
+ } catch (error) {
585
+ if (error?.code === "ENOENT") return;
586
+ throw error;
587
+ }
588
+ if (info.isSymbolicLink() || !info.isDirectory()) {
589
+ fail(
590
+ "invalid_entry_path",
591
+ `Package entry passes through a symlink or file: ${entry}`,
592
+ { entry },
593
+ );
594
+ }
595
+ }
596
+ }
597
+
392
598
  async function writeCandidate(
393
599
  originalPath,
394
600
  snapshot,
@@ -397,34 +603,51 @@ async function writeCandidate(
397
603
  blobWrites = new Map(),
398
604
  ) {
399
605
  const transactionPath = await mkdtemp(
400
- join(dirname(originalPath), ".smallpen-transaction-"),
606
+ join(dirname(originalPath), `.${basename(originalPath)}.transaction-`),
401
607
  );
402
608
  const candidatePath = join(transactionPath, "candidate.smallpen");
403
609
  try {
404
- await cp(originalPath, candidatePath, { recursive: true });
610
+ const directories = new Set([transactionPath]);
611
+ await linkPackageTree(originalPath, candidatePath, directories);
612
+ // Changed files and blobs are always written as new inodes: unlink the
613
+ // shared hard link first so the live Package is never modified in place.
614
+ const replace = async (target, data, encoding) => {
615
+ await rm(target, { force: true });
616
+ await mkdir(dirname(target), { recursive: true });
617
+ directories.add(dirname(target));
618
+ await writeFileDurable(target, data, encoding);
619
+ };
405
620
  for (const entry of changedFiles) {
406
- const target = join(candidatePath, entry);
621
+ const target = resolve(candidatePath, entry);
622
+ if (!isWithinRoot(candidatePath, target)) {
623
+ fail("invalid_entry_path", `Changed entry escapes the Package: ${entry}`);
624
+ }
625
+ await assertNoSymlinkAncestors(candidatePath, entry);
407
626
  const value =
408
627
  entry === "manifest.json"
409
628
  ? snapshot.manifest
410
629
  : snapshot.entries[entry];
411
- await mkdir(dirname(target), { recursive: true });
412
- await writeFile(target, canonicalJSON(value), "utf8");
630
+ await replace(target, canonicalJSON(value), "utf8");
413
631
  }
414
632
  for (const entry of deletedFiles) {
415
633
  const target = resolve(candidatePath, entry);
416
634
  if (!isWithinRoot(candidatePath, target)) {
417
635
  fail("invalid_entry_path", `Deleted entry escapes the Package: ${entry}`);
418
636
  }
637
+ await assertNoSymlinkAncestors(candidatePath, entry);
419
638
  await rm(target, { force: true });
639
+ directories.add(dirname(target));
420
640
  }
421
641
  for (const [entry, bytes] of blobWrites) {
422
642
  const target = resolve(candidatePath, entry);
423
643
  if (!isWithinRoot(candidatePath, target)) {
424
644
  fail("invalid_entry_path", `Media blob escapes the Package: ${entry}`);
425
645
  }
426
- await mkdir(dirname(target), { recursive: true });
427
- await writeFile(target, bytes);
646
+ await assertNoSymlinkAncestors(candidatePath, entry);
647
+ await replace(target, bytes);
648
+ }
649
+ for (const directory of directories) {
650
+ if (await pathExists(directory)) await syncDirectory(directory);
428
651
  }
429
652
  return { candidatePath, transactionPath };
430
653
  } catch (error) {
@@ -440,13 +663,14 @@ async function commitCandidate(originalPath, candidatePath, transactionPath) {
440
663
  );
441
664
  const journalPath = commitJournalPath(originalPath);
442
665
  const journalTemporary = `${journalPath}.${randomUUID()}.tmp`;
666
+ const parent = dirname(originalPath);
443
667
  try {
444
- await writeFile(
668
+ await writeFileDurable(
445
669
  journalTemporary,
446
670
  JSON.stringify({ backupPath, candidatePath, transactionPath }),
447
- { flag: "wx" },
448
671
  );
449
672
  await rename(journalTemporary, journalPath);
673
+ await syncDirectory(parent);
450
674
  } catch (error) {
451
675
  await cleanup([journalTemporary]);
452
676
  throw error;
@@ -463,6 +687,8 @@ async function commitCandidate(originalPath, candidatePath, transactionPath) {
463
687
  }
464
688
  throw error;
465
689
  }
690
+ // The swap must be durable before the backup and journal disappear.
691
+ await syncDirectory(parent);
466
692
  await cleanup([backupPath, transactionPath, journalPath]);
467
693
  }
468
694
 
@@ -543,107 +769,202 @@ function batchLedgerPath(packagePath) {
543
769
  return `${packagePath}.batches.json`;
544
770
  }
545
771
 
772
+ // The ledger is a null-prototype map so batch IDs such as "__proto__" stay
773
+ // plain keys.
774
+ function emptyBatchLedger() {
775
+ return Object.create(null);
776
+ }
777
+
546
778
  async function readBatchLedger(packagePath) {
779
+ let parsed;
547
780
  try {
548
- const parsed = JSON.parse(
549
- await readFile(batchLedgerPath(packagePath), "utf8"),
781
+ parsed = JSON.parse(
782
+ await readRegularFile(batchLedgerPath(packagePath), "utf8", {
783
+ maxBytes: MAX_BATCH_LEDGER_BYTES,
784
+ }),
550
785
  );
551
- return parsed && typeof parsed === "object" ? parsed : {};
552
786
  } catch (error) {
553
- if (error?.code === "ENOENT") return {};
554
- throw error;
787
+ if (error?.code === "ENOENT") return emptyBatchLedger();
788
+ // The ledger only provides replay idempotency. An unreadable ledger must
789
+ // not block every write; it is replaced on the next recorded commit.
790
+ process.emitWarning(
791
+ `Ignoring unreadable SmallPen batch ledger: ${error?.message ?? error}`,
792
+ { code: "SMALLPEN_BATCH_LEDGER_UNREADABLE" },
793
+ );
794
+ return emptyBatchLedger();
795
+ }
796
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
797
+ process.emitWarning("Ignoring malformed SmallPen batch ledger", {
798
+ code: "SMALLPEN_BATCH_LEDGER_UNREADABLE",
799
+ });
800
+ return emptyBatchLedger();
555
801
  }
802
+ return Object.assign(emptyBatchLedger(), parsed);
556
803
  }
557
804
 
558
805
  async function writeBatchLedger(packagePath, ledger) {
806
+ const entries = Object.entries(ledger).sort(([, left], [, right]) =>
807
+ String(left?.committedAt ?? "").localeCompare(
808
+ String(right?.committedAt ?? ""),
809
+ ),
810
+ );
811
+ const retained = Object.assign(
812
+ emptyBatchLedger(),
813
+ Object.fromEntries(entries.slice(-MAX_BATCH_LEDGER_ENTRIES)),
814
+ );
559
815
  const temporary = `${batchLedgerPath(packagePath)}.${randomUUID()}.tmp`;
560
- await writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`);
816
+ await writeFile(temporary, `${JSON.stringify(retained, null, 2)}\n`);
561
817
  await rename(temporary, batchLedgerPath(packagePath));
562
818
  }
563
819
 
564
- // SP-039/040: a process-local batch ledger gives the public CLI stable
565
- // idempotency. Replaying the same batch identity with the same operations
566
- // returns the recorded confirmation without a second write; replaying the same
567
- // identity with different operations is rejected with a correction path.
568
- // Public replay check used by the CLI before stale-revision validation: an
569
- // already-committed batch identity with identical operations replays its
570
- // recorded confirmation instead of failing or writing again.
571
- export async function replayRecordedBatch(locator, batch) {
820
+ function hashCanonical(value) {
821
+ return sha256Hex(new TextEncoder().encode(canonicalJSON(value)));
822
+ }
823
+
824
+ // A batch identity is its ID plus everything it was prepared against: the
825
+ // same operations at another base revision are a different intent.
826
+ function batchIdentityHash(batch) {
827
+ return hashCanonical({
828
+ baseRevision: batch.baseRevision ?? null,
829
+ operations: batch.operations ?? [],
830
+ });
831
+ }
832
+
833
+ function batchOperationsHash(batch) {
834
+ return hashCanonical({ operations: batch.operations ?? [] });
835
+ }
836
+
837
+ // SP-039/040: a batch ledger beside the Package gives the public CLI stable
838
+ // idempotency. A recorded batch ID replays only while its effect is the
839
+ // current state, so a replay never writes twice and never reports a
840
+ // revision other than the current one as success:
841
+ // - current revision is the recorded result: return the recorded
842
+ // confirmation with alreadyApplied:true and write nothing;
843
+ // - current revision is the batch's base again (the effect was undone):
844
+ // apply it as a new write;
845
+ // - otherwise later writes moved the Package on: fail batch_superseded.
846
+ // `options.rebuilt` marks a batch a high-level command rebuilt from the
847
+ // current revision with the caller's --batch-id: a retry of the same command
848
+ // after success carries the same operations on top of the recorded result.
849
+ async function recordedBatchReplay(ledger, batch, readRevision, options = {}) {
850
+ const batchId = batch?.batchId;
851
+ if (typeof batchId !== "string" || !Object.hasOwn(ledger, batchId)) {
852
+ return undefined;
853
+ }
854
+ const recorded = ledger[batchId];
855
+ if (!recorded || typeof recorded !== "object") return undefined;
856
+ const operationsHash = await batchOperationsHash(batch);
857
+ const sameBatch =
858
+ typeof recorded.identityHash === "string"
859
+ ? recorded.identityHash === (await batchIdentityHash(batch))
860
+ : // Entries written before identities carried the base revision.
861
+ recorded.operationsHash === operationsHash;
862
+ const sameCommandRetry =
863
+ options.rebuilt === true &&
864
+ recorded.operationsHash === operationsHash &&
865
+ batch.baseRevision === recorded.revision;
866
+ if (!sameBatch && !sameCommandRetry) {
867
+ fail(
868
+ "batch_id_conflict",
869
+ "Batch ID was already committed with a different base revision or operations",
870
+ {
871
+ batchId,
872
+ committedRevision: recorded.revision,
873
+ correction:
874
+ "A batchId names one committed intent. Retrying it replays only the identical batch (same baseRevision and operations, or the same high-level command input); for any other change pick a new unique batchId.",
875
+ },
876
+ );
877
+ }
878
+ const currentRevision = await readRevision();
879
+ if (currentRevision === recorded.revision) {
880
+ return { ...recorded.result, alreadyApplied: true };
881
+ }
882
+ if (sameBatch && currentRevision === batch.baseRevision) return undefined;
883
+ fail(
884
+ "batch_superseded",
885
+ "Batch ID was already committed, and later writes changed the Package",
886
+ {
887
+ batchId,
888
+ committedRevision: recorded.revision,
889
+ currentRevision,
890
+ correction:
891
+ "Do not resend this batch. Read the Package at currentRevision, check whether the intent still holds, and build any further change against currentRevision with a new unique batchId.",
892
+ },
893
+ );
894
+ }
895
+
896
+ function currentRevisionReader(packagePath) {
897
+ return async () => (await openPackageUnlocked(packagePath)).revision;
898
+ }
899
+
900
+ // Public replay check used by the CLI before stale-revision validation and
901
+ // before high-level commands rebuild a batch: a committed batch identity
902
+ // replays its recorded confirmation instead of failing or writing again.
903
+ export async function replayRecordedBatch(locator, batch, options = {}) {
572
904
  const packagePath = await lockTarget(locator);
573
- const release = await acquireWriteLock(packagePath);
905
+ let release;
574
906
  try {
907
+ release = await acquireWriteLock(packagePath);
908
+ } catch (error) {
909
+ // Nothing commits where this process cannot lock; read the ledger as is.
910
+ if (error?.code !== "package_not_writable") throw error;
575
911
  const resolvedPackagePath = await resolvePackageRoot(packagePath);
576
- const ledger = await readBatchLedger(resolvedPackagePath);
577
- const recorded = ledger[batch?.batchId];
578
- if (!recorded) return undefined;
579
- const operationsHash = await sha256Hex(
580
- new TextEncoder().encode(canonicalJSON({ operations: batch.operations ?? [] })),
912
+ return recordedBatchReplay(
913
+ await readBatchLedger(resolvedPackagePath),
914
+ batch,
915
+ currentRevisionReader(resolvedPackagePath),
916
+ options,
917
+ );
918
+ }
919
+ try {
920
+ await recoverInterruptedCommit(packagePath);
921
+ const resolvedPackagePath = await resolvePackageRoot(packagePath);
922
+ return await recordedBatchReplay(
923
+ await readBatchLedger(resolvedPackagePath),
924
+ batch,
925
+ currentRevisionReader(resolvedPackagePath),
926
+ options,
581
927
  );
582
- if (recorded.operationsHash !== operationsHash) {
583
- fail(
584
- "batch_id_conflict",
585
- "Batch ID was already committed with different operations",
586
- {
587
- batchId: batch.batchId,
588
- committedRevision: recorded.revision,
589
- correction:
590
- "Pick a new unique batchId for the different intent; do not reuse committed batch identities.",
591
- },
592
- );
593
- }
594
- return { ...recorded.result, alreadyApplied: true };
595
928
  } finally {
596
929
  await release();
597
930
  }
598
931
  }
599
932
 
600
- export async function applyOperationBatch(locator, batch) {
933
+ // `options.onCommitted(snapshot)` receives the Package as it stands right
934
+ // after this batch, read while the write lock is still held, so callers never
935
+ // mistake a later external write for their own commit.
936
+ export async function applyOperationBatch(locator, batch, options = {}) {
601
937
  const packagePath = await lockTarget(locator);
602
938
  const release = await acquireWriteLock(packagePath);
603
939
  try {
604
940
  await recoverInterruptedCommit(packagePath);
605
941
  const resolvedPackagePath = await resolvePackageRoot(packagePath);
942
+ await removeAbandonedCommitFiles(resolvedPackagePath);
606
943
  const ledger = await readBatchLedger(resolvedPackagePath);
607
- const recorded = ledger[batch?.batchId];
608
- if (recorded) {
609
- const operationsHash = await sha256Hex(
610
- new TextEncoder().encode(canonicalJSON({ operations: batch.operations ?? [] })),
611
- );
612
- if (recorded.operationsHash !== operationsHash) {
613
- fail(
614
- "batch_id_conflict",
615
- "Batch ID was already committed with different operations",
616
- {
617
- batchId: batch.batchId,
618
- committedRevision: recorded.revision,
619
- correction:
620
- "Pick a new unique batchId for the different intent; do not reuse committed batch identities.",
621
- },
622
- );
944
+ let result = await recordedBatchReplay(
945
+ ledger,
946
+ batch,
947
+ currentRevisionReader(resolvedPackagePath),
948
+ );
949
+ if (!result) {
950
+ result = await applyOperationBatchLocked(resolvedPackagePath, batch);
951
+ if (Array.isArray(batch?.operations)) {
952
+ try {
953
+ ledger[batch.batchId] = {
954
+ committedAt: new Date().toISOString(),
955
+ identityHash: await batchIdentityHash(batch),
956
+ operationsHash: await batchOperationsHash(batch),
957
+ result,
958
+ revision: result.revision,
959
+ };
960
+ await writeBatchLedger(resolvedPackagePath, ledger);
961
+ } catch {
962
+ // Ledger persistence is best-effort; the canonical commit stands.
963
+ }
623
964
  }
624
- return {
625
- ...recorded.result,
626
- alreadyApplied: true,
627
- revision: recorded.result.revision,
628
- };
629
965
  }
630
- const result = await applyOperationBatchLocked(resolvedPackagePath, batch);
631
- if (!result.alreadyApplied && Array.isArray(batch?.operations)) {
632
- try {
633
- ledger[batch.batchId] = {
634
- committedAt: new Date().toISOString(),
635
- operationsHash: await sha256Hex(
636
- new TextEncoder().encode(
637
- canonicalJSON({ operations: batch.operations ?? [] }),
638
- ),
639
- ),
640
- result,
641
- revision: result.revision,
642
- };
643
- await writeBatchLedger(resolvedPackagePath, ledger);
644
- } catch {
645
- // Ledger persistence is best-effort; the canonical commit stands.
646
- }
966
+ if (options.onCommitted) {
967
+ options.onCommitted(await openPackageUnlocked(resolvedPackagePath));
647
968
  }
648
969
  return result;
649
970
  } finally {
@@ -651,9 +972,16 @@ export async function applyOperationBatch(locator, batch) {
651
972
  }
652
973
  }
653
974
 
975
+ // Helper writes (media, fonts) get a fresh identity per call: the same
976
+ // intent can legitimately repeat (import, remove, import again).
977
+ function helperBatchId(kind, id) {
978
+ return `${kind}_${String(id).slice(0, 120)}_${randomUUID()}`;
979
+ }
980
+
654
981
  export async function importMedia(
655
982
  locator,
656
983
  { bytes, height, id, mimeType, name, path = "", width },
984
+ options = {},
657
985
  ) {
658
986
  if (!(bytes instanceof Uint8Array)) {
659
987
  fail("invalid_media_blob", "Imported Media bytes must be a Uint8Array");
@@ -663,8 +991,11 @@ export async function importMedia(
663
991
  const library = before.manifest.entries.assets[0]
664
992
  ? structuredClone(before.entries[entry])
665
993
  : { colors: [], fonts: [], id: "alib_design", media: [], typographies: [] };
666
- if (library.media.some((media) => media.id === id)) {
667
- fail("duplicate_media_id", `Media already exists: ${id}`);
994
+ const existing = library.media.find((media) => media.id === id);
995
+ if (existing) {
996
+ fail("duplicate_media_id", `Media already exists: ${id}`, {
997
+ descriptor: existing,
998
+ });
668
999
  }
669
1000
  const sha256 = await sha256Hex(bytes);
670
1001
  const blob = `blobs/${sha256}`;
@@ -680,22 +1011,19 @@ export async function importMedia(
680
1011
  width,
681
1012
  };
682
1013
  library.media.push(descriptor);
683
- const result = await applyOperationBatch(locator, {
684
- baseRevision: before.revision,
685
- batchId: `import_${id}`,
686
- blobs: new Map([[blob, bytes]]),
687
- operations: [
688
- {
689
- ...(before.manifest.entries.assets[0] ? {} : { entry }),
690
- library,
691
- type: "replace-asset-library",
692
- },
693
- ],
694
- });
1014
+ const result = await replaceAssetLibrary(
1015
+ locator,
1016
+ before,
1017
+ entry,
1018
+ library,
1019
+ helperBatchId("import", id),
1020
+ new Map([[blob, bytes]]),
1021
+ options,
1022
+ );
695
1023
  return { descriptor, result };
696
1024
  }
697
1025
 
698
- export async function removeMedia(locator, mediaId) {
1026
+ export async function removeMedia(locator, mediaId, options = {}) {
699
1027
  const before = await openPackage(locator);
700
1028
  if (!before.manifest.entries.assets[0]) {
701
1029
  fail("missing_media", `Media does not exist: ${mediaId}`);
@@ -705,7 +1033,15 @@ export async function removeMedia(locator, mediaId) {
705
1033
  fail("missing_media", `Media does not exist: ${mediaId}`);
706
1034
  }
707
1035
  library.media = library.media.filter((media) => media.id !== mediaId);
708
- return replaceAssetLibrary(locator, before, entry, library, `remove_${mediaId}`);
1036
+ return replaceAssetLibrary(
1037
+ locator,
1038
+ before,
1039
+ entry,
1040
+ library,
1041
+ helperBatchId("remove", mediaId),
1042
+ undefined,
1043
+ options,
1044
+ );
709
1045
  }
710
1046
 
711
1047
  function assetLibraryForWrite(snapshot) {
@@ -716,24 +1052,37 @@ function assetLibraryForWrite(snapshot) {
716
1052
  return { entry, library };
717
1053
  }
718
1054
 
719
- async function replaceAssetLibrary(locator, before, entry, library, batchId, blobs) {
720
- return applyOperationBatch(locator, {
721
- baseRevision: before.revision,
722
- batchId,
723
- ...(blobs ? { blobs } : {}),
724
- operations: [
725
- {
726
- ...(before.manifest.entries.assets[0] ? {} : { entry }),
727
- library,
728
- type: "replace-asset-library",
729
- },
730
- ],
731
- });
1055
+ async function replaceAssetLibrary(
1056
+ locator,
1057
+ before,
1058
+ entry,
1059
+ library,
1060
+ batchId,
1061
+ blobs,
1062
+ options,
1063
+ ) {
1064
+ return applyOperationBatch(
1065
+ locator,
1066
+ {
1067
+ baseRevision: before.revision,
1068
+ batchId,
1069
+ ...(blobs ? { blobs } : {}),
1070
+ operations: [
1071
+ {
1072
+ ...(before.manifest.entries.assets[0] ? {} : { entry }),
1073
+ library,
1074
+ type: "replace-asset-library",
1075
+ },
1076
+ ],
1077
+ },
1078
+ options,
1079
+ );
732
1080
  }
733
1081
 
734
1082
  export async function importFontVariant(
735
1083
  locator,
736
1084
  { family, files, fontId, id, name, style, weight },
1085
+ options = {},
737
1086
  ) {
738
1087
  const before = await openPackage(locator);
739
1088
  const { entry, library } = assetLibraryForWrite(before);
@@ -783,13 +1132,14 @@ export async function importFontVariant(
783
1132
  before,
784
1133
  entry,
785
1134
  library,
786
- `import_${id}`,
1135
+ helperBatchId("import", id),
787
1136
  blobs,
1137
+ options,
788
1138
  );
789
1139
  return { result, variant };
790
1140
  }
791
1141
 
792
- export async function updateFontFamily(locator, fontId, family) {
1142
+ export async function updateFontFamily(locator, fontId, family, options = {}) {
793
1143
  const before = await openPackage(locator);
794
1144
  const { entry, library } = assetLibraryForWrite(before);
795
1145
  const font = library.fonts.find(({ id }) => id === fontId);
@@ -800,11 +1150,13 @@ export async function updateFontFamily(locator, fontId, family) {
800
1150
  before,
801
1151
  entry,
802
1152
  library,
803
- `update_${fontId}`,
1153
+ helperBatchId("update", fontId),
1154
+ undefined,
1155
+ options,
804
1156
  );
805
1157
  }
806
1158
 
807
- export async function deleteFontFamily(locator, fontId) {
1159
+ export async function deleteFontFamily(locator, fontId, options = {}) {
808
1160
  const before = await openPackage(locator);
809
1161
  const { entry, library } = assetLibraryForWrite(before);
810
1162
  const index = library.fonts.findIndex(({ id }) => id === fontId);
@@ -815,11 +1167,13 @@ export async function deleteFontFamily(locator, fontId) {
815
1167
  before,
816
1168
  entry,
817
1169
  library,
818
- `delete_${fontId}`,
1170
+ helperBatchId("delete", fontId),
1171
+ undefined,
1172
+ options,
819
1173
  );
820
1174
  }
821
1175
 
822
- export async function deleteFontVariant(locator, fontVariantId) {
1176
+ export async function deleteFontVariant(locator, fontVariantId, options = {}) {
823
1177
  const before = await openPackage(locator);
824
1178
  const { entry, library } = assetLibraryForWrite(before);
825
1179
  const font = library.fonts.find((candidate) =>
@@ -837,6 +1191,8 @@ export async function deleteFontVariant(locator, fontVariantId) {
837
1191
  before,
838
1192
  entry,
839
1193
  library,
840
- `delete_${fontVariantId}`,
1194
+ helperBatchId("delete", fontVariantId),
1195
+ undefined,
1196
+ options,
841
1197
  );
842
1198
  }