@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11

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