@glw907/cairn-cms 0.58.0 → 0.59.0

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.
@@ -29,3 +29,4 @@ export { default as RefreshCwIcon } from '@lucide/svelte/icons/refresh-cw';
29
29
  export { default as GitBranchIcon } from '@lucide/svelte/icons/git-branch';
30
30
  export { default as ArrowRightIcon } from '@lucide/svelte/icons/arrow-right';
31
31
  export { default as MegaphoneIcon } from '@lucide/svelte/icons/megaphone';
32
+ export { default as DatabaseIcon } from '@lucide/svelte/icons/database';
@@ -23,6 +23,8 @@ export type CairnLogEvent =
23
23
  | 'media.resolve_missing'
24
24
  | 'media.deleted'
25
25
  | 'media.delete_blocked'
26
+ | 'media.bulk_deleted'
27
+ | 'media.orphans_purged'
26
28
  | 'media.replaced'
27
29
  | 'media.replace_blocked'
28
30
  | 'media.alt_propagated';
@@ -0,0 +1,54 @@
1
+ // cairn-cms: the pure core of the bulk-delete safety floor. Given a STRICT usage index, the selected
2
+ // hashes, and the media manifest, it partitions the selection into what is safe to delete and what is
3
+ // skipped, with the reason. The gate is membership in the passed strict index, never a display count:
4
+ // the caller builds the index with strict:true (see usage.ts) so a transient branch-read failure
5
+ // fails the whole build rather than making a still-referenced asset look orphaned. This function
6
+ // stays pure so the same verdict is testable without a repo round trip and so the destructive action
7
+ // that consumes it can be reviewed against a fixed input.
8
+ import type { UsageEntry, UsageIndex } from './usage.js';
9
+ import type { MediaManifest } from './manifest.js';
10
+
11
+ /** One selected hash that is not deleted, with why and (for the where-used) its usage rows. The rows
12
+ * are present only for 'still-referenced'; an 'uncommitted' skip carries an empty list. */
13
+ export interface BulkDeleteSkip {
14
+ hash: string;
15
+ reason: 'still-referenced' | 'uncommitted';
16
+ usage: UsageEntry[];
17
+ }
18
+
19
+ /** The partitioned selection: the hashes safe to purge and the hashes held back. Both arrays keep the
20
+ * input order of `selected` so the screen reports them in the order the user picked. */
21
+ export interface BulkDeletePlan {
22
+ deletable: string[];
23
+ skipped: BulkDeleteSkip[];
24
+ }
25
+
26
+ /**
27
+ * Partition `selected` against a strict usage index and the media manifest.
28
+ *
29
+ * A hash with one or more usage rows is skipped 'still-referenced', carrying those rows for the
30
+ * where-used. A hash with no usage row and no committed manifest row is skipped 'uncommitted', since
31
+ * there is nothing committed to delete. A hash with no usage row and a committed manifest row is
32
+ * deletable. The input order of `selected` is preserved in both output arrays.
33
+ */
34
+ export function planBulkDelete(
35
+ selected: string[],
36
+ index: UsageIndex,
37
+ manifest: MediaManifest,
38
+ ): BulkDeletePlan {
39
+ const deletable: string[] = [];
40
+ const skipped: BulkDeleteSkip[] = [];
41
+
42
+ for (const hash of selected) {
43
+ const usage = index.get(hash);
44
+ if (usage && usage.length > 0) {
45
+ skipped.push({ hash, reason: 'still-referenced', usage });
46
+ } else if (manifest[hash]) {
47
+ deletable.push(hash);
48
+ } else {
49
+ skipped.push({ hash, reason: 'uncommitted', usage: [] });
50
+ }
51
+ }
52
+
53
+ return { deletable, skipped };
54
+ }
@@ -0,0 +1,74 @@
1
+ // cairn-cms: the orphan-scan projection, the pure model behind the admin Media Library's scan
2
+ // surface. It folds reconcileMedia's two directions together with the usage index into the two rows
3
+ // the screen renders: the purgeable byte-rows and the read-only broken-reference rows (manifest rows
4
+ // whose bytes are gone). It only projects; no path here reads R2, the manifest, or git. The module
5
+ // is engine-internal and on no public subpath.
6
+ //
7
+ // An orphaned byte is a stored R2 object whose hash has NO manifest row AND appears in NO usage row,
8
+ // so it is referenced nowhere across main and every open branch. Reconcile only checks main's
9
+ // manifest, so a branch-only upload (bytes in R2, manifest row only on the open cairn/* branch) gets
10
+ // flagged as an orphaned object even though a colleague's in-progress draft references it. The byte
11
+ // purge is irreversible, so we intersect reconcile's verdict with the strict cross-branch usage
12
+ // index here: any hash the index references is in use and is dropped from orphanedBytes, which keeps
13
+ // a live draft's bytes from ever reaching the purge surface.
14
+ import { MEDIA_KEY_RE, type ReconcileResult } from './reconcile.js';
15
+ import type { MediaManifest } from './manifest.js';
16
+ import type { UsageEntry, UsageIndex } from './usage.js';
17
+
18
+ /** A purgeable orphan: a stored R2 key with no manifest row, plus the 16-hex hash parsed from it. */
19
+ export interface OrphanByteRow {
20
+ /** The full R2 object key, e.g. "media/ff/ffffffffffffffff.webp". */
21
+ key: string;
22
+ /** The 16-hex content hash parsed from the key. */
23
+ hash: string;
24
+ }
25
+
26
+ /** A broken reference: a manifest row whose bytes are gone. Read-only, since purging it would drop a
27
+ * still-referenced asset's record; the screen shows where it is used so an operator can re-ingest. */
28
+ export interface BrokenRefRow {
29
+ /** The 16-hex content hash of the manifest row whose bytes are missing. */
30
+ hash: string;
31
+ /** The manifest row's display slug, or '' when the row is somehow absent. */
32
+ slug: string;
33
+ /** Where the asset is referenced, from the usage index. Empty when no reference was found. */
34
+ usage: UsageEntry[];
35
+ }
36
+
37
+ /** The scan surface model: the two row sets the Library renders. */
38
+ export interface OrphanScan {
39
+ orphanedBytes: OrphanByteRow[];
40
+ brokenRefs: BrokenRefRow[];
41
+ }
42
+
43
+ /**
44
+ * Project a reconcile read plus the usage index into the scan surface model.
45
+ *
46
+ * `orphanedBytes` come from `reconcile.orphanedObjects`: each key is parsed to its hash via the
47
+ * shared media-key grammar, and a key that does not match (so it is not a content-addressed media
48
+ * object) is skipped. A key whose hash the usage index references is also skipped: it is referenced
49
+ * on main or some open branch, so its bytes are in use, not orphaned. `brokenRefs` come from
50
+ * `reconcile.missingObjects`: each hash carries its
51
+ * manifest slug (falling back to '' when the row is absent) and its where-used rows from the index
52
+ * (an empty list when no reference was found). Both directions keep their input order.
53
+ */
54
+ export function buildOrphanScan(
55
+ reconcile: ReconcileResult,
56
+ manifest: MediaManifest,
57
+ index: UsageIndex,
58
+ ): OrphanScan {
59
+ const orphanedBytes: OrphanByteRow[] = [];
60
+ for (const key of reconcile.orphanedObjects) {
61
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
62
+ if (hash === undefined) continue;
63
+ if (index.has(hash)) continue;
64
+ orphanedBytes.push({ key, hash });
65
+ }
66
+
67
+ const brokenRefs: BrokenRefRow[] = reconcile.missingObjects.map((hash) => ({
68
+ hash,
69
+ slug: manifest[hash]?.slug ?? '',
70
+ usage: index.get(hash) ?? [],
71
+ }));
72
+
73
+ return { orphanedBytes, brokenRefs };
74
+ }
@@ -8,8 +8,9 @@
8
8
  import type { MediaManifest } from './manifest.js';
