@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.
@@ -26,8 +26,11 @@ import { r2Store } from '../media/store.js';
26
26
  import { parseMediaEntries, parseMediaManifest, upsertMediaEntry, removeMediaEntry, serializeMediaManifest } from '../media/manifest.js';
27
27
  import { mediaLibraryEntry } from '../media/library-entry.js';
28
28
  import { buildUsageIndex } from '../media/usage.js';
29
+ import { runReconcile, MEDIA_KEY_RE } from '../media/reconcile.js';
30
+ import { buildOrphanScan } from '../media/orphan-scan.js';
29
31
  import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
30
32
  import { planMediaRewrite } from '../media/rewrite-plan.js';
33
+ import { planBulkDelete } from '../media/bulk-delete-plan.js';
31
34
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
32
35
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
33
36
  * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
@@ -233,6 +236,10 @@ export function createContentRoutes(runtime, deps = {}) {
233
236
  flash = 'replaced';
234
237
  else if (event.url.searchParams.get('altPropagated') === '1')
235
238
  flash = 'altPropagated';
239
+ else if (event.url.searchParams.get('bulkDeleted') === '1')
240
+ flash = 'bulkDeleted';
241
+ else if (event.url.searchParams.get('orphansPurged') === '1')
242
+ flash = 'orphansPurged';
236
243
  const flashError = event.url.searchParams.get('error');
237
244
  let token;
238
245
  try {
@@ -1073,6 +1080,245 @@ export function createContentRoutes(runtime, deps = {}) {
1073
1080
  log.info('media.deleted', { editor: editor.email, hash });
1074
1081
  throw redirect(303, '/admin/media?deleted=1');
1075
1082
  }
1083
+ /** Bulk safe-delete a multi-select of committed media assets. This is mediaDeleteAction extended to
1084
+ * many items, with the same safety primitives and one rule that defines the batch: the gate is ONE
1085
+ * shared strict cross-branch usage index built per batch, never N per-item reads (N strict reads
1086
+ * would blow the workerd connection budget at many open branches). The fail-closed posture is for
1087
+ * the WHOLE batch: if that single strict index cannot complete, the action refuses everything and
1088
+ * commits nothing, rather than risk deleting bytes a branch still references.
1089
+ *
1090
+ * Skip-and-report, never force: the pure planBulkDelete partitions the selection against the strict
1091
+ * index into deletable (no usage row, a committed manifest row exists), skipped-still-referenced (a
1092
+ * usage row, carried for the where-used), and skipped-uncommitted (no manifest row). An in-use item
1093
+ * is skipped and reported, never bulk-force-deleted; forced in-use deletion stays the single-item
1094
+ * typed-slug path.
1095
+ *
1096
+ * The order is load-bearing, mirroring single delete: ONE atomic commit removes every deletable row
1097
+ * FIRST, then the R2 objects are deleted (commit-row-then-delete-R2). A failure after the commit
1098
+ * leaves bytes with no row (a benign orphan) rather than a row pointing at deleted bytes. Each R2
1099
+ * delete is best-effort and batch-resilient: a per-object error is reported in `failed` and never
1100
+ * aborts the rest of the batch. The result is an itemized 207-style summary the component renders
1101
+ * (deleted / skipped with reasons / failed); there is no success redirect. */
1102
+ async function mediaBulkDelete(event) {
1103
+ const editor = requireSession(event);
1104
+ const token = await mintToken(event.platform?.env ?? {});
1105
+ // Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
1106
+ // `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
1107
+ // dropped silently rather than surfaced as a skip (it was never a real selection).
1108
+ const form = await event.request.formData();
1109
+ let raw = form.getAll('hash').map(String);
1110
+ if (raw.length === 0) {
1111
+ const json = form.get('hashes');
1112
+ if (typeof json === 'string') {
1113
+ try {
1114
+ const parsed = JSON.parse(json);
1115
+ if (Array.isArray(parsed))
1116
+ raw = parsed.map(String);
1117
+ }
1118
+ catch {
1119
+ raw = [];
1120
+ }
1121
+ }
1122
+ }
1123
+ const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
1124
+ // Read the fresh media manifest (the deletable rows come from here, by hash).
1125
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1126
+ // Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
1127
+ // the commit, exactly like single delete.
1128
+ const resolved = runtime.resolvedAssets;
1129
+ if (!resolved.enabled) {
1130
+ return fail(503, { error: 'Media is not enabled for this site.' });
1131
+ }
1132
+ const platformEnv = event.platform?.env ?? {};
1133
+ const rawBucket = platformEnv[resolved.bucketBinding];
1134
+ if (!rawBucket) {
1135
+ return fail(503, { error: 'The media bucket is not bound.' });
1136
+ }
1137
+ const store = r2Store(rawBucket);
1138
+ // THE fail-closed gate for the whole batch: one shared strict usage index. STRICT mode rethrows a
1139
+ // branch-read failure, so a transient branch read failing refuses the whole batch rather than
1140
+ // mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
1141
+ let index;
1142
+ try {
1143
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1144
+ }
1145
+ catch {
1146
+ return fail(503, { error: 'Could not verify where these assets are used. Try again.' });
1147
+ }
1148
+ // The pure partition: membership in the fresh strict index is the gate, never the display count.
1149
+ const plan = planBulkDelete(selected, index, manifest);
1150
+ // An all-skipped or empty batch is a no-op success: nothing committed, nothing deleted.
1151
+ if (plan.deletable.length === 0) {
1152
+ return { deleted: [], skipped: plan.skipped, failed: [] };
1153
+ }
1154
+ // ONE atomic commit removing EVERY deletable row, folded over removeMediaEntry.
1155
+ let next = manifest;
1156
+ for (const hash of plan.deletable)
1157
+ next = removeMediaEntry(next, hash);
1158
+ const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
1159
+ try {
1160
+ await commitFiles(runtime.backend, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }], { message: `Delete ${plan.deletable.length} media assets`, author: { name: editor.displayName, email: editor.email } }, token);
1161
+ log.info('commit.succeeded', commitFields);
1162
+ }
1163
+ catch (err) {
1164
+ commitFailure(commitFields, err, '/admin/media', 'The media manifest changed since you opened it. Reload and try again.');
1165
+ }
1166
+ // THEN delete each deletable hash's R2 object (the load-bearing order, see the docstring). Best
1167
+ // effort and batch-resilient: a thrown key derivation or a delete error is reported in `failed`
1168
+ // and the loop continues. An absent object is a no-op (the R2 contract).
1169
+ const deleted = [];
1170
+ const failed = [];
1171
+ for (const hash of plan.deletable) {
1172
+ try {
1173
+ const row = manifest[hash];
1174
+ await store.delete(r2Key(row.hash, row.ext));
1175
+ deleted.push(hash);
1176
+ }
1177
+ catch (err) {
1178
+ failed.push({ hash, error: err instanceof Error ? err.message : String(err) });
1179
+ }
1180
+ }
1181
+ log.info('media.bulk_deleted', { editor: editor.email, deleted: deleted.length, skipped: plan.skipped.length });
1182
+ return { deleted, skipped: plan.skipped, failed };
1183
+ }
1184
+ /** The on-demand orphan scan: a read-only reconcile of stored R2 bytes against the manifest, joined
1185
+ * with one strict cross-branch usage index for the broken-reference where-used. It runs only when
1186
+ * requested, never on the loaded index, because it is heavier than the load path: a full R2 list
1187
+ * plus a reconcile pass on top of the strict usage build.
1188
+ *
1189
+ * Detection-time fail-closed: BOTH the reconcile and the strict usage build run inside one
1190
+ * try/catch, and any throw refuses the whole scan with fail(503) rather than returning a partial
1191
+ * result. The reconcile must not run on a half-listed bucket: a truncated R2 list would call
1192
+ * still-stored bytes orphaned. The strict usage build must not run on a half-read branch set: an
1193
+ * unread branch would make a branch-referenced asset look orphaned. A wrong orphan verdict here
1194
+ * feeds the irreversible purge, so the scan refuses rather than risk it.
1195
+ *
1196
+ * The result is the OrphanScan projection: orphanedBytes (stored keys with no manifest row, the
1197
+ * purge surface) and brokenRefs (manifest rows whose bytes are gone, read-only, shown with their
1198
+ * where-used so an operator can re-ingest rather than purge a still-referenced record). */
1199
+ async function mediaOrphanScan(event) {
1200
+ requireSession(event);
1201
+ const token = await mintToken(event.platform?.env ?? {});
1202
+ // Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
1203
+ // the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
1204
+ const resolved = runtime.resolvedAssets;
1205
+ if (!resolved.enabled) {
1206
+ return fail(503, { error: 'Media is not enabled for this site.' });
1207
+ }
1208
+ const platformEnv = event.platform?.env ?? {};
1209
+ const rawBucket = platformEnv[resolved.bucketBinding];
1210
+ if (!rawBucket) {
1211
+ return fail(503, { error: 'The media bucket is not bound.' });
1212
+ }
1213
+ // Read the fresh media manifest for the reconcile's manifest side.
1214
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1215
+ // THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
1216
+ // and the strict usage build (a branch read that must complete in full) are both unsafe to use
1217
+ // partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
1218
+ // would feed the irreversible purge.
1219
+ let reconcile;
1220
+ let index;
1221
+ try {
1222
+ reconcile = await runReconcile(rawBucket, manifest);
1223
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1224
+ }
1225
+ catch {
1226
+ return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' });
1227
+ }
1228
+ return buildOrphanScan(reconcile, manifest, index);
1229
+ }
1230
+ /** Purge orphaned R2 bytes: the one IRREVERSIBLE media action. Raw object bytes live only in R2, not
1231
+ * in git, so a purged orphan cannot be recovered the way a deleted manifest row can be reverted in
1232
+ * history. The whole action is built around that fact.
1233
+ *
1234
+ * The typed-count confirm is the never-bypassable gate, the analogue of single delete's typed-slug
1235
+ * check. The form's `confirm` must equal the count of selected keys (the approved rev.2 mockup's
1236
+ * "Type N to purge these files for good"); an empty selection or a mismatched count deletes nothing.
1237
+ *
1238
+ * Re-derive fresh is the safety crux. The selection came from an earlier scan, so the action does
1239
+ * NOT trust it: the purge keys are client-posted, so the server cannot assume they came from a fresh
1240
+ * scan. It reads the current media manifest AND rebuilds ONE strict cross-branch usage index, then
1241
+ * for each selected key parses the hash from the key grammar. A key that does not match the grammar
1242
+ * was never a real orphan key and is dropped silently. A key whose hash now has a manifest row OR is
1243
+ * referenced on any open cairn/* branch survived the scan window (it was claimed by a row, or a
1244
+ * draft started referencing those bytes), so it is skipped into skippedClaimed and its bytes survive.
1245
+ * Only a key whose hash is STILL absent from both is purged. This closes the TOCTOU between scan and
1246
+ * purge that could otherwise irreversibly delete a live draft's bytes.
1247
+ *
1248
+ * Like the scan and the bulk delete, the strict index build is the fail-closed gate: a branch read
1249
+ * that throws refuses the whole batch with fail(503) rather than mistaking an unverifiable reference
1250
+ * for an absent one. The index is built exactly once for the batch, never once per key.
1251
+ *
1252
+ * There is no commit. An orphan by definition has no manifest row to remove, so the purge deletes
1253
+ * the R2 object directly. Each delete is best-effort and batch-resilient: a per-object error is
1254
+ * reported in `failed` and the loop continues; an absent object is a no-op (the R2 contract). */
1255
+ async function mediaPurgeOrphans(event) {
1256
+ const editor = requireSession(event);
1257
+ const token = await mintToken(event.platform?.env ?? {});
1258
+ // Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
1259
+ // deletes through the MediaStore seam, so wrap the raw binding.
1260
+ const resolved = runtime.resolvedAssets;
1261
+ if (!resolved.enabled) {
1262
+ return fail(503, { error: 'Media is not enabled for this site.' });
1263
+ }
1264
+ const platformEnv = event.platform?.env ?? {};
1265
+ const rawBucket = platformEnv[resolved.bucketBinding];
1266
+ if (!rawBucket) {
1267
+ return fail(503, { error: 'The media bucket is not bound.' });
1268
+ }
1269
+ const store = r2Store(rawBucket);
1270
+ // Read the selected R2 keys and the typed confirm.
1271
+ const form = await event.request.formData();
1272
+ const keys = form.getAll('key').map(String);
1273
+ const confirm = String(form.get('confirm') ?? '');
1274
+ // The irreversible gate: the confirm must equal the selected count, and the set must be non-empty.
1275
+ // A mismatch or an empty set refuses and deletes NOTHING.
1276
+ if (keys.length === 0 || confirm !== String(keys.length)) {
1277
+ return fail(400, { error: 'Type the number of files to confirm the purge.' });
1278
+ }
1279
+ // Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
1280
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1281
+ // THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
1282
+ // with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
1283
+ // branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
1284
+ // treated as a true orphan. Build exactly one index, never one per key.
1285
+ let index;
1286
+ try {
1287
+ index = await buildUsageIndex(runtime.backend, token, runtime.concepts, await readManifest(token), { strict: true });
1288
+ }
1289
+ catch {
1290
+ return fail(503, { error: 'Could not verify where these files are used. Try again.' });
1291
+ }
1292
+ const purged = [];
1293
+ const skippedClaimed = [];
1294
+ const failed = [];
1295
+ for (const key of keys) {
1296
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
1297
+ // A key that does not match the grammar was never a real orphan key: drop it silently.
1298
+ if (hash === undefined)
1299
+ continue;
1300
+ // A hash that now has a manifest row was claimed since the scan: its bytes are a live asset now.
1301
+ if (manifest[hash]) {
1302
+ skippedClaimed.push(key);
1303
+ continue;
1304
+ }
1305
+ // A hash referenced on any open cairn/* branch backs an in-progress draft: skip it claimed too.
1306
+ if (index.has(hash)) {
1307
+ skippedClaimed.push(key);
1308
+ continue;
1309
+ }
1310
+ // Still orphaned: delete the object directly. No commit, there is no manifest row.
1311
+ try {
1312
+ await store.delete(key);
1313
+ purged.push(key);
1314
+ }
1315
+ catch (err) {
1316
+ failed.push({ key, error: err instanceof Error ? err.message : String(err) });
1317
+ }
1318
+ }
1319
+ log.info('media.orphans_purged', { editor: editor.email, purged: purged.length });
1320
+ return { purged, skippedClaimed, failed };
1321
+ }
1076
1322
  /** Edit a committed asset's metadata: its display name, slug, and default alt. A single media.json
1077
1323
  * row commit, with NO reference rewrite: the resolver and the delivery route key on the hash, so a
1078
1324
  * rename never breaks an existing `media:` reference. The default alt is the asset's value for the
@@ -1425,7 +1671,7 @@ export function createContentRoutes(runtime, deps = {}) {
1425
1671
  }
1426
1672
  throw redirect(303, '/admin/media?altPropagated=1');
1427
1673
  }
1428
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
1674
+ 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 };
1429
1675
  }
1430
1676
  /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1431
1677
  * so a generous cap rejects only abuse-scale input. */
@@ -3,7 +3,7 @@ export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './a
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
5
  export { createMediaRoute } from './media-route.js';
6
- export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, MediaReplaceFailure, MediaAltPropagateFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
6
+ export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, MediaReplaceFailure, MediaAltPropagateFailure, MediaBulkFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
7
7
  export { createNavRoutes } from './nav-routes.js';
8
8
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
9
9
  export { parseAdminPath, type AdminView } from './admin-dispatch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [