@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.
- package/package.json +2 -2
- package/runtime/manifest.json +2 -2
- package/runtime/render-wasm.wasm +0 -0
- package/runtime/renderer.mjs +8555 -8625
- package/src/history-cache.mjs +29 -0
- package/src/index.mjs +2 -0
- package/src/layout-inspection.mjs +4 -2
- package/src/local-package.mjs +221 -50
- package/src/render-shared.mjs +24 -10
|
@@ -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
|
);
|
package/src/local-package.mjs
CHANGED
|
@@ -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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
815
|
-
//
|
|
816
|
-
//
|
|
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
|
-
`
|
|
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(
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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:
|
|
1173
|
+
result: prepared,
|
|
1174
|
+
revision: prepared.revision,
|
|
1015
1175
|
};
|
|
1016
|
-
|
|
1017
|
-
}
|
|
1018
|
-
|
|
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
|
}
|
package/src/render-shared.mjs
CHANGED
|
@@ -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
|
-
|
|
218
|
-
entries: product.entries,
|
|
219
|
-
runtime: product.runtime,
|
|
233
|
+
...renderSnapshot(product),
|
|
220
234
|
libraries: [options.foundation, ...(options.libraries ?? [])]
|
|
221
235
|
.filter(Boolean)
|
|
222
|
-
.map(
|
|
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
|
-
|
|
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",
|