9
9
  import { log } from '../log/index.js';
10
10
 
11
- /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
12
- const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
11
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
12
+ * the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
13
+ export const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
13
14
 
14
15
  /** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
15
16
  * has no manifest row; `missingObjects` are manifest hashes with no stored object. */
@@ -186,6 +186,12 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
186
186
  mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
187
187
  mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
188
188
  mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
189
+ // Pass C library actions: a multi-select bulk delete, the on-demand orphan scan, and the
190
+ // irreversible byte purge. The component posts to `?/mediaBulkDelete`, `?/mediaOrphanScan`, and
191
+ // `?/mediaPurge` (the purge key is short of its content method name). All gate on the media view.
192
+ mediaBulkDelete: viewAction(['media'], (event) => content.mediaBulkDelete(contentEvent(event, {}))),
193
+ mediaOrphanScan: viewAction(['media'], (event) => content.mediaOrphanScan(contentEvent(event, {}))),
194
+ mediaPurge: viewAction(['media'], (event) => content.mediaPurgeOrphans(contentEvent(event, {}))),
189
195
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
190
196
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
191
197
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
@@ -29,10 +29,14 @@ import { mediaLibraryEntry } from '../media/library-entry.js';
29
29
  import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
30
30
  import { buildUsageIndex } from '../media/usage.js';
31
31
  import type { UsageEntry } from '../media/usage.js';
32
+ import { runReconcile, MEDIA_KEY_RE, type ReconcileBucket } from '../media/reconcile.js';
33
+ import { buildOrphanScan, type OrphanScan } from '../media/orphan-scan.js';
32
34
  import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
33
35
  import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
34
36
  import { planMediaRewrite } from '../media/rewrite-plan.js';
35
37
  import type { BranchRef } from '../media/rewrite-plan.js';
38
+ import { planBulkDelete } from '../media/bulk-delete-plan.js';
39
+ import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
36
40
  import type { CookieJar, EventBase } from './types.js';
37
41
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
38
42
  import type { Editor, Role } from '../auth/types.js';
@@ -161,9 +165,10 @@ export interface MediaLibraryData {
161
165
  * redirected commit conflict never overwrite each other. */
162
166
  error: string | null;
163
167
  /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
164
- * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`, null
165
- * otherwise. The component renders a polite success strip for each. */
166
- flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | null;
168
+ * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
169
+ * `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
170
+ * The component renders a polite success strip for each. */
171
+ flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
167
172
  /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
168
173
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
169
174
  flashError: string | null;
@@ -248,6 +253,30 @@ export interface MediaAltPropagateFailure {
248
253
  error: string;
249
254
  }
250
255
 
256
+ /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
257
+ * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
258
+ * returned summary, not a fail. */
259
+ export interface MediaBulkFailure {
260
+ error: string;
261
+ }
262
+
263
+ /** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
264
+ * partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
265
+ * not on the package subpath, so no reference page. */
266
+ export interface MediaBulkDeleteResult {
267
+ deleted: string[];
268
+ skipped: BulkDeleteSkip[];
269
+ failed: { hash: string; error: string }[];
270
+ }
271
+
272
+ /** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
273
+ * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
274
+ export interface MediaOrphanPurgeResult {
275
+ purged: string[];
276
+ skippedClaimed: string[];
277
+ failed: { key: string; error: string }[];
278
+ }
279
+
251
280
  /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
252
281
  * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
253
282
  * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
@@ -312,7 +341,7 @@ export interface MediaAltPreviewPlan {
312
341
  * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
313
342
  * refusal without a second type. */
314
343
  export type ContentFormFailure = Partial<
315
- SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure
344
+ SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure
316
345
  >;
317
346
 
318
347
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
@@ -546,6 +575,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
546
575
  else if (event.url.searchParams.get('updated') === '1') flash = 'updated';
547
576
  else if (event.url.searchParams.get('replaced') === '1') flash = 'replaced';
548
577
  else if (event.url.searchParams.get('altPropagated') === '1') flash = 'altPropagated';
578
+ else if (event.url.searchParams.get('bulkDeleted') === '1') flash = 'bulkDeleted';
579
+ else if (event.url.searchParams.get('orphansPurged') === '1') flash = 'orphansPurged';
549
580
  const flashError = event.url.searchParams.get('error');
550
581
  let token: string;
551
582
  try {
@@ -1493,6 +1524,263 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1493
1524
  throw redirect(303, '/admin/media?deleted=1');
1494
1525
  }
1495
1526
 
1527
+ /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1528
+ * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1529
+ * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1530
+ * would blow the workerd connection budget at many open branches). The fail-closed posture is for
1531
+ * the WHOLE batch: if that single strict index cannot complete, the action refuses everything and
1532
+ * commits nothing, rather than risk deleting bytes a branch still references.
1533
+ *
1534
+ * Skip-and-report, never force: the pure planBulkDelete partitions the selection against the strict
1535
+ * index into deletable (no usage row, a committed manifest row exists), skipped-still-referenced (a
1536
+ * usage row, carried for the where-used), and skipped-uncommitted (no manifest row). An in-use item
1537
+ * is skipped and reported, never bulk-force-deleted; forced in-use deletion stays the single-item
1538
+ * typed-slug path.
1539
+ *
1540
+ * The order is load-bearing, mirroring single delete: ONE atomic commit removes every deletable row
1541
+ * FIRST, then the R2 objects are deleted (commit-row-then-delete-R2). A failure after the commit
1542
+ * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1543
+ * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1544
+ * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1545
+ * (deleted / skipped with reasons / failed); there is no success redirect. */
1546
+ async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
1547
+ const editor = requireSession(event);
1548
+ const token = await mintToken(event.platform?.env ?? {});
1549
+
1550
+ // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1551
+ // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
1552
+ // dropped silently rather than surfaced as a skip (it was never a real selection).
1553
+ const form = await event.request.formData();
1554
+ let raw = form.getAll('hash').map(String);
1555
+ if (raw.length === 0) {
1556
+ const json = form.get('hashes');
1557
+ if (typeof json === 'string') {
1558
+ try {
1559
+ const parsed: unknown = JSON.parse(json);
1560
+ if (Array.isArray(parsed)) raw = parsed.map(String);
1561
+ } catch {
1562
+ raw = [];
1563
+ }
1564
+ }
1565
+ }
1566
+ const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
1567
+
1568
+ // Read the fresh media manifest (the deletable rows come from here, by hash).
1569
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1570
+
1571
+ // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
1572
+ // the commit, exactly like single delete.
1573
+ const resolved = runtime.resolvedAssets;
1574
+ if (!resolved.enabled) {
1575
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1576
+ }
1577
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1578
+ const rawBucket = platformEnv[resolved.bucketBinding];
1579
+ if (!rawBucket) {
1580
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1581
+ }
1582
+ const store = r2Store(rawBucket as R2Bucket);
1583
+
1584
+ // THE fail-closed gate for the whole batch: one shared strict usage index. STRICT mode rethrows a
1585
+ // branch-read failure, so a transient branch read failing refuses the whole batch rather than
1586
+ // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
1587
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1588
+ try {
1589
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1590
+ } catch {
1591
+ return fail(503, { error: 'Could not verify where these assets are used. Try again.' } satisfies MediaBulkFailure);
1592
+ }
1593
+
1594
+ // The pure partition: membership in the fresh strict index is the gate, never the display count.
1595
+ const plan = planBulkDelete(selected, index, manifest);
1596
+ // An all-skipped or empty batch is a no-op success: nothing committed, nothing deleted.
1597
+ if (plan.deletable.length === 0) {
1598
+ return { deleted: [], skipped: plan.skipped, failed: [] } satisfies MediaBulkDeleteResult;
1599
+ }
1600
+
1601
+ // ONE atomic commit removing EVERY deletable row, folded over removeMediaEntry.
1602
+ let next = manifest;
1603
+ for (const hash of plan.deletable) next = removeMediaEntry(next, hash);
1604
+ const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
1605
+ try {
1606
+ await commitFiles(
1607
+ runtime.backend,
1608
+ [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }],
1609
+ { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } },
1610
+ token,
1611
+ );
1612
+ log.info('commit.succeeded', commitFields);
1613
+ } catch (err) {
1614
+ commitFailure(commitFields, err, '/admin/media',
1615
+ 'The media manifest changed since you opened it. Reload and try again.');
1616
+ }
1617
+
1618
+ // THEN delete each deletable hash's R2 object (the load-bearing order, see the docstring). Best
1619
+ // effort and batch-resilient: a thrown key derivation or a delete error is reported in `failed`
1620
+ // and the loop continues. An absent object is a no-op (the R2 contract).
1621
+ const deleted: string[] = [];
1622
+ const failed: { hash: string; error: string }[] = [];
1623
+ for (const hash of plan.deletable) {
1624
+ try {
1625
+ const row = manifest[hash];
1626
+ await store.delete(r2Key(row.hash, row.ext));
1627
+ deleted.push(hash);
1628
+ } catch (err) {
1629
+ failed.push({ hash, error: err instanceof Error ? err.message : String(err) });
1630
+ }
1631
+ }
1632
+
1633
+ log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
1634
+ return { deleted, skipped: plan.skipped, failed } satisfies MediaBulkDeleteResult;
1635
+ }
1636
+
1637
+ /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1638
+ * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1639
+ * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1640
+ * plus a reconcile pass on top of the strict usage build.
1641
+ *
1642
+ * Detection-time fail-closed: BOTH the reconcile and the strict usage build run inside one
1643
+ * try/catch, and any throw refuses the whole scan with fail(503) rather than returning a partial
1644
+ * result. The reconcile must not run on a half-listed bucket: a truncated R2 list would call
1645
+ * still-stored bytes orphaned. The strict usage build must not run on a half-read branch set: an
1646
+ * unread branch would make a branch-referenced asset look orphaned. A wrong orphan verdict here
1647
+ * feeds the irreversible purge, so the scan refuses rather than risk it.
1648
+ *
1649
+ * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1650
+ * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1651
+ * where-used so an operator can re-ingest rather than purge a still-referenced record). */
1652
+ async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
1653
+ requireSession(event);
1654
+ const token = await mintToken(event.platform?.env ?? {});
1655
+
1656
+ // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
1657
+ // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
1658
+ const resolved = runtime.resolvedAssets;
1659
+ if (!resolved.enabled) {
1660
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1661
+ }
1662
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1663
+ const rawBucket = platformEnv[resolved.bucketBinding];
1664
+ if (!rawBucket) {
1665
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1666
+ }
1667
+
1668
+ // Read the fresh media manifest for the reconcile's manifest side.
1669
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1670
+
1671
+ // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
1672
+ // and the strict usage build (a branch read that must complete in full) are both unsafe to use
1673
+ // partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
1674
+ // would feed the irreversible purge.
1675
+ let reconcile: Awaited<ReturnType<typeof runReconcile>>;
1676
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1677
+ try {
1678
+ reconcile = await runReconcile(rawBucket as unknown as ReconcileBucket, manifest);
1679
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1680
+ } catch {
1681
+ return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' } satisfies MediaBulkFailure);
1682
+ }
1683
+
1684
+ return buildOrphanScan(reconcile, manifest, index);
1685
+ }
1686
+
1687
+ /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1688
+ * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1689
+ * history. The whole action is built around that fact.
1690
+ *
1691
+ * The typed-count confirm is the never-bypassable gate, the analogue of single delete's typed-slug
1692
+ * check. The form's `confirm` must equal the count of selected keys (the approved rev.2 mockup's
1693
+ * "Type N to purge these files for good"); an empty selection or a mismatched count deletes nothing.
1694
+ *
1695
+ * Re-derive fresh is the safety crux. The selection came from an earlier scan, so the action does
1696
+ * NOT trust it: the purge keys are client-posted, so the server cannot assume they came from a fresh
1697
+ * scan. It reads the current media manifest AND rebuilds ONE strict cross-branch usage index, then
1698
+ * for each selected key parses the hash from the key grammar. A key that does not match the grammar
1699
+ * was never a real orphan key and is dropped silently. A key whose hash now has a manifest row OR is
1700
+ * referenced on any open cairn/* branch survived the scan window (it was claimed by a row, or a
1701
+ * draft started referencing those bytes), so it is skipped into skippedClaimed and its bytes survive.
1702
+ * Only a key whose hash is STILL absent from both is purged. This closes the TOCTOU between scan and
1703
+ * purge that could otherwise irreversibly delete a live draft's bytes.
1704
+ *
1705
+ * Like the scan and the bulk delete, the strict index build is the fail-closed gate: a branch read
1706
+ * that throws refuses the whole batch with fail(503) rather than mistaking an unverifiable reference
1707
+ * for an absent one. The index is built exactly once for the batch, never once per key.
1708
+ *
1709
+ * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1710
+ * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1711
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
1712
+ async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
1713
+ const editor = requireSession(event);
1714
+ const token = await mintToken(event.platform?.env ?? {});
1715
+
1716
+ // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
1717
+ // deletes through the MediaStore seam, so wrap the raw binding.
1718
+ const resolved = runtime.resolvedAssets;
1719
+ if (!resolved.enabled) {
1720
+ return fail(503, { error: 'Media is not enabled for this site.' } satisfies MediaBulkFailure);
1721
+ }
1722
+ const platformEnv = (event.platform as { env?: Record<string, unknown> } | undefined)?.env ?? {};
1723
+ const rawBucket = platformEnv[resolved.bucketBinding];
1724
+ if (!rawBucket) {
1725
+ return fail(503, { error: 'The media bucket is not bound.' } satisfies MediaBulkFailure);
1726
+ }
1727
+ const store = r2Store(rawBucket as R2Bucket);
1728
+
1729
+ // Read the selected R2 keys and the typed confirm.
1730
+ const form = await event.request.formData();
1731
+ const keys = form.getAll('key').map(String);
1732
+ const confirm = String(form.get('confirm') ?? '');
1733
+
1734
+ // The irreversible gate: the confirm must equal the selected count, and the set must be non-empty.
1735
+ // A mismatch or an empty set refuses and deletes NOTHING.
1736
+ if (keys.length === 0 || confirm !== String(keys.length)) {
1737
+ return fail(400, { error: 'Type the number of files to confirm the purge.' } satisfies MediaBulkFailure);
1738
+ }
1739
+
1740
+ // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
1741
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1742
+
1743
+ // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
1744
+ // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
1745
+ // branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
1746
+ // treated as a true orphan. Build exactly one index, never one per key.
1747
+ let index: Awaited<ReturnType<typeof buildUsageIndex>>;
1748
+ try {
1749
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1750
+ } catch {
1751
+ return fail(503, { error: 'Could not verify where these files are used. Try again.' } satisfies MediaBulkFailure);
1752
+ }
1753
+
1754
+ const purged: string[] = [];
1755
+ const skippedClaimed: string[] = [];
1756
+ const failed: { key: string; error: string }[] = [];
1757
+ for (const key of keys) {
1758
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
1759
+ // A key that does not match the grammar was never a real orphan key: drop it silently.
1760
+ if (hash === undefined) continue;
1761
+ // A hash that now has a manifest row was claimed since the scan: its bytes are a live asset now.
1762
+ if (manifest[hash]) {
1763
+ skippedClaimed.push(key);
1764
+ continue;
1765
+ }
1766
+ // A hash referenced on any open cairn/* branch backs an in-progress draft: skip it claimed too.
1767
+ if (index.has(hash)) {
1768
+ skippedClaimed.push(key);
1769
+ continue;
1770
+ }
1771
+ // Still orphaned: delete the object directly. No commit, there is no manifest row.
1772
+ try {
1773
+ await store.delete(key);
1774
+ purged.push(key);
1775
+ } catch (err) {
1776
+ failed.push({ key, error: err instanceof Error ? err.message : String(err) });
1777
+ }
1778
+ }
1779
+
1780
+ log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
1781
+ return { purged, skippedClaimed, failed } satisfies MediaOrphanPurgeResult;
1782
+ }
1783
+
1496
1784
  /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1497
1785
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1498
1786
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
@@ -1881,7 +2169,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1881
2169
  throw redirect(303, '/admin/media?altPropagated=1');
1882
2170
  }
1883
2171
 
1884
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
2172
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
1885
2173
  }
1886
2174
 
1887
2175
  /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
@@ -22,6 +22,7 @@ export type {
22
22
  MediaUpdateFailure,
23
23
  MediaReplaceFailure,
24
24
  MediaAltPropagateFailure,
25
+ MediaBulkFailure,
25
26
  ContentFormFailure,
26
27
  UploadResult,
27
28
  } from './content-routes.js';