@smallpen/local-package 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,842 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import {
4
+ cp,
5
+ lstat,
6
+ mkdir,
7
+ mkdtemp,
8
+ readdir,
9
+ readFile,
10
+ realpath,
11
+ rename,
12
+ rm,
13
+ stat,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
17
+ import { setTimeout as delay } from "node:timers/promises";
18
+ import { promisify } from "node:util";
19
+
20
+ import {
21
+ canonicalJSON,
22
+ fail,
23
+ listPackageEntries,
24
+ loadPackageFromValues,
25
+ prepareOperationBatch,
26
+ sha256Hex,
27
+ SmallPenError,
28
+ } from "@smallpen/core";
29
+
30
+ function isWithinRoot(rootPath, candidatePath) {
31
+ const child = relative(rootPath, candidatePath);
32
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
33
+ }
34
+
35
+ const execFileAsync = promisify(execFile);
36
+
37
+ async function processIdentity(pid) {
38
+ try {
39
+ const { stdout } = await execFileAsync("ps", ["-o", "lstart=", "-p", String(pid)]);
40
+ const value = stdout.trim();
41
+ return value || undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ function commitJournalPath(packagePath) {
48
+ return join(dirname(packagePath), `.${basename(packagePath)}.commit.json`);
49
+ }
50
+
51
+ async function pathExists(path) {
52
+ try {
53
+ await lstat(path);
54
+ return true;
55
+ } catch (error) {
56
+ if (error?.code === "ENOENT") return false;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ function validatedCommitJournal(packagePath, journal) {
62
+ const parent = dirname(packagePath);
63
+ const packageName = basename(packagePath);
64
+ const backupPath = resolve(String(journal?.backupPath ?? ""));
65
+ const candidatePath = resolve(String(journal?.candidatePath ?? ""));
66
+ const transactionPath = resolve(String(journal?.transactionPath ?? ""));
67
+ if (
68
+ dirname(backupPath) !== parent ||
69
+ !basename(backupPath).startsWith(`.${packageName}.backup-`) ||
70
+ dirname(transactionPath) !== parent ||
71
+ !basename(transactionPath).startsWith(".smallpen-transaction-") ||
72
+ candidatePath !== join(transactionPath, "candidate.smallpen")
73
+ ) {
74
+ fail("invalid_commit_journal", "Package commit journal is invalid", {
75
+ packagePath,
76
+ });
77
+ }
78
+ return { backupPath, candidatePath, transactionPath };
79
+ }
80
+
81
+ async function recoverInterruptedCommit(packagePath) {
82
+ const journalPath = commitJournalPath(packagePath);
83
+ let journal;
84
+ try {
85
+ journal = JSON.parse(await readFile(journalPath, "utf8"));
86
+ } catch (error) {
87
+ if (error?.code === "ENOENT") return;
88
+ if (await pathExists(packagePath)) {
89
+ await cleanup([journalPath]);
90
+ return;
91
+ }
92
+ fail(
93
+ "invalid_commit_journal",
94
+ `Unable to recover Package commit: ${error.message}`,
95
+ { packagePath },
96
+ );
97
+ }
98
+ const { backupPath, candidatePath, transactionPath } = validatedCommitJournal(
99
+ packagePath,
100
+ journal,
101
+ );
102
+ if (!(await pathExists(packagePath))) {
103
+ const recoverySource = (await pathExists(candidatePath))
104
+ ? candidatePath
105
+ : backupPath;
106
+ if (!(await pathExists(recoverySource))) {
107
+ fail(
108
+ "incomplete_commit_recovery",
109
+ "Package commit cannot be recovered because its candidate and backup are missing",
110
+ { packagePath },
111
+ );
112
+ }
113
+ try {
114
+ await rename(recoverySource, packagePath);
115
+ } catch (error) {
116
+ if (!(await pathExists(packagePath))) throw error;
117
+ }
118
+ }
119
+ await cleanup([backupPath, transactionPath, journalPath]);
120
+ }
121
+
122
+ async function resolvePackageRoot(locator) {
123
+ let packagePath;
124
+ try {
125
+ packagePath = await realpath(resolve(locator));
126
+ } catch (error) {
127
+ fail("invalid_package_path", `Unable to open Package: ${error.message}`);
128
+ }
129
+ const packageInfo = await stat(packagePath);
130
+ if (!packageInfo.isDirectory() || !packagePath.endsWith(".smallpen")) {
131
+ fail(
132
+ "invalid_package_path",
133
+ "SmallPen Package must be a directory with the .smallpen extension",
134
+ { packagePath },
135
+ );
136
+ }
137
+ return packagePath;
138
+ }
139
+
140
+ async function resolveEntry(packagePath, entry) {
141
+ const candidatePath = resolve(packagePath, entry);
142
+ if (!isWithinRoot(packagePath, candidatePath)) {
143
+ fail(
144
+ "invalid_entry_path",
145
+ `Canonical entry escapes the Package: ${entry}`,
146
+ );
147
+ }
148
+ let entryInfo;
149
+ try {
150
+ entryInfo = await lstat(candidatePath);
151
+ } catch (error) {
152
+ fail(
153
+ "invalid_entry_path",
154
+ `Unable to resolve Canonical entry ${entry}: ${error.message}`,
155
+ { entry },
156
+ );
157
+ }
158
+ if (entryInfo.isSymbolicLink()) {
159
+ fail("invalid_entry_path", `Canonical entry cannot be a symlink: ${entry}`);
160
+ }
161
+ const entryPath = await realpath(candidatePath);
162
+ if (!isWithinRoot(packagePath, entryPath)) {
163
+ fail(
164
+ "invalid_entry_path",
165
+ `Canonical entry resolves outside the Package: ${entry}`,
166
+ );
167
+ }
168
+ return entryPath;
169
+ }
170
+
171
+ async function readJson(path, code, details) {
172
+ try {
173
+ return JSON.parse(await readFile(path, "utf8"));
174
+ } catch (error) {
175
+ if (error instanceof SmallPenError) throw error;
176
+ fail(code, `Unable to read ${basename(path)}: ${error.message}`, details);
177
+ }
178
+ }
179
+
180
+ async function readPackageValues(packagePath) {
181
+ const manifest = await readJson(
182
+ await resolveEntry(packagePath, "manifest.json"),
183
+ "invalid_manifest_json",
184
+ { packagePath },
185
+ );
186
+ const { entries } = listPackageEntries(manifest);
187
+ const values = new Map([["manifest.json", manifest]]);
188
+ await Promise.all(
189
+ entries.map(async (entry) => {
190
+ values.set(
191
+ entry,
192
+ await readJson(
193
+ await resolveEntry(packagePath, entry),
194
+ "invalid_entry_json",
195
+ { entry, packagePath },
196
+ ),
197
+ );
198
+ }),
199
+ );
200
+ const blobsPath = join(packagePath, "blobs");
201
+ let blobEntries = [];
202
+ try {
203
+ const info = await lstat(blobsPath);
204
+ if (info.isSymbolicLink() || !info.isDirectory()) {
205
+ fail("invalid_media_blob_directory", "Package blobs must be a directory");
206
+ }
207
+ blobEntries = await readdir(blobsPath, { withFileTypes: true });
208
+ } catch (error) {
209
+ if (error?.code !== "ENOENT") throw error;
210
+ }
211
+ await Promise.all(
212
+ blobEntries.map(async (entry) => {
213
+ if (!entry.isFile() || !/^[a-f0-9]{64}$/.test(entry.name)) {
214
+ fail(
215
+ "invalid_media_blob",
216
+ `Package blob entry is invalid: blobs/${entry.name}`,
217
+ );
218
+ }
219
+ const path = `blobs/${entry.name}`;
220
+ values.set(path, await readFile(await resolveEntry(packagePath, path)));
221
+ }),
222
+ );
223
+ return values;
224
+ }
225
+
226
+ async function openPackageUnlocked(locator) {
227
+ const packagePath = await resolvePackageRoot(locator);
228
+ return loadPackageFromValues(
229
+ packagePath,
230
+ await readPackageValues(packagePath),
231
+ );
232
+ }
233
+
234
+ async function cleanup(paths) {
235
+ await Promise.allSettled(
236
+ paths.map((path) => rm(path, { force: true, recursive: true })),
237
+ );
238
+ }
239
+
240
+ async function abandonedWriteClaim(claimPath) {
241
+ let owner;
242
+ try {
243
+ owner = JSON.parse(await readFile(claimPath, "utf8"));
244
+ } catch {
245
+ let info;
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;
253
+ }
254
+ if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 0) {
255
+ const info = await lstat(claimPath);
256
+ return Date.now() - info.mtimeMs > 1000;
257
+ }
258
+ try {
259
+ 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
+ } catch (error) {
267
+ return error?.code === "ESRCH";
268
+ }
269
+ }
270
+
271
+ function orderedWriteClaims(names) {
272
+ return names
273
+ .filter((name) => name === "owner.json" || /^\d{16}-.+\.json$/.test(name))
274
+ .sort((left, right) => {
275
+ if (left === "owner.json") return -1;
276
+ if (right === "owner.json") return 1;
277
+ return left.localeCompare(right);
278
+ });
279
+ }
280
+
281
+ async function activeWriteClaim(activePath) {
282
+ try {
283
+ const value = JSON.parse(await readFile(activePath, "utf8"));
284
+ return typeof value?.claimName === "string" ? value.claimName : undefined;
285
+ } catch (error) {
286
+ if (error?.code === "ENOENT") return undefined;
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ async function releaseWriteClaim(activePath, claimName, claimPath) {
292
+ if (await activeWriteClaim(activePath) === claimName) {
293
+ await rm(activePath, { force: true });
294
+ }
295
+ await rm(claimPath, { force: true });
296
+ // Keep the claim directory as the stable synchronization point. Removing it
297
+ // here can race with a waiter that has already created its claim and would
298
+ // either delete that live claim or leave the waiter operating on an unlinked
299
+ // directory. Empty lock directories contain no ownership state and are safe
300
+ // to reuse on the next acquisition.
301
+ }
302
+
303
+ async function acquireWriteLock(packagePath) {
304
+ const lockPath = join(
305
+ dirname(packagePath),
306
+ `.${basename(packagePath)}.write-lock`,
307
+ );
308
+ const ownerProcessIdentity = await processIdentity(process.pid);
309
+ await mkdir(lockPath, { recursive: true });
310
+ const claimName = `${String(Date.now()).padStart(16, "0")}-${randomUUID()}.json`;
311
+ const claimPath = join(lockPath, claimName);
312
+ const activePath = join(lockPath, "active");
313
+ await writeFile(
314
+ claimPath,
315
+ JSON.stringify({
316
+ createdAt: new Date().toISOString(),
317
+ pid: process.pid,
318
+ processIdentity: ownerProcessIdentity,
319
+ }),
320
+ { flag: "wx" },
321
+ );
322
+ const deadline = Date.now() + 30000;
323
+ try {
324
+ while (true) {
325
+ const claims = orderedWriteClaims(await readdir(lockPath));
326
+ for (const name of claims) {
327
+ const candidate = join(lockPath, name);
328
+ if (candidate !== claimPath && await abandonedWriteClaim(candidate)) {
329
+ await rm(candidate, { force: true });
330
+ }
331
+ }
332
+ const activeClaims = orderedWriteClaims(await readdir(lockPath));
333
+ if (activeClaims[0] === claimName) {
334
+ try {
335
+ await writeFile(activePath, JSON.stringify({ claimName }), { flag: "wx" });
336
+ return () => releaseWriteClaim(
337
+ activePath,
338
+ claimName,
339
+ claimPath,
340
+ );
341
+ } catch (error) {
342
+ if (error?.code !== "EEXIST") throw error;
343
+ const activeClaimName = await activeWriteClaim(activePath);
344
+ const activeClaimPath = activeClaimName
345
+ ? join(lockPath, activeClaimName)
346
+ : undefined;
347
+ if (
348
+ !activeClaimPath ||
349
+ !activeClaims.includes(activeClaimName) ||
350
+ await abandonedWriteClaim(activeClaimPath)
351
+ ) {
352
+ await rm(activePath, { force: true });
353
+ }
354
+ }
355
+ }
356
+ if (Date.now() >= deadline) {
357
+ fail(
358
+ "package_write_locked",
359
+ "Timed out waiting for another Package writer",
360
+ { packagePath },
361
+ );
362
+ }
363
+ await delay(10);
364
+ }
365
+ } catch (error) {
366
+ await releaseWriteClaim(activePath, claimName, claimPath);
367
+ throw error;
368
+ }
369
+ }
370
+
371
+ async function lockTarget(locator) {
372
+ const requested = resolve(locator);
373
+ try {
374
+ return await realpath(requested);
375
+ } catch (error) {
376
+ if (error?.code === "ENOENT") return requested;
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ export async function openPackage(locator) {
382
+ const target = await lockTarget(locator);
383
+ const release = await acquireWriteLock(target);
384
+ try {
385
+ await recoverInterruptedCommit(target);
386
+ return await openPackageUnlocked(target);
387
+ } finally {
388
+ await release();
389
+ }
390
+ }
391
+
392
+ async function writeCandidate(
393
+ originalPath,
394
+ snapshot,
395
+ changedFiles,
396
+ deletedFiles,
397
+ blobWrites = new Map(),
398
+ ) {
399
+ const transactionPath = await mkdtemp(
400
+ join(dirname(originalPath), ".smallpen-transaction-"),
401
+ );
402
+ const candidatePath = join(transactionPath, "candidate.smallpen");
403
+ try {
404
+ await cp(originalPath, candidatePath, { recursive: true });
405
+ for (const entry of changedFiles) {
406
+ const target = join(candidatePath, entry);
407
+ const value =
408
+ entry === "manifest.json"
409
+ ? snapshot.manifest
410
+ : snapshot.entries[entry];
411
+ await mkdir(dirname(target), { recursive: true });
412
+ await writeFile(target, canonicalJSON(value), "utf8");
413
+ }
414
+ for (const entry of deletedFiles) {
415
+ const target = resolve(candidatePath, entry);
416
+ if (!isWithinRoot(candidatePath, target)) {
417
+ fail("invalid_entry_path", `Deleted entry escapes the Package: ${entry}`);
418
+ }
419
+ await rm(target, { force: true });
420
+ }
421
+ for (const [entry, bytes] of blobWrites) {
422
+ const target = resolve(candidatePath, entry);
423
+ if (!isWithinRoot(candidatePath, target)) {
424
+ fail("invalid_entry_path", `Media blob escapes the Package: ${entry}`);
425
+ }
426
+ await mkdir(dirname(target), { recursive: true });
427
+ await writeFile(target, bytes);
428
+ }
429
+ return { candidatePath, transactionPath };
430
+ } catch (error) {
431
+ await cleanup([transactionPath]);
432
+ throw error;
433
+ }
434
+ }
435
+
436
+ async function commitCandidate(originalPath, candidatePath, transactionPath) {
437
+ const backupPath = join(
438
+ dirname(originalPath),
439
+ `.${basename(originalPath)}.backup-${randomUUID()}`,
440
+ );
441
+ const journalPath = commitJournalPath(originalPath);
442
+ const journalTemporary = `${journalPath}.${randomUUID()}.tmp`;
443
+ try {
444
+ await writeFile(
445
+ journalTemporary,
446
+ JSON.stringify({ backupPath, candidatePath, transactionPath }),
447
+ { flag: "wx" },
448
+ );
449
+ await rename(journalTemporary, journalPath);
450
+ } catch (error) {
451
+ await cleanup([journalTemporary]);
452
+ throw error;
453
+ }
454
+ await rename(originalPath, backupPath);
455
+ try {
456
+ await rename(candidatePath, originalPath);
457
+ } catch (error) {
458
+ try {
459
+ await rename(backupPath, originalPath);
460
+ await cleanup([journalPath]);
461
+ } catch {
462
+ // Keep the journal so the next Package open can complete recovery.
463
+ }
464
+ throw error;
465
+ }
466
+ await cleanup([backupPath, transactionPath, journalPath]);
467
+ }
468
+
469
+ async function applyOperationBatchLocked(locator, batch) {
470
+ const before = await openPackageUnlocked(locator);
471
+ const blobWrites = batch.blobs ?? new Map();
472
+ if (!(blobWrites instanceof Map)) {
473
+ fail("invalid_blob_writes", "Operation Batch blobs must be a Map");
474
+ }
475
+ for (const [entry, bytes] of blobWrites) {
476
+ before.blobs.set(entry, bytes);
477
+ }
478
+ const prepared = await prepareOperationBatch(before, batch);
479
+ const referencedBlobs = new Set(
480
+ prepared.snapshot.manifest.entries.assets.flatMap((entry) =>
481
+ [
482
+ ...prepared.snapshot.entries[entry].media.map((media) => media.blob),
483
+ ...prepared.snapshot.entries[entry].fonts.flatMap((font) =>
484
+ font.variants.flatMap((variant) =>
485
+ Object.values(variant.files).map(({ blob }) => blob),
486
+ ),
487
+ ),
488
+ ],
489
+ ),
490
+ );
491
+ for (const entry of blobWrites.keys()) {
492
+ if (!referencedBlobs.has(entry)) {
493
+ fail(
494
+ "unreferenced_blob_write",
495
+ `Operation Batch binary blob is not referenced: ${entry}`,
496
+ );
497
+ }
498
+ }
499
+ if (
500
+ prepared.result.changedFiles.length === 0 &&
501
+ prepared.result.deletedFiles.length === 0
502
+ ) {
503
+ return prepared.result;
504
+ }
505
+
506
+ const transaction = await writeCandidate(
507
+ before.locator,
508
+ prepared.snapshot,
509
+ prepared.result.changedFiles,
510
+ prepared.result.deletedFiles,
511
+ blobWrites,
512
+ );
513
+ try {
514
+ const validated = await openPackage(transaction.candidatePath);
515
+ if (validated.revision !== prepared.result.revision) {
516
+ fail(
517
+ "candidate_revision_mismatch",
518
+ "Candidate Package revision changed during validation",
519
+ {
520
+ actualRevision: validated.revision,
521
+ expectedRevision: prepared.result.revision,
522
+ },
523
+ );
524
+ }
525
+ await commitCandidate(
526
+ before.locator,
527
+ transaction.candidatePath,
528
+ transaction.transactionPath,
529
+ );
530
+ return {
531
+ ...prepared.result,
532
+ changedFiles: [
533
+ ...new Set([...prepared.result.changedFiles, ...blobWrites.keys()]),
534
+ ].sort(),
535
+ };
536
+ } catch (error) {
537
+ await cleanup([transaction.transactionPath]);
538
+ throw error;
539
+ }
540
+ }
541
+
542
+ function batchLedgerPath(packagePath) {
543
+ return `${packagePath}.batches.json`;
544
+ }
545
+
546
+ async function readBatchLedger(packagePath) {
547
+ try {
548
+ const parsed = JSON.parse(
549
+ await readFile(batchLedgerPath(packagePath), "utf8"),
550
+ );
551
+ return parsed && typeof parsed === "object" ? parsed : {};
552
+ } catch (error) {
553
+ if (error?.code === "ENOENT") return {};
554
+ throw error;
555
+ }
556
+ }
557
+
558
+ async function writeBatchLedger(packagePath, ledger) {
559
+ const temporary = `${batchLedgerPath(packagePath)}.${randomUUID()}.tmp`;
560
+ await writeFile(temporary, `${JSON.stringify(ledger, null, 2)}\n`);
561
+ await rename(temporary, batchLedgerPath(packagePath));
562
+ }
563
+
564
+ // SP-039/040: a process-local batch ledger gives the public CLI stable
565
+ // idempotency. Replaying the same batch identity with the same operations
566
+ // returns the recorded confirmation without a second write; replaying the same
567
+ // identity with different operations is rejected with a correction path.
568
+ // Public replay check used by the CLI before stale-revision validation: an
569
+ // already-committed batch identity with identical operations replays its
570
+ // recorded confirmation instead of failing or writing again.
571
+ export async function replayRecordedBatch(locator, batch) {
572
+ const packagePath = await lockTarget(locator);
573
+ const release = await acquireWriteLock(packagePath);
574
+ try {
575
+ const resolvedPackagePath = await resolvePackageRoot(packagePath);
576
+ const ledger = await readBatchLedger(resolvedPackagePath);
577
+ const recorded = ledger[batch?.batchId];
578
+ if (!recorded) return undefined;
579
+ const operationsHash = await sha256Hex(
580
+ new TextEncoder().encode(canonicalJSON({ operations: batch.operations ?? [] })),
581
+ );
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
+ } finally {
596
+ await release();
597
+ }
598
+ }
599
+
600
+ export async function applyOperationBatch(locator, batch) {
601
+ const packagePath = await lockTarget(locator);
602
+ const release = await acquireWriteLock(packagePath);
603
+ try {
604
+ await recoverInterruptedCommit(packagePath);
605
+ const resolvedPackagePath = await resolvePackageRoot(packagePath);
606
+ const ledger = await readBatchLedger(resolvedPackagePath);
607
+ const recorded = ledger[batch?.batchId];
608
+ if (recorded) {
609
+ const operationsHash = await sha256Hex(
610
+ new TextEncoder().encode(canonicalJSON({ operations: batch.operations ?? [] })),
611
+ );
612
+ if (recorded.operationsHash !== operationsHash) {
613
+ fail(
614
+ "batch_id_conflict",
615
+ "Batch ID was already committed with different operations",
616
+ {
617
+ batchId: batch.batchId,
618
+ committedRevision: recorded.revision,
619
+ correction:
620
+ "Pick a new unique batchId for the different intent; do not reuse committed batch identities.",
621
+ },
622
+ );
623
+ }
624
+ return {
625
+ ...recorded.result,
626
+ alreadyApplied: true,
627
+ revision: recorded.result.revision,
628
+ };
629
+ }
630
+ const result = await applyOperationBatchLocked(resolvedPackagePath, batch);
631
+ if (!result.alreadyApplied && Array.isArray(batch?.operations)) {
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
+ }
647
+ }
648
+ return result;
649
+ } finally {
650
+ await release();
651
+ }
652
+ }
653
+
654
+ export async function importMedia(
655
+ locator,
656
+ { bytes, height, id, mimeType, name, path = "", width },
657
+ ) {
658
+ if (!(bytes instanceof Uint8Array)) {
659
+ fail("invalid_media_blob", "Imported Media bytes must be a Uint8Array");
660
+ }
661
+ const before = await openPackage(locator);
662
+ const entry = before.manifest.entries.assets[0] ?? "assets/assets.json";
663
+ const library = before.manifest.entries.assets[0]
664
+ ? structuredClone(before.entries[entry])
665
+ : { colors: [], fonts: [], id: "alib_design", media: [], typographies: [] };
666
+ if (library.media.some((media) => media.id === id)) {
667
+ fail("duplicate_media_id", `Media already exists: ${id}`);
668
+ }
669
+ const sha256 = await sha256Hex(bytes);
670
+ const blob = `blobs/${sha256}`;
671
+ const descriptor = {
672
+ blob,
673
+ byteLength: bytes.byteLength,
674
+ height,
675
+ id,
676
+ mimeType,
677
+ name,
678
+ path,
679
+ sha256,
680
+ width,
681
+ };
682
+ library.media.push(descriptor);
683
+ const result = await applyOperationBatch(locator, {
684
+ baseRevision: before.revision,
685
+ batchId: `import_${id}`,
686
+ blobs: new Map([[blob, bytes]]),
687
+ operations: [
688
+ {
689
+ ...(before.manifest.entries.assets[0] ? {} : { entry }),
690
+ library,
691
+ type: "replace-asset-library",
692
+ },
693
+ ],
694
+ });
695
+ return { descriptor, result };
696
+ }
697
+
698
+ export async function removeMedia(locator, mediaId) {
699
+ const before = await openPackage(locator);
700
+ if (!before.manifest.entries.assets[0]) {
701
+ fail("missing_media", `Media does not exist: ${mediaId}`);
702
+ }
703
+ const { entry, library } = assetLibraryForWrite(before);
704
+ if (!library.media.some((media) => media.id === mediaId)) {
705
+ fail("missing_media", `Media does not exist: ${mediaId}`);
706
+ }
707
+ library.media = library.media.filter((media) => media.id !== mediaId);
708
+ return replaceAssetLibrary(locator, before, entry, library, `remove_${mediaId}`);
709
+ }
710
+
711
+ function assetLibraryForWrite(snapshot) {
712
+ const entry = snapshot.manifest.entries.assets[0] ?? "assets/assets.json";
713
+ const library = snapshot.manifest.entries.assets[0]
714
+ ? structuredClone(snapshot.entries[entry])
715
+ : { colors: [], fonts: [], id: "alib_design", media: [], typographies: [] };
716
+ return { entry, library };
717
+ }
718
+
719
+ async function replaceAssetLibrary(locator, before, entry, library, batchId, blobs) {
720
+ return applyOperationBatch(locator, {
721
+ baseRevision: before.revision,
722
+ batchId,
723
+ ...(blobs ? { blobs } : {}),
724
+ operations: [
725
+ {
726
+ ...(before.manifest.entries.assets[0] ? {} : { entry }),
727
+ library,
728
+ type: "replace-asset-library",
729
+ },
730
+ ],
731
+ });
732
+ }
733
+
734
+ export async function importFontVariant(
735
+ locator,
736
+ { family, files, fontId, id, name, style, weight },
737
+ ) {
738
+ const before = await openPackage(locator);
739
+ const { entry, library } = assetLibraryForWrite(before);
740
+ let font = library.fonts.find((candidate) => candidate.id === fontId);
741
+ if (font && font.family !== family) {
742
+ fail("font_family_mismatch", `Font family does not match ${fontId}`);
743
+ }
744
+ if (!font) {
745
+ font = { family, id: fontId, variants: [] };
746
+ library.fonts.push(font);
747
+ }
748
+ if (
749
+ font.variants.some(
750
+ (variant) =>
751
+ variant.id === id ||
752
+ (variant.style === style && variant.weight === weight),
753
+ )
754
+ ) {
755
+ fail(
756
+ "duplicate_font_variant",
757
+ `Font Variant already exists: ${fontId}/${style}-${weight}`,
758
+ );
759
+ }
760
+ const blobs = new Map();
761
+ const descriptors = {};
762
+ for (const [format, { bytes, mimeType }] of Object.entries(files)) {
763
+ if (!(bytes instanceof Uint8Array)) {
764
+ fail("invalid_font_blob", `Imported Font ${format} must be a Uint8Array`);
765
+ }
766
+ const sha256 = await sha256Hex(bytes);
767
+ const blob = `blobs/${sha256}`;
768
+ blobs.set(blob, bytes);
769
+ descriptors[format] = {
770
+ blob,
771
+ byteLength: bytes.byteLength,
772
+ mimeType,
773
+ sha256,
774
+ };
775
+ }
776
+ if (!descriptors.woff) {
777
+ fail("missing_font_woff", "Imported Font Variant must include a WOFF file");
778
+ }
779
+ const variant = { files: descriptors, id, name, style, weight };
780
+ font.variants.push(variant);
781
+ const result = await replaceAssetLibrary(
782
+ locator,
783
+ before,
784
+ entry,
785
+ library,
786
+ `import_${id}`,
787
+ blobs,
788
+ );
789
+ return { result, variant };
790
+ }
791
+
792
+ export async function updateFontFamily(locator, fontId, family) {
793
+ const before = await openPackage(locator);
794
+ const { entry, library } = assetLibraryForWrite(before);
795
+ const font = library.fonts.find(({ id }) => id === fontId);
796
+ if (!font) fail("missing_font", `Font does not exist: ${fontId}`);
797
+ font.family = family;
798
+ return replaceAssetLibrary(
799
+ locator,
800
+ before,
801
+ entry,
802
+ library,
803
+ `update_${fontId}`,
804
+ );
805
+ }
806
+
807
+ export async function deleteFontFamily(locator, fontId) {
808
+ const before = await openPackage(locator);
809
+ const { entry, library } = assetLibraryForWrite(before);
810
+ const index = library.fonts.findIndex(({ id }) => id === fontId);
811
+ if (index < 0) fail("missing_font", `Font does not exist: ${fontId}`);
812
+ library.fonts.splice(index, 1);
813
+ return replaceAssetLibrary(
814
+ locator,
815
+ before,
816
+ entry,
817
+ library,
818
+ `delete_${fontId}`,
819
+ );
820
+ }
821
+
822
+ export async function deleteFontVariant(locator, fontVariantId) {
823
+ const before = await openPackage(locator);
824
+ const { entry, library } = assetLibraryForWrite(before);
825
+ const font = library.fonts.find((candidate) =>
826
+ candidate.variants.some(({ id }) => id === fontVariantId),
827
+ );
828
+ if (!font) {
829
+ fail("missing_font_variant", `Font Variant does not exist: ${fontVariantId}`);
830
+ }
831
+ font.variants = font.variants.filter(({ id }) => id !== fontVariantId);
832
+ if (font.variants.length === 0) {
833
+ library.fonts = library.fonts.filter(({ id }) => id !== font.id);
834
+ }
835
+ return replaceAssetLibrary(
836
+ locator,
837
+ before,
838
+ entry,
839
+ library,
840
+ `delete_${fontVariantId}`,
841
+ );
842
+ }