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