@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.
- package/assets/fonts/SourceSansPro-OFL.txt +46 -0
- package/assets/fonts/WorkSans-OFL.txt +46 -0
- package/package.json +4 -4
- package/runtime/fonts/OFL.txt +46 -0
- package/runtime/fonts/sourcesanspro-black.ttf +0 -0
- package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-light.ttf +0 -0
- package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
- package/runtime/manifest.json +7 -0
- package/runtime/render-wasm.js +15 -0
- package/runtime/render-wasm.wasm +0 -0
- package/runtime/renderer.mjs +169652 -0
- package/src/draft-import.mjs +1 -11
- package/src/font-convert.mjs +23 -1
- package/src/fs-utils.mjs +95 -0
- package/src/index.mjs +19 -3
- package/src/initialize.mjs +363 -136
- package/src/kiwi-runtime.mjs +38 -36
- package/src/library-network.mjs +189 -0
- package/src/local-package-backend.mjs +215 -24
- package/src/local-package.mjs +589 -177
- package/src/local-workspace-session.mjs +278 -96
- package/src/migrate-themes.mjs +116 -0
- package/src/remote-library.mjs +167 -28
- package/src/render-fonts.mjs +172 -0
- package/src/render-shared.mjs +346 -0
- package/src/render.mjs +71 -3557
- package/src/workspace.mjs +178 -17
- package/src/webp-decode.mjs +0 -73
package/src/local-package.mjs
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
copyFile,
|
|
5
|
+
link,
|
|
5
6
|
lstat,
|
|
6
7
|
mkdir,
|
|
7
8
|
mkdtemp,
|
|
9
|
+
open,
|
|
8
10
|
readdir,
|
|
9
|
-
|
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
!
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
325
|
+
owner = JSON.parse(
|
|
326
|
+
await readRegularFile(claimPath, "utf8", {
|
|
327
|
+
maxBytes: MAX_CONTROL_FILE_BYTES,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
244
330
|
} catch {
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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),
|
|
607
|
+
join(dirname(originalPath), `.${basename(originalPath)}.transaction-`),
|
|
401
608
|
);
|
|
402
609
|
const candidatePath = join(transactionPath, "candidate.smallpen");
|
|
403
610
|
try {
|
|
404
|
-
|
|
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 =
|
|
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
|
|
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
|
|
427
|
-
await
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
549
|
-
await
|
|
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
|
-
|
|
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(
|
|
843
|
+
await writeFile(temporary, `${JSON.stringify(retained, null, 2)}\n`);
|
|
561
844
|
await rename(temporary, batchLedgerPath(packagePath));
|
|
562
845
|
}
|
|
563
846
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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 (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
667
|
-
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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(
|
|
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(
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1250
|
+
helperBatchId("delete", fontVariantId),
|
|
1251
|
+
undefined,
|
|
1252
|
+
options,
|
|
841
1253
|
);
|
|
842
1254
|
}
|