@smallpen/local-package 0.1.0-alpha.13 → 0.1.0-alpha.15

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,29 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join, posix, resolve, win32 } from "node:path";
4
+ import { realpath } from "node:fs/promises";
5
+ import { SmallPenError } from "@smallpen/core";
6
+ import { readRegularFile } from "./fs-utils.mjs";
7
+
8
+ export const HISTORY_CACHE_IDLE_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ export function defaultHistoryCacheRoot({ platform = process.platform, home = homedir(), env = process.env } = {}) {
11
+ if (env.SMALLPEN_HISTORY_CACHE_DIR) return resolve(env.SMALLPEN_HISTORY_CACHE_DIR);
12
+ if (platform === "darwin") return posix.join(home, "Library", "Caches", "SmallPen", "history");
13
+ if (platform === "win32") return win32.join(env.LOCALAPPDATA && win32.isAbsolute(env.LOCALAPPDATA)
14
+ ? env.LOCALAPPDATA : win32.join(home, "AppData", "Local"), "SmallPen", "Cache", "history");
15
+ return posix.join(env.XDG_CACHE_HOME && posix.isAbsolute(env.XDG_CACHE_HOME)
16
+ ? env.XDG_CACHE_HOME : posix.join(home, ".cache"), "smallpen", "history");
17
+ }
18
+
19
+ // Resolve aliases before hashing. Copies keep separate histories even when
20
+ // their manifests share a Package ID; replacing a Package at the same path
21
+ // with another ID also starts a fresh history. No project names enter filenames.
22
+ export async function batchHistoryPath(locator) {
23
+ const path = await realpath(resolve(locator));
24
+ const manifest = JSON.parse(await readRegularFile(join(path, "manifest.json"), "utf8", { maxBytes: 4 * 1024 * 1024 }));
25
+ if (typeof manifest.packageId !== "string" || !manifest.packageId)
26
+ throw new SmallPenError("invalid_package_id", "Cannot locate history without a Package ID");
27
+ const identity = createHash("sha256").update(path).update("\0").update(manifest.packageId).digest("hex");
28
+ return join(defaultHistoryCacheRoot(), identity, "batches.json");
29
+ }
package/src/index.mjs CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  } from "./render.mjs";
20
20
  export { renderAppProjection } from "./render-shared.mjs";
21
21
  export { inspectProjection } from "./layout-inspection.mjs";
22
+ export { batchHistoryPath, defaultHistoryCacheRoot } from "./history-cache.mjs";
22
23
  export {
23
24
  decodeFigmaClipboard,
24
25
  importDraft,
@@ -31,6 +32,7 @@ export {
31
32
  } from "./initialize.mjs";
32
33
  export { migrateThemesByCopy } from "./migrate-themes.mjs";
33
34
  export {
35
+ BATCH_HISTORY_LIMITS,
34
36
  applyOperationBatch,
35
37
  deleteFontFamily,
36
38
  deleteFontVariant,
@@ -39,7 +39,7 @@ export async function inspectProjection(product, projection, options = {}) {
39
39
  ].includes(error.code)
40
40
  )
41
41
  throw error;
42
- failure = { code: error.code, message: error.message };
42
+ failure = { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
43
43
  }
44
44
  const origin = {
45
45
  x: (root?.x ?? 0) + (options.crop?.x ?? 0),
@@ -84,7 +84,9 @@ export async function inspectProjection(product, projection, options = {}) {
84
84
  };
85
85
  update(tree.root);
86
86
  const diagnostics = failure
87
- ? [failure]
87
+ ? [failure, ...(failure.details?.nodes ?? []).map(({ nodeId, path, ...details }) => ({
88
+ code: failure.code, message: failure.message, nodeId, path, details,
89
+ }))]
88
90
  : (render?.diagnostics ?? []).filter(
89
91
  (item) => item.code !== "layout_projection_partial",
90
92
  );
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
+ import { constants as fsConstants } from "node:fs";
3
4
  import {
4
5
  copyFile,
5
6
  link,
@@ -39,6 +40,7 @@ import {
39
40
  syncDirectory,
40
41
  writeFileDurable,
41
42
  } from "./fs-utils.mjs";
43
+ import { batchHistoryPath, HISTORY_CACHE_IDLE_MS } from "./history-cache.mjs";
42
44
 
43
45
  const execFileAsync = promisify(execFile);
44
46
 
@@ -49,10 +51,16 @@ const execFileAsync = promisify(execFile);
49
51
  const CLAIM_HEARTBEAT_MS = 2000;
50
52
  const UNVERIFIED_CLAIM_STALE_MS = 10000;
51
53
  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.
54
+ // Control files and cached histories are read whole; a huge or endless file
55
+ // must fail fast instead of exhausting memory. Undo records can be
56
+ // large, so the ledger enforces both byte and record limits on every write.
55
57
  const MAX_BATCH_LEDGER_BYTES = 32 * 1024 * 1024;
58
+ const UNREADABLE_BATCH_LEDGER = Symbol("unreadableBatchLedger");
59
+ export const BATCH_HISTORY_LIMITS = Object.freeze({
60
+ maxBytes: MAX_BATCH_LEDGER_BYTES,
61
+ maxBatches: MAX_BATCH_LEDGER_ENTRIES,
62
+ maxIdleMs: HISTORY_CACHE_IDLE_MS,
63
+ });
56
64
  const MAX_CONTROL_FILE_BYTES = 64 * 1024;
57
65
  // Errors that mean this process cannot create the lock next to the Package.
58
66
  const UNWRITABLE_LOCATION_CODES = new Set(["EACCES", "EPERM", "EROFS"]);
@@ -521,14 +529,22 @@ export async function openPackage(locator) {
521
529
  }
522
530
  try {
523
531
  await recoverInterruptedCommit(target);
524
- return await openPackageUnlocked(target);
532
+ const snapshot = await openPackageUnlocked(target);
533
+ if (await pathExists(`${target}.batches.json`)) {
534
+ try { await readBatchLedger(target); }
535
+ catch (error) {
536
+ // Auxiliary history migration must not prevent opening a valid design.
537
+ process.emitWarning(`Legacy history migration deferred: ${error.message}`, { code: "SMALLPEN_HISTORY_CACHE_UNAVAILABLE" });
538
+ }
539
+ }
540
+ return snapshot;
525
541
  } finally {
526
542
  await release();
527
543
  }
528
544
  }
529
545
 
530
- async function copyFileDurable(source, target) {
531
- await copyFile(source, target);
546
+ async function copyFileDurable(source, target, flags = 0) {
547
+ await copyFile(source, target, flags);
532
548
  const handle = await open(target, "r+");
533
549
  try {
534
550
  await handle.sync();
@@ -715,7 +731,7 @@ async function themeSelectionDependencies(locator, before, batch, dependencies)
715
731
  }
716
732
  }
717
733
 
718
- async function applyOperationBatchLocked(locator, batch, dependencies = {}) {
734
+ async function applyOperationBatchLocked(locator, batch, dependencies = {}, beforeCommit = () => {}) {
719
735
  const before = await openPackageUnlocked(locator);
720
736
  const blobWrites = batch.blobs ?? new Map();
721
737
  if (!(blobWrites instanceof Map)) {
@@ -749,11 +765,18 @@ async function applyOperationBatchLocked(locator, batch, dependencies = {}) {
749
765
  );
750
766
  }
751
767
  }
768
+ const result = {
769
+ ...prepared.result,
770
+ changedFiles: [...new Set([...prepared.result.changedFiles, ...blobWrites.keys()])].sort(),
771
+ };
772
+ // Capacity checks must run before changing the design, including no-op
773
+ // batches: a single undo record larger than the window cannot be replayed.
774
+ await beforeCommit(result);
752
775
  if (
753
776
  prepared.result.changedFiles.length === 0 &&
754
777
  prepared.result.deletedFiles.length === 0
755
778
  ) {
756
- return prepared.result;
779
+ return result;
757
780
  }
758
781
 
759
782
  const transaction = await writeCandidate(
@@ -780,68 +803,193 @@ async function applyOperationBatchLocked(locator, batch, dependencies = {}) {
780
803
  transaction.candidatePath,
781
804
  transaction.transactionPath,
782
805
  );
783
- return {
784
- ...prepared.result,
785
- changedFiles: [
786
- ...new Set([...prepared.result.changedFiles, ...blobWrites.keys()]),
787
- ].sort(),
788
- };
806
+ return result;
789
807
  } catch (error) {
790
808
  await cleanup([transaction.transactionPath]);
791
809
  throw error;
792
810
  }
793
811
  }
794
812
 
795
- function batchLedgerPath(packagePath) {
796
- return `${packagePath}.batches.json`;
797
- }
798
-
799
813
  // The ledger is a null-prototype map so batch IDs such as "__proto__" stay
800
814
  // plain keys.
801
815
  function emptyBatchLedger() {
802
816
  return Object.create(null);
803
817
  }
804
818
 
805
- async function readBatchLedger(packagePath) {
819
+ async function readLedgerFile(path, { expire = true } = {}) {
806
820
  let parsed;
807
821
  try {
822
+ const info = await lstat(path);
823
+ if (expire && Date.now() - info.mtimeMs > HISTORY_CACHE_IDLE_MS) return emptyBatchLedger();
808
824
  parsed = JSON.parse(
809
- await readRegularFile(batchLedgerPath(packagePath), "utf8", {
825
+ await readRegularFile(path, "utf8", {
810
826
  maxBytes: MAX_BATCH_LEDGER_BYTES,
811
827
  }),
812
828
  );
813
829
  } catch (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.
830
+ if (error?.code === "ENOENT") return undefined;
831
+ // Reads never erase evidence. A writer archives this exact file before
832
+ // installing a fresh ledger; history reads report the unavailable window.
817
833
  process.emitWarning(
818
- `Ignoring unreadable SmallPen batch ledger: ${error?.message ?? error}`,
834
+ `Unreadable SmallPen batch ledger retained at ${path}: ${error?.message ?? error}`,
819
835
  { code: "SMALLPEN_BATCH_LEDGER_UNREADABLE" },
820
836
  );
821
- return emptyBatchLedger();
837
+ return Object.assign(emptyBatchLedger(), { [UNREADABLE_BATCH_LEDGER]: { error, path } });
822
838
  }
823
839
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
824
- process.emitWarning("Ignoring malformed SmallPen batch ledger", {
840
+ process.emitWarning(`Malformed SmallPen batch ledger retained at ${path}`, {
825
841
  code: "SMALLPEN_BATCH_LEDGER_UNREADABLE",
826
842
  });
827
- return emptyBatchLedger();
843
+ return Object.assign(emptyBatchLedger(), { [UNREADABLE_BATCH_LEDGER]: { error: new Error("Invalid batch ledger object"), path } });
828
844
  }
829
845
  return Object.assign(emptyBatchLedger(), parsed);
830
846
  }
831
847
 
832
- async function writeBatchLedger(packagePath, ledger) {
833
- const entries = Object.entries(ledger).sort(([, left], [, right]) =>
848
+ // Called while the Package write lock is held. A read-only owner can read
849
+ // legacy history but never tries to move it or prune cache files.
850
+ async function readBatchLedger(packagePath, { migrate = true } = {}) {
851
+ const path = await batchHistoryPath(packagePath);
852
+ if (migrate) {
853
+ try { await pruneHistoryCache(path); }
854
+ catch (error) {
855
+ process.emitWarning(`History cache cleanup failed: ${error.message}`, { code: "SMALLPEN_HISTORY_CACHE_UNAVAILABLE" });
856
+ }
857
+ }
858
+ const cached = await readLedgerFile(path);
859
+ const legacy = `${packagePath}.batches.json`;
860
+ if (!await pathExists(legacy)) return cached ?? emptyBatchLedger();
861
+ if (!migrate) return cached ?? await readLedgerFile(legacy, { expire: false }) ?? emptyBatchLedger();
862
+ try {
863
+ return await migrateBatchLedger(packagePath, path, legacy, cached);
864
+ } catch (error) {
865
+ if (error instanceof SmallPenError) throw error;
866
+ if (!["EACCES", "EPERM", "EROFS", "ENOSPC", "ENOTDIR", "EEXIST"].includes(error.code)) throw error;
867
+ // A cache failure must not prevent saving a valid design. Preserve the
868
+ // old sidecar; the write reports unavailable history if caching also fails.
869
+ process.emitWarning(`Legacy history migration deferred: ${error.message}`, { code: "SMALLPEN_HISTORY_CACHE_UNAVAILABLE" });
870
+ const present = await readLedgerFile(path);
871
+ if (present && !present[UNREADABLE_BATCH_LEDGER]) return present;
872
+ return await readLedgerFile(legacy, { expire: false }) ?? present ?? emptyBatchLedger();
873
+ }
874
+ }
875
+
876
+ async function migrateBatchLedger(packagePath, path, legacy, cached) {
877
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
878
+ if (!cached) {
879
+ await moveHistoryFile(legacy, path);
880
+ await utimes(path, new Date(), new Date());
881
+ return await readLedgerFile(path) ?? emptyBatchLedger();
882
+ }
883
+ const older = await readLedgerFile(legacy, { expire: false });
884
+ if (older?.[UNREADABLE_BATCH_LEDGER]) {
885
+ const recoveryPath = `${path}.unreadable-${randomUUID()}.bak`;
886
+ await moveHistoryFile(legacy, recoveryPath);
887
+ process.emitWarning(`Unreadable legacy history preserved at ${recoveryPath}`, { code: "SMALLPEN_BATCH_LEDGER_RECOVERED" });
888
+ return cached;
889
+ }
890
+ if (cached[UNREADABLE_BATCH_LEDGER]) {
891
+ await moveHistoryFile(path, `${path}.unreadable-${randomUUID()}.bak`);
892
+ await moveHistoryFile(legacy, path);
893
+ await utimes(path, new Date(), new Date());
894
+ return await readLedgerFile(path) ?? emptyBatchLedger();
895
+ }
896
+ // During an upgrade an older App can recreate its adjacent ledger. Merge
897
+ // it under the same Package lock, preserving the newer version's records.
898
+ for (const [id, entry] of Object.entries(older ?? {})) {
899
+ if (cached[id] && cached[id].identityHash !== entry?.identityHash)
900
+ fail("batch_id_conflict", "Legacy and cached histories contain different batches with the same ID", { batchId: id, legacyPath: legacy, cachePath: path });
901
+ if (!cached[id] || String(entry?.committedAt) > String(cached[id]?.committedAt)) cached[id] = entry;
902
+ }
903
+ const newest = Object.entries(cached).sort(([, a], [, b]) => String(a?.committedAt ?? "").localeCompare(String(b?.committedAt ?? ""))).at(-1)?.[0];
904
+ await writeBatchLedger(packagePath, newest ? serializeBatchLedger(cached, newest) : "{}\n");
905
+ await rm(legacy);
906
+ await syncDirectory(dirname(legacy));
907
+ return await readLedgerFile(path) ?? emptyBatchLedger();
908
+ }
909
+
910
+ async function moveHistoryFile(source, target) {
911
+ const info = await lstat(source);
912
+ if (!info.isFile() || info.isSymbolicLink())
913
+ fail("invalid_batch_history", "Legacy history must be a regular file", { path: source });
914
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
915
+ try {
916
+ await rename(source, target);
917
+ } catch (error) {
918
+ if (error.code !== "EXDEV") throw error;
919
+ // A malicious sparse legacy file must not fill another filesystem.
920
+ if (info.size > 8 * MAX_BATCH_LEDGER_BYTES)
921
+ fail("batch_history_migration_too_large", "Legacy history is too large to copy to the cache; move it out of the project and retry", { path: source });
922
+ const temporary = `${target}.${randomUUID()}.tmp`;
923
+ try {
924
+ await copyFileDurable(source, temporary, fsConstants.COPYFILE_EXCL);
925
+ await rename(temporary, target);
926
+ await syncDirectory(dirname(target));
927
+ await rm(source);
928
+ } finally {
929
+ await cleanup([temporary]);
930
+ }
931
+ }
932
+ await syncDirectory(dirname(target));
933
+ await syncDirectory(dirname(source));
934
+ }
935
+
936
+ async function pruneHistoryCache(path) {
937
+ let names;
938
+ try { names = await readdir(dirname(path)); }
939
+ catch (error) { if (error.code === "ENOENT") return; throw error; }
940
+ for (const name of names) {
941
+ if (!/^batches\.json(?:\.unreadable-[0-9a-f-]+\.bak)?(?:\.[0-9a-f-]+\.tmp)?$/.test(name)) continue;
942
+ const candidate = join(dirname(path), name);
943
+ const info = await lstat(candidate).catch(error => { if (error.code === "ENOENT") return; throw error; });
944
+ if (info?.isFile() && Date.now() - info.mtimeMs > HISTORY_CACHE_IDLE_MS) await rm(candidate);
945
+ }
946
+ }
947
+
948
+ function serializeBatchLedger(ledger, newestBatchId) {
949
+ const entries = Object.entries(ledger).filter(([id]) => id !== newestBatchId).sort(([, left], [, right]) =>
834
950
  String(left?.committedAt ?? "").localeCompare(
835
951
  String(right?.committedAt ?? ""),
836
952
  ),
837
953
  );
838
- const retained = Object.assign(
839
- emptyBatchLedger(),
840
- Object.fromEntries(entries.slice(-MAX_BATCH_LEDGER_ENTRIES)),
841
- );
842
- const temporary = `${batchLedgerPath(packagePath)}.${randomUUID()}.tmp`;
843
- await writeFile(temporary, `${JSON.stringify(retained, null, 2)}\n`);
844
- await rename(temporary, batchLedgerPath(packagePath));
954
+ // Even a clock adjustment or a future-dated imported record must not evict
955
+ // the write being committed now.
956
+ entries.push([newestBatchId, ledger[newestBatchId]]);
957
+ // Keep a contiguous suffix of complete records. Count UTF-8 bytes of the
958
+ // exact compact JSON that the bounded reader will open, including framing.
959
+ const parts = [];
960
+ let bytes = 3; // braces and final newline
961
+ for (const [id, entry] of entries.reverse()) {
962
+ const part = `${JSON.stringify(id)}:${JSON.stringify(entry)}`;
963
+ const size = Buffer.byteLength(part, "utf8");
964
+ if (id === newestBatchId && size + 3 > MAX_BATCH_LEDGER_BYTES)
965
+ fail("batch_history_entry_too_large", "This batch's undo record exceeds the history capacity; split the change into smaller batches", {
966
+ maxBytes: MAX_BATCH_LEDGER_BYTES, requiredBytes: size + 3,
967
+ });
968
+ if (parts.length >= MAX_BATCH_LEDGER_ENTRIES || bytes + size + (parts.length ? 1 : 0) > MAX_BATCH_LEDGER_BYTES) break;
969
+ bytes += size + (parts.length ? 1 : 0);
970
+ parts.unshift(part);
971
+ }
972
+ return `{${parts.join(",")}}\n`;
973
+ }
974
+
975
+ async function writeBatchLedger(packagePath, serialized, unreadable) {
976
+ const path = await batchHistoryPath(packagePath);
977
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
978
+ const temporary = `${path}.${randomUUID()}.tmp`;
979
+ try {
980
+ await writeFileDurable(temporary, serialized, "utf8");
981
+ if (unreadable) {
982
+ const recoveryPath = `${path}.unreadable-${randomUUID()}.bak`;
983
+ await moveHistoryFile(unreadable.path, recoveryPath);
984
+ process.emitWarning(`Previous SmallPen batch ledger preserved at ${recoveryPath}`, {
985
+ code: "SMALLPEN_BATCH_LEDGER_RECOVERED", detail: recoveryPath,
986
+ });
987
+ }
988
+ await rename(temporary, path);
989
+ await syncDirectory(dirname(path));
990
+ } finally {
991
+ await cleanup([temporary]);
992
+ }
845
993
  }
846
994
 
847
995
  function hashCanonical(value) {
@@ -862,7 +1010,7 @@ function batchOperationsHash(batch) {
862
1010
  return hashCanonical({ operations: batch.operations });
863
1011
  }
864
1012
 
865
- // SP-039/040: a batch ledger beside the Package gives the public CLI stable
1013
+ // SP-039/040: a machine-local cached ledger gives the public CLI stable
866
1014
  // idempotency. A recorded batch ID replays only while its effect is the
867
1015
  // current state, so a replay never writes twice and never reports a
868
1016
  // revision other than the current one as success:
@@ -929,11 +1077,21 @@ function currentRevisionReader(packagePath) {
929
1077
 
930
1078
  // The committed batches, oldest first: what each changed, when, from which
931
1079
  // revision, and the inverse that restores the revision before it. Read
932
- // only; `smallpen changes` rebuilds an earlier revision from it.
1080
+ // The design stays unchanged; caches can migrate or expire on access.
1081
+ // `smallpen changes` rebuilds an earlier revision from the retained records.
933
1082
  export async function readBatchHistory(locator) {
934
1083
  const resolvedPackagePath = await resolvePackageRoot(await lockTarget(locator));
935
- const ledger = await readBatchLedger(resolvedPackagePath);
936
- return Object.entries(ledger)
1084
+ let release;
1085
+ try { release = await acquireWriteLock(resolvedPackagePath); }
1086
+ catch (error) { if (error.code !== "package_not_writable") throw error; }
1087
+ try {
1088
+ if (release) await recoverInterruptedCommit(resolvedPackagePath);
1089
+ const ledger = await readBatchLedger(resolvedPackagePath, { migrate: Boolean(release) });
1090
+ if (ledger[UNREADABLE_BATCH_LEDGER])
1091
+ fail("batch_history_unavailable", "The history cannot be read; its original file is preserved", {
1092
+ ledgerPath: ledger[UNREADABLE_BATCH_LEDGER].path, ...BATCH_HISTORY_LIMITS,
1093
+ });
1094
+ return Object.entries(ledger)
937
1095
  .map(([batchId, entry]) => ({
938
1096
  affectedIds: entry?.result?.affectedIds ?? [],
939
1097
  baseRevision: entry?.baseRevision,
@@ -944,7 +1102,10 @@ export async function readBatchHistory(locator) {
944
1102
  // CLI writes carry the intent that produced them; App edits do not.
945
1103
  source: entry?.cliIntentHash ? "cli" : "app",
946
1104
  }))
947
- .sort((left, right) => String(left.committedAt ?? "").localeCompare(String(right.committedAt ?? "")));
1105
+ .sort((left, right) => String(left.committedAt ?? "").localeCompare(String(right.committedAt ?? "")));
1106
+ } finally {
1107
+ await release?.();
1108
+ }
948
1109
  }
949
1110
 
950
1111
  // Public replay check used by the CLI before stale-revision validation and
@@ -960,7 +1121,7 @@ export async function replayRecordedBatch(locator, batch, options = {}) {
960
1121
  if (error?.code !== "package_not_writable") throw error;
961
1122
  const resolvedPackagePath = await resolvePackageRoot(packagePath);
962
1123
  return recordedBatchReplay(
963
- await readBatchLedger(resolvedPackagePath),
1124
+ await readBatchLedger(resolvedPackagePath, { migrate: false }),
964
1125
  batch,
965
1126
  currentRevisionReader(resolvedPackagePath),
966
1127
  options,
@@ -997,25 +1158,35 @@ export async function applyOperationBatch(locator, batch, options = {}) {
997
1158
  currentRevisionReader(resolvedPackagePath),
998
1159
  );
999
1160
  if (!result) {
1161
+ let serialized;
1000
1162
  result = await applyOperationBatchLocked(
1001
1163
  resolvedPackagePath,
1002
1164
  batch,
1003
1165
  options.dependencies,
1004
- );
1005
- if (Array.isArray(batch?.operations)) {
1006
- try {
1166
+ async prepared => {
1007
1167
  ledger[batch.batchId] = {
1008
1168
  baseRevision: batch.baseRevision,
1009
1169
  committedAt: new Date().toISOString(),
1010
1170
  identityHash: await batchIdentityHash(batch),
1011
1171
  operationsHash: await batchOperationsHash(batch),
1012
1172
  ...(batch.cliIntent ? { cliIntentHash: await hashCanonical(batch.cliIntent) } : {}),
1013
- result,
1014
- revision: result.revision,
1173
+ result: prepared,
1174
+ revision: prepared.revision,
1015
1175
  };
1016
- await writeBatchLedger(resolvedPackagePath, ledger);
1017
- } catch {
1018
- // Ledger persistence is best-effort; the canonical commit stands.
1176
+ serialized = serializeBatchLedger(ledger, batch.batchId);
1177
+ },
1178
+ );
1179
+ if (Array.isArray(batch?.operations)) {
1180
+ try {
1181
+ await writeBatchLedger(resolvedPackagePath, serialized, ledger[UNREADABLE_BATCH_LEDGER]);
1182
+ } catch (error) {
1183
+ // The canonical commit stands, but callers must know replay and
1184
+ // changes tracking were not recorded instead of assuming success.
1185
+ result = { ...result, history: { status: "unavailable", message: error.message,
1186
+ ledgerPath: await batchHistoryPath(resolvedPackagePath) } };
1187
+ process.emitWarning(`Design saved but batch history was not recorded: ${error.message}`, {
1188
+ code: "SMALLPEN_BATCH_LEDGER_WRITE_FAILED",
1189
+ });
1019
1190
  }
1020
1191
  }
1021
1192
  }
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { provisionFonts } from "./render-fonts.mjs";
6
6
  import { inspectMedia } from "./media-inspect.mjs";
7
- import { SmallPenError, stableRuntimeUuid } from "@smallpen/core";
7
+ import { SmallPenError, stableRuntimeUuid, elementPath } from "@smallpen/core";
8
8
 
9
9
  const runtime = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime");
10
10
  let initialized;
@@ -121,6 +121,19 @@ async function resources(engine, product, options) {
121
121
  return { fonts, fontFamilies, images };
122
122
  }
123
123
 
124
+ // Nodes have already resolved their components and Tokens. The App node
125
+ // projector only needs asset metadata and resource identities here. Sending
126
+ // every page and variant made each native measurement convert the entire
127
+ // package to Clojure data twice, even for a single button.
128
+ function renderSnapshot(snapshot) {
129
+ const assets = snapshot.manifest.entries.assets;
130
+ return {
131
+ manifest: { packageId: snapshot.manifest.packageId, entries: { assets } },
132
+ entries: Object.fromEntries(assets.map(entry => [entry, snapshot.entries[entry]])),
133
+ runtime: { file: snapshot.runtime.file, fonts: snapshot.runtime.fonts, media: snapshot.runtime.media },
134
+ };
135
+ }
136
+
124
137
  export async function renderAppProjection(product, projection, options = {}) {
125
138
  if (projection.fallbackUsed)
126
139
  throw new SmallPenError(
@@ -163,12 +176,15 @@ export async function renderAppProjection(product, projection, options = {}) {
163
176
  throw new SmallPenError(
164
177
  "render_size_exceeded",
165
178
  "Rendered image exceeds safety limits",
179
+ { width, height, maxDimension: 8192, maxPixels: 32 * 1024 * 1024,
180
+ correction: "Reduce --scale or export a selected --element; layout dimensions stay unchanged" },
166
181
  );
167
182
  const perform = async () => {
168
183
  const engine = await (initialized ??= loadRenderer().catch((error) => {
169
184
  initialized = undefined;
170
185
  throw error;
171
186
  }));
187
+ let sourceIds = {};
172
188
  try {
173
189
  const assets = await resources(engine, product, options);
174
190
  let nodes = projection.nodes,
@@ -214,16 +230,10 @@ export async function renderAppProjection(product, projection, options = {}) {
214
230
  );
215
231
  const request = {
216
232
  snapshot: {
217
- manifest: product.manifest,
218
- entries: product.entries,
219
- runtime: product.runtime,
233
+ ...renderSnapshot(product),
220
234
  libraries: [options.foundation, ...(options.libraries ?? [])]
221
235
  .filter(Boolean)
222
- .map((snapshot) => ({
223
- manifest: snapshot.manifest,
224
- entries: snapshot.entries,
225
- runtime: snapshot.runtime,
226
- })),
236
+ .map(renderSnapshot),
227
237
  },
228
238
  nodes,
229
239
  nodeIds,
@@ -234,7 +244,7 @@ export async function renderAppProjection(product, projection, options = {}) {
234
244
  forExport: options.forExport === true,
235
245
  fontFamilies: assets.fontFamilies,
236
246
  };
237
- const sourceIds = Object.fromEntries(
247
+ sourceIds = Object.fromEntries(
238
248
  Object.entries(nodeIds).map(([id, uuid]) => [uuid, id]),
239
249
  );
240
250
  const described = engine.renderer.describe(request);
@@ -356,6 +366,10 @@ export async function renderAppProjection(product, projection, options = {}) {
356
366
  // A native panic invalidates the module. A later request can reload it.
357
367
  if (error instanceof WebAssembly.RuntimeError) initialized = undefined;
358
368
  const details = engine.renderer.errorDetails(error) ?? {};
369
+ if (details.nodes) details.nodes = details.nodes.map(node => {
370
+ const nodeId = sourceIds[node.nodeId] ?? node.nodeId;
371
+ return { ...node, nodeId, path: elementPath(projection.nodes, projection.rootId, nodeId) };
372
+ });
359
373
  throw new SmallPenError(
360
374
  details.code?.replaceAll("-", "_") ?? "shared_render_failed",
361
375
  error.message ?? "Shared App renderer failed",