@smallpen/local-package 0.1.0-alpha.14 → 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,
@@ -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
  }
@@ -176,6 +176,8 @@ export async function renderAppProjection(product, projection, options = {}) {
176
176
  throw new SmallPenError(
177
177
  "render_size_exceeded",
178
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" },
179
181
  );
180
182
  const perform = async () => {
181
183
  const engine = await (initialized ??= loadRenderer().catch((error) => {