@pramen/cms 0.0.61 → 0.0.64

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/src/index.ts CHANGED
@@ -50,7 +50,7 @@ import {
50
50
  resolveSecret,
51
51
  authorizeHandler,
52
52
  } from "@pramen/server";
53
- import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef, FieldDef, FieldType } from "@pramen/server";
53
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, DataMigration, MigrationContext, JsonValue, SchemaDef, FieldDef, FieldType, WhereClause } from "@pramen/server";
54
54
  import type { EnvBag } from "@pramen/server";
55
55
  import { isSafeHref, normalizeHref } from "./href";
56
56
  import { NAV_ORDER } from "./nav";
@@ -1023,6 +1023,18 @@ export const cmsSchema = {
1023
1023
  // Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
1024
1024
  // write. Enforced in the handler, not the schema — one term table serves both.
1025
1025
  hierarchical: defaultTo(t.bool(), false),
1026
+ // What this vocabulary classifies — a subset of `TAXONOMY_TARGETS`. NULL means EVERYTHING,
1027
+ // which is both the backward-compatible reading for rows written before this column and a
1028
+ // legitimate authored value ("Topics classifies whatever there is"). Without it a site with
1029
+ // "Categories" for articles and tags for images offers both on both, so a photo can be
1030
+ // filed under "Local news" and `getTermTree("category")` fills up with terms like "hero"
1031
+ // that no page listing will ever use.
1032
+ //
1033
+ // A `t.json()` array rather than two booleans: the set of things a CMS classifies grows
1034
+ // (collections are the obvious next one), and a column per target would need a migration
1035
+ // each time. It is not queryable — JSON in a TEXT cell — but nothing pages by taxonomy:
1036
+ // `listTaxonomies` reads them all and narrows in memory.
1037
+ appliesTo: t.json(),
1026
1038
  createdAt: defaultTo(t.text(), expr.now()),
1027
1039
  })),
1028
1040
 
@@ -1045,6 +1057,7 @@ export const cmsSchema = {
1045
1057
  taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
1046
1058
  parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
1047
1059
  pages: r.hasMany("cms_page_terms", "termId"),
1060
+ media: r.hasMany("cms_media_terms", "termId"),
1048
1061
  }),
1049
1062
  // A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
1050
1063
  // are two different terms, and both are legitimate.
@@ -1069,6 +1082,28 @@ export const cmsSchema = {
1069
1082
  { unique: [["pageId", "termId"]] },
1070
1083
  ),
1071
1084
 
1085
+ // The same junction for media. A SECOND table rather than one polymorphic
1086
+ // `cms_object_terms(objectType, objectId, termId)`: a polymorphic key cannot carry a real
1087
+ // foreign key, and the FKs are what does the work here — purging a media row or deleting a
1088
+ // term takes its assignments with it, with no handler remembering to. A `where` traversal
1089
+ // also needs a typed column to join on; `objectId` would have to be filtered by a
1090
+ // discriminator the read engine has no way to require, so one forgotten `objectType`
1091
+ // clause silently mixes a page's tags into a media query.
1092
+ cms_media_terms: Entity(
1093
+ (t) => ({
1094
+ id: primaryKey(generated(t.uuid())),
1095
+ mediaId: indexed(notNull(t.uuid())),
1096
+ termId: indexed(notNull(t.uuid())),
1097
+ }),
1098
+ (r) => ({
1099
+ // `cascade` on both ends, as on the page junction. Note this fires on PURGE, not on
1100
+ // trashing: `deleteMedia` only stamps `deletedAt`, so a restored file keeps its tags.
1101
+ media: r.belongsTo("cms_media", "mediaId", { onDelete: "cascade" }),
1102
+ term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
1103
+ }),
1104
+ { unique: [["mediaId", "termId"]] },
1105
+ ),
1106
+
1072
1107
  // A named template region an admin fills without touching code — the sidebar, the footer
1073
1108
  // column, the pre-footer strip.
1074
1109
  //
@@ -1092,17 +1127,178 @@ export const cmsSchema = {
1092
1127
 
1093
1128
  // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
1094
1129
  // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
1095
- cms_media: Entity((t) => ({
1096
- id: primaryKey(generated(t.uuid())),
1097
- file: t.fileRef(),
1098
- alt: t.text(),
1099
- // Soft delete, as on cms_pages. The R2 OBJECT is deliberately kept while a media row
1100
- // is trasheddeleting the bytes would make restore a lie. `purgeMedia` drops both.
1101
- deletedAt: indexed(t.text()),
1102
- createdAt: defaultTo(t.text(), expr.now()),
1103
- })),
1130
+ cms_media: Entity(
1131
+ (t) => ({
1132
+ id: primaryKey(generated(t.uuid())),
1133
+ file: t.fileRef(),
1134
+ // The three fields the library SORTS and FILTERS by, projected out of `file` into real
1135
+ // columns. `file` is a `t.fileRef()` JSON in a TEXT cell so its `filename`,
1136
+ // `contentType` and `size` are invisible to `orderBy` and `where`, and sorting a library
1137
+ // after the page has been fetched sorts one page, which is not sorting.
1138
+ //
1139
+ // A duplicate, deliberately, and the cost is named: they are written where a media row is
1140
+ // CREATED and nowhere else (`updateMedia` never touches the file), so there is one writer
1141
+ // and `file` stays the source of truth for serving. SQLite generated columns would remove
1142
+ // the duplication outright — `GENERATED ALWAYS AS (json_extract(file,'$.filename'))` — but
1143
+ // the schema DSL has no way to declare one, and `generated()` here means something else
1144
+ // entirely (auto-mint a uuid on insert).
1145
+ //
1146
+ // Nullable, so adding them is a plain `ADD COLUMN` on an existing store rather than a
1147
+ // gated table rebuild; `cmsMigrations` backfills what is already there.
1148
+ filename: indexed(t.text()),
1149
+ contentType: indexed(t.text()),
1150
+ size: t.int(),
1151
+ alt: t.text(),
1152
+ // Soft delete, as on cms_pages. The R2 OBJECT is deliberately kept while a media row
1153
+ // is trashed — deleting the bytes would make restore a lie. `purgeMedia` drops both.
1154
+ deletedAt: indexed(t.text()),
1155
+ createdAt: defaultTo(t.text(), expr.now()),
1156
+ }),
1157
+ (r) => ({
1158
+ // Media is classified by the SAME vocabularies pages are, through the same `cms_terms`
1159
+ // table — a deployment that declares "Topics" gets to tag a photo with one without
1160
+ // declaring it twice, and the terms screen stays the one place a vocabulary is edited.
1161
+ //
1162
+ // The traversal is what makes tagging worth having: `where: { terms: { id } }` compiles
1163
+ // to a subquery through the junction, so the library filters by term IN SQL like it
1164
+ // filters by kind. A tag you cannot filter a paged library by is decoration.
1165
+ terms: r.manyToMany("cms_terms", { through: "cms_media_terms", sourceColumn: "mediaId", targetColumn: "termId" }),
1166
+ }),
1167
+ ),
1104
1168
  };
1105
1169
 
1170
+ // --- media: what the library may be sorted and filtered by ---------------------------------
1171
+ //
1172
+ // A CLOSED vocabulary on the server, not a column name and a direction from the client. The
1173
+ // two inputs compile straight into `ORDER BY` and `WHERE`, so letting a caller name the column
1174
+ // would hand it the ability to order by — and therefore probe — any column on the table,
1175
+ // `alt` and `deletedAt` included. Six sorts and five kinds is what the screen offers.
1176
+
1177
+ /** How a media list may be ordered. */
1178
+ export type MediaSort = "newest" | "oldest" | "name" | "name_desc" | "largest" | "smallest";
1179
+
1180
+ /** …resolved to the order the read engine takes. `createdAt` is ISO-8601 TEXT (`expr.now()`),
1181
+ * so a lexicographic sort on it IS chronological. */
1182
+ const MEDIA_SORTS: Record<MediaSort, { column: string; dir: "asc" | "desc" }> = {
1183
+ newest: { column: "createdAt", dir: "desc" },
1184
+ oldest: { column: "createdAt", dir: "asc" },
1185
+ name: { column: "filename", dir: "asc" },
1186
+ name_desc: { column: "filename", dir: "desc" },
1187
+ largest: { column: "size", dir: "desc" },
1188
+ smallest: { column: "size", dir: "asc" },
1189
+ };
1190
+
1191
+ /** The coarse type buckets the library filters by — the first segment of a MIME type, which
1192
+ * is the distinction someone browsing a library actually makes ("show me the images"). */
1193
+ export const MEDIA_KINDS = ["image", "video", "audio", "document", "other"] as const;
1194
+ export type MediaKind = (typeof MEDIA_KINDS)[number];
1195
+
1196
+ /** MIME prefixes for the buckets that have one. `document` is a LIST rather than a prefix
1197
+ * because the useful documents share no MIME family — a PDF is `application/pdf` and a Word
1198
+ * file is `application/vnd.openxmlformats-…`. */
1199
+ const DOCUMENT_PREFIXES = ["application/pdf", "text/", "application/msword", "application/vnd."] as const;
1200
+
1201
+ /** Trim and cap a search needle. Capped because it lands in a `LIKE` pattern: the engine
1202
+ * escapes `%` and `_` for us, so this is not an injection guard — it is a bound on work a
1203
+ * caller can ask the database to do per row. */
1204
+ function mediaQuery(raw: unknown): string | undefined {
1205
+ const q = typeof raw === "string" ? raw.trim().slice(0, 120) : "";
1206
+ return q === "" ? undefined : q;
1207
+ }
1208
+
1209
+ /** Match a needle against the two fields that NAME a file: what it was uploaded as, and what
1210
+ * an editor wrote about it. `alt` is included because it is the only human description a
1211
+ * media row carries, and searching a library for "logo" should find the file somebody
1212
+ * described as a logo even when the upload was called `IMG_2831.png`. */
1213
+ function mediaSearchWhere(q: string): WhereClause<typeof cmsSchema, "cms_media"> {
1214
+ return { OR: [{ filename: { contains: q } }, { alt: { contains: q } }] };
1215
+ }
1216
+
1217
+ /** The `where` clause for one bucket, or `undefined` for "everything".
1218
+ *
1219
+ * `other` is the interesting one: it is defined as NOT any of the buckets that have a
1220
+ * definition, so a file type nobody anticipated still has exactly one home rather than
1221
+ * disappearing from every filter. A row whose `contentType` is NULL — uploaded before the
1222
+ * projection columns existed and never backfilled — lands there too, which is the honest
1223
+ * place for it.
1224
+ *
1225
+ * That NULL case needs its OWN clause, and did not have one. SQL is three-valued: against a
1226
+ * NULL column every `LIKE` is NULL, so the `OR` is NULL and `NOT NULL` is NULL — which is not
1227
+ * TRUE, so the row is excluded. `other` therefore matched everything except the rows it
1228
+ * documents as belonging there, and a legacy row was invisible under ALL FIVE chips, with only
1229
+ * "clear the filter" to find it and nothing on screen saying why. Exactly the deployment that
1230
+ * upgraded and did not spread `cmsMigrations`. */
1231
+ function mediaKindWhere(kind: MediaKind | undefined): WhereClause<typeof cmsSchema, "cms_media"> | undefined {
1232
+ if (kind === undefined) return undefined;
1233
+ if (kind === "image" || kind === "video" || kind === "audio") return { contentType: { startsWith: `${kind}/` } };
1234
+ const documents = { OR: DOCUMENT_PREFIXES.map((p) => ({ contentType: { startsWith: p } })) };
1235
+ if (kind === "document") return documents;
1236
+ const known = { OR: [{ contentType: { startsWith: "image/" } }, { contentType: { startsWith: "video/" } }, { contentType: { startsWith: "audio/" } }, documents] };
1237
+ return { OR: [{ contentType: { isNull: true } }, { NOT: known }] };
1238
+ }
1239
+
1240
+ // --- taxonomies: what a vocabulary classifies ---------------------------------------------
1241
+
1242
+ /** The object types a vocabulary can be applied to. A CLOSED vocabulary, like `MEDIA_KINDS`:
1243
+ * it decides which panels offer a taxonomy AND which assignments are accepted, so an unknown
1244
+ * value must not be storable. */
1245
+ export const TAXONOMY_TARGETS = ["page", "media"] as const;
1246
+ export type TaxonomyTarget = (typeof TAXONOMY_TARGETS)[number];
1247
+
1248
+ /** Whether a vocabulary applies to `target`.
1249
+ *
1250
+ * The permissive readings all collapse to TRUE, deliberately: NULL (never narrowed, or written
1251
+ * before the column existed), a non-array, an array of things that are not targets. A stored
1252
+ * value nobody can interpret must not silently stop a vocabulary from working — the failure
1253
+ * would be a taxonomy that has quietly vanished from every panel, with the row still there. */
1254
+ export function taxonomyApplies(row: { appliesTo?: unknown }, target: TaxonomyTarget): boolean {
1255
+ const list = row.appliesTo;
1256
+ if (!Array.isArray(list)) return true;
1257
+ const targets = list.filter((v): v is TaxonomyTarget => (TAXONOMY_TARGETS as readonly unknown[]).includes(v));
1258
+ return targets.length === 0 || targets.includes(target);
1259
+ }
1260
+
1261
+ /** Parse an `appliesTo` input. `undefined` stays undefined (the field was not sent); `null`
1262
+ * clears the narrowing back to "everything". An unknown target is a 400 rather than a silent
1263
+ * drop — dropping it would store a NARROWER set than the caller asked for, which is the one
1264
+ * direction that loses assignments. */
1265
+ function parseAppliesTo(raw: unknown): TaxonomyTarget[] | null | undefined {
1266
+ if (raw === undefined) return undefined;
1267
+ if (raw === null) return null;
1268
+ if (!Array.isArray(raw)) throw new BadRequest("appliesTo must be a list or null");
1269
+ const out: TaxonomyTarget[] = [];
1270
+ for (const v of raw) {
1271
+ if (typeof v !== "string" || !(TAXONOMY_TARGETS as readonly string[]).includes(v)) {
1272
+ throw new BadRequest(`appliesTo must contain only ${TAXONOMY_TARGETS.join(", ")}`);
1273
+ }
1274
+ if (!out.includes(v as TaxonomyTarget)) out.push(v as TaxonomyTarget);
1275
+ }
1276
+ // A vocabulary that classifies nothing is not a narrowing, it is a vocabulary nobody can
1277
+ // reach — and it reads identically to NULL in storage, which means the opposite.
1278
+ if (out.length === 0) throw new BadRequest("appliesTo must name at least one of " + TAXONOMY_TARGETS.join(", "));
1279
+ return out;
1280
+ }
1281
+
1282
+ /** Refuse term ids from a vocabulary that does not classify `target`.
1283
+ *
1284
+ * The write side, not just the UI. Hiding a vocabulary from a panel without changing what the
1285
+ * server accepts is the `hideI18n` mistake this package already retired once: the control
1286
+ * disappears, the request does not, and `appliesTo` becomes a hint rather than a rule. It also
1287
+ * has to be here for the assignments the panel never made — a script, a migration, an older
1288
+ * editor build that has not learned the capability.
1289
+ *
1290
+ * Reads the terms' taxonomies through `ctx.db`, so an unreadable vocabulary refuses the
1291
+ * assignment rather than being waved through. */
1292
+ async function assertTermsApplyTo(db: CmsDb, termIds: readonly string[], target: TaxonomyTarget): Promise<void> {
1293
+ if (termIds.length === 0) return;
1294
+ const terms = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "taxonomyId"], limit: termIds.length });
1295
+ const taxIds = [...new Set(terms.map((t) => String(t.taxonomyId)))];
1296
+ if (taxIds.length === 0) return;
1297
+ const taxa = await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug", "appliesTo"], limit: taxIds.length });
1298
+ const bad = taxa.filter((t) => !taxonomyApplies(t, target));
1299
+ if (bad[0]) throw new BadRequest(`vocabulary '${String(bad[0].slug)}' does not apply to ${target}`);
1300
+ }
1301
+
1106
1302
  /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
1107
1303
  * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
1108
1304
  export {
@@ -1110,22 +1306,32 @@ export {
1110
1306
  createAdminPageHandlers,
1111
1307
  normalizeAdminResponse,
1112
1308
  validateAdminPages,
1309
+ ADMIN_ELEMENT_TYPES,
1310
+ ADMIN_PAGE_KINDS,
1113
1311
  MAX_ADMIN_BLOCK_DEPTH,
1114
1312
  } from "./blockkit";
1115
1313
  export type {
1116
1314
  AdminBlock,
1117
1315
  AdminButton,
1316
+ AdminCell,
1118
1317
  AdminElement,
1119
1318
  AdminInput,
1120
1319
  AdminInteractionType,
1121
1320
  AdminPageDef,
1122
1321
  AdminPageHandlerOpts,
1123
1322
  AdminPageInteraction,
1323
+ AdminPageKind,
1124
1324
  AdminPageMeta,
1125
1325
  AdminPageResponse,
1326
+ AdminScreenDef,
1126
1327
  AdminText,
1127
1328
  } from "./blockkit";
1128
1329
 
1330
+ /** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
1331
+ * screens a server-driven vocabulary cannot carry. See `./panel`. */
1332
+ export { adminPanel, isAdminPanel } from "./panel";
1333
+ export type { AdminPanelDef } from "./panel";
1334
+
1129
1335
  /**
1130
1336
  * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
1131
1337
  *
@@ -3103,11 +3309,36 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3103
3309
  // `parentId` on its terms; a flat one refuses it rather than storing something no
3104
3310
  // listing renders.
3105
3311
 
3106
- /** Every vocabulary. PUBLIC like content types, a taxonomy's slug is structural (it
3107
- * is a URL segment) and a front end routes on it. */
3108
- listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
3312
+ /** Every vocabulary, or just the ones that classify `target`. PUBLIC like content
3313
+ * types, a taxonomy's slug is structural (it is a URL segment) and a front end routes on
3314
+ * it.
3315
+ *
3316
+ * Narrowed HERE rather than in each caller, so the page panel, the media panel and the
3317
+ * write-side guard cannot disagree about what a vocabulary applies to. In memory, because
3318
+ * `appliesTo` is a `t.json()` column that `where` cannot see into — which costs nothing:
3319
+ * this handler already reads every taxonomy, and nothing pages by them.
3320
+ *
3321
+ * No `target` means EVERY vocabulary, which is what the Taxonomies screen needs: the one
3322
+ * place that edits `appliesTo` must be able to see a vocabulary it has narrowed away. */
3323
+ listTaxonomies: query(async (ctx, input: { target?: TaxonomyTarget }) => {
3324
+ const rows = await cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT });
3325
+ const target = input?.target;
3326
+ return target === undefined ? rows : rows.filter((r) => taxonomyApplies(r, target));
3327
+ }, {
3328
+ input: (raw): { target?: TaxonomyTarget } => {
3329
+ const t = asObj(raw).target;
3330
+ // Unrecognised narrows to nothing rather than falling back to everything: this one
3331
+ // decides what a panel OFFERS, and answering "all of them" to a question the server
3332
+ // did not understand is how a media panel ends up listing page-only vocabularies.
3333
+ if (t === undefined || t === null) return {};
3334
+ if (typeof t !== "string" || !(TAXONOMY_TARGETS as readonly string[]).includes(t)) {
3335
+ throw new BadRequest(`target must be one of ${TAXONOMY_TARGETS.join(", ")}`);
3336
+ }
3337
+ return { target: t as TaxonomyTarget };
3338
+ },
3339
+ }),
3109
3340
 
3110
- createTaxonomy: mutation(async (ctx, input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean }) => {
3341
+ createTaxonomy: mutation(async (ctx, input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) => {
3111
3342
  const db = cdb(ctx);
3112
3343
  const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
3113
3344
  if (clash[0]) throw new Conflict(`taxonomy '${input.slug}' already exists`);
@@ -3117,10 +3348,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3117
3348
  pluralLabel: input.pluralLabel ?? null,
3118
3349
  description: input.description ?? null,
3119
3350
  hierarchical: input.hierarchical ?? false,
3351
+ // Unsent means EVERY target, which is what a vocabulary created before this existed
3352
+ // means too — one reading of NULL, so an old row and a new one behave alike.
3353
+ appliesTo: input.appliesTo ?? null,
3120
3354
  });
3121
3355
  }, {
3122
3356
  ...editor,
3123
- input: (raw): { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean } => {
3357
+ input: (raw): { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null } => {
3124
3358
  const o = asObj(raw);
3125
3359
  return {
3126
3360
  slug: assertKey(o.slug, "taxonomy slug"),
@@ -3128,6 +3362,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3128
3362
  pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
3129
3363
  description: typeof o.description === "string" ? o.description : undefined,
3130
3364
  hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
3365
+ appliesTo: parseAppliesTo(o.appliesTo),
3131
3366
  };
3132
3367
  },
3133
3368
  }),
@@ -3137,8 +3372,15 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3137
3372
  *
3138
3373
  * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
3139
3374
  * would leave a stored hierarchy that no reader renders and no writer can clear, and
3140
- * flattening the terms silently is a destructive edit behind a checkbox. */
3141
- updateTaxonomy: mutation(async (ctx, input: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean }) => {
3375
+ * flattening the terms silently is a destructive edit behind a checkbox.
3376
+ *
3377
+ * NARROWING `appliesTo` is refused on exactly the same grounds, and it is the same bug:
3378
+ * dropping a target this vocabulary is already used for would strand those assignments —
3379
+ * still stored, still returned by `listPageTerms`/`listMediaTerms`, but invisible in the
3380
+ * panel that could remove them, because the panel only renders vocabularies that apply.
3381
+ * Unassign them first; then the narrowing is a settings change rather than a silent
3382
+ * orphaning. WIDENING is always fine — it strands nothing. */
3383
+ updateTaxonomy: mutation(async (ctx, input: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null }) => {
3142
3384
  const db = cdb(ctx);
3143
3385
  const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
3144
3386
  const row = rows[0];
@@ -3147,21 +3389,33 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3147
3389
  const nested = await db.find({ from: "cms_terms", where: { taxonomyId: input.id, parentId: { isNull: false } }, select: ["id"], limit: 1 });
3148
3390
  if (nested[0]) throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
3149
3391
  }
3392
+ if (input.appliesTo !== undefined && input.appliesTo !== null) {
3393
+ const next = input.appliesTo;
3394
+ for (const target of TAXONOMY_TARGETS) {
3395
+ // Only a target this vocabulary applies to TODAY and would not after the patch.
3396
+ if (next.includes(target) || !taxonomyApplies(row, target)) continue;
3397
+ const junction = target === "page" ? "cms_page_terms" : "cms_media_terms";
3398
+ const used = await db.find({ from: junction, where: { term: { taxonomyId: input.id } }, select: ["id"], limit: 1 });
3399
+ if (used[0]) throw new BadRequest(`this vocabulary is still assigned to ${target === "page" ? "pages" : "media"} — remove those assignments before narrowing it`);
3400
+ }
3401
+ }
3150
3402
  const patch: Record<string, unknown> = {};
3151
- for (const k of ["label", "pluralLabel", "description", "hierarchical"] as const) {
3403
+ for (const k of ["label", "pluralLabel", "description", "hierarchical", "appliesTo"] as const) {
3152
3404
  if (input[k] !== undefined) patch[k] = input[k];
3153
3405
  }
3154
3406
  return db.update("cms_taxonomies", input.id, patch);
3155
3407
  }, {
3156
3408
  ...editor,
3157
- input: (raw): { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } => {
3409
+ input: (raw): { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null } => {
3158
3410
  const o = asObj(raw);
3159
3411
  if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
3160
- const out: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } = { id: o.id };
3412
+ const out: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean; appliesTo?: TaxonomyTarget[] | null } = { id: o.id };
3161
3413
  if (o.label !== undefined) out.label = assertLabel(o.label, "taxonomy label");
3162
3414
  if (o.pluralLabel !== undefined) out.pluralLabel = typeof o.pluralLabel === "string" ? o.pluralLabel : null;
3163
3415
  if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
3164
3416
  if (typeof o.hierarchical === "boolean") out.hierarchical = o.hierarchical;
3417
+ const appliesTo = parseAppliesTo(o.appliesTo);
3418
+ if (appliesTo !== undefined) out.appliesTo = appliesTo;
3165
3419
  return out;
3166
3420
  },
3167
3421
  }),
@@ -3330,6 +3584,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3330
3584
  // uuid — the FK would catch it, but as a driver error with no HTTP status.
3331
3585
  const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
3332
3586
  if (found.length !== wanted.size) throw new BadRequest("one or more termIds are not terms");
3587
+ await assertTermsApplyTo(db, [...wanted], "page");
3333
3588
  }
3334
3589
  const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
3335
3590
  const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
@@ -3512,7 +3767,15 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3512
3767
  filename: input.ref.filename,
3513
3768
  uploadedAt: Date.now(),
3514
3769
  };
3515
- return cdb(ctx).insert("cms_media", { file, alt: input.alt ?? null });
3770
+ // The projection columns go in beside `file`, from the SAME resolved values — never
3771
+ // from `input`, which is the client's claim about a blob it has just uploaded.
3772
+ return cdb(ctx).insert("cms_media", {
3773
+ file,
3774
+ filename: file.filename ?? null,
3775
+ contentType: file.contentType ?? null,
3776
+ size: file.size ?? null,
3777
+ alt: input.alt ?? null,
3778
+ });
3516
3779
  }, {
3517
3780
  ...editor,
3518
3781
  input: (raw): { ref: FileRef; alt?: string } => {
@@ -3531,11 +3794,45 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3531
3794
  },
3532
3795
  }),
3533
3796
 
3534
- listMedia: query((ctx, input: { limit?: number; offset?: number }) => {
3797
+ listMedia: query((ctx, input: { limit?: number; offset?: number; sort?: MediaSort; kind?: MediaKind; q?: string; term?: string }) => {
3535
3798
  const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
3536
3799
  const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
3537
- return cdb(ctx).find({ from: "cms_media", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
3538
- }, viewer),
3800
+ // The narrowings AND together a search inside a type filter inside a tag is all
3801
+ // three. Built as a list so none has to know whether the others are present.
3802
+ const clauses = [
3803
+ mediaKindWhere(input?.kind),
3804
+ input?.q ? mediaSearchWhere(input.q) : undefined,
3805
+ // A relation traversal, compiled to a subquery through `cms_media_terms`. Filtering
3806
+ // by term therefore costs the same page of rows as filtering by kind — the whole
3807
+ // reason the assignments are a junction rather than a JSON array on the media row.
3808
+ input?.term ? { terms: { id: input.term } } : undefined,
3809
+ ].filter(
3810
+ (c): c is WhereClause<typeof cmsSchema, "cms_media"> => c !== undefined,
3811
+ );
3812
+ const where = clauses.length === 0 ? undefined : clauses.length === 1 ? clauses[0] : { AND: clauses };
3813
+ return cdb(ctx).find({ from: "cms_media", where, orderBy: MEDIA_SORTS[input?.sort ?? "newest"], limit, offset });
3814
+ }, {
3815
+ ...viewer,
3816
+ // Parsed, not cast: `sort` names an ORDER BY and `kind` a WHERE, and both arrive from a
3817
+ // browser. Anything unrecognised falls back to the default rather than erroring — a
3818
+ // stale bookmark carrying a sort this build dropped should show the library, not a 400.
3819
+ input: (raw): { limit?: number; offset?: number; sort?: MediaSort; kind?: MediaKind; q?: string; term?: string } => {
3820
+ const o = asObj(raw);
3821
+ const sort = typeof o.sort === "string" && o.sort in MEDIA_SORTS ? (o.sort as MediaSort) : undefined;
3822
+ const kind = typeof o.kind === "string" && (MEDIA_KINDS as readonly string[]).includes(o.kind) ? (o.kind as MediaKind) : undefined;
3823
+ return {
3824
+ limit: typeof o.limit === "number" ? o.limit : undefined,
3825
+ offset: typeof o.offset === "number" ? o.offset : undefined,
3826
+ sort,
3827
+ kind,
3828
+ q: mediaQuery(o.q),
3829
+ // An id, not a slug: a slug identifies a term only within its vocabulary, and the
3830
+ // filter has no vocabulary to resolve it against. An id that is not a term matches
3831
+ // nothing, which is the same answer as a term with no files.
3832
+ term: typeof o.term === "string" && o.term !== "" ? o.term : undefined,
3833
+ };
3834
+ },
3835
+ }),
3539
3836
 
3540
3837
  getMedia: query(async (ctx, input: { id: string }) => {
3541
3838
  const rows = await cdb(ctx).find({ from: "cms_media", where: { id: input.id }, limit: 1 });
@@ -3563,6 +3860,63 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3563
3860
  },
3564
3861
  }),
3565
3862
 
3863
+ /** A media asset's assigned terms.
3864
+ *
3865
+ * The media row is read FIRST, through `ctx.db`, so the caller's own scope decides
3866
+ * whether this answers — the junction and `cms_terms` are granted unscoped, so without
3867
+ * it anyone holding an id could read a TRASHED file's tags and the non-empty answer
3868
+ * would confirm the file exists. Same rule as `listPageTerms`, for the same reason. */
3869
+ listMediaTerms: query(async (ctx, input: { mediaId: string }): Promise<Term[]> => {
3870
+ const db = cdb(ctx);
3871
+ const media = await db.find({ from: "cms_media", where: { id: input.mediaId }, select: ["id"], limit: 1 });
3872
+ if (!media[0]) return [];
3873
+ const links = await db.find({ from: "cms_media_terms", where: { mediaId: input.mediaId }, select: ["termId"], limit: MAX_TERMS });
3874
+ const ids = links.map((l) => String(l.termId));
3875
+ if (ids.length === 0) return [];
3876
+ const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
3877
+ return rows as unknown as Term[];
3878
+ }, {
3879
+ ...viewer,
3880
+ input: (raw): { mediaId: string } => {
3881
+ const id = asObj(raw).mediaId;
3882
+ if (typeof id !== "string" || id === "") throw new BadRequest("mediaId is required");
3883
+ return { mediaId: id };
3884
+ },
3885
+ }),
3886
+
3887
+ /** Replace a media asset's term assignments wholesale — set semantics, like
3888
+ * `setPageTerms`, and for the same reason: the panel holds the whole selection, and two
3889
+ * calls each patching one end of it race into a state neither asked for. */
3890
+ setMediaTerms: mutation(async (ctx, input: { mediaId: string; termIds: string[] }) => {
3891
+ const db = cdb(ctx);
3892
+ const media = await db.find({ from: "cms_media", where: { id: input.mediaId }, select: ["id"], limit: 1 });
3893
+ if (!media[0]) throw notFound("media");
3894
+ const wanted = new Set(input.termIds);
3895
+ if (wanted.size > 0) {
3896
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
3897
+ if (found.length !== wanted.size) throw new BadRequest("one or more termIds are not terms");
3898
+ await assertTermsApplyTo(db, [...wanted], "media");
3899
+ }
3900
+ const existing = await db.find({ from: "cms_media_terms", where: { mediaId: input.mediaId }, select: ["id", "termId"], limit: MAX_TERMS });
3901
+ const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
3902
+ for (const [termId, linkId] of have) if (!wanted.has(termId)) await db.delete("cms_media_terms", linkId);
3903
+ for (const termId of wanted) if (!have.has(termId)) await db.insert("cms_media_terms", { mediaId: input.mediaId, termId });
3904
+ return { ok: true as const, count: wanted.size };
3905
+ }, {
3906
+ ...editor,
3907
+ input: (raw): { mediaId: string; termIds: string[] } => {
3908
+ const o = asObj(raw);
3909
+ if (typeof o.mediaId !== "string" || o.mediaId === "") throw new BadRequest("mediaId is required");
3910
+ if (!Array.isArray(o.termIds)) throw new BadRequest("termIds must be a list");
3911
+ const ids = o.termIds.map((v) => {
3912
+ if (typeof v !== "string" || v === "") throw new BadRequest("termIds must be a list of ids");
3913
+ return v;
3914
+ });
3915
+ if (ids.length > MAX_TERMS) throw new BadRequest(`a file may carry at most ${MAX_TERMS} terms`);
3916
+ return { mediaId: o.mediaId, termIds: ids };
3917
+ },
3918
+ }),
3919
+
3566
3920
  /** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
3567
3921
  * `restoreMedia` a lie, and a block still referencing the id would render a dead url
3568
3922
  * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
@@ -3964,6 +4318,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
3964
4318
  // the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
3965
4319
  // fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
3966
4320
  codeDefinedTypes: true as const,
4321
+ // Media carries taxonomy terms, and `listMedia` understands `term`. Declared for the
4322
+ // usual reason: an older server has neither handler, so the detail panel's Tags
4323
+ // section would 404 on open and the library's tag filter would send an argument that
4324
+ // is ignored — a filter that visibly does nothing. Absent ⇒ neither is drawn.
4325
+ mediaTerms: true as const,
3967
4326
  // PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
3968
4327
  // so a reviewer-only session reaches this handler and every read handler — but every
3969
4328
  // WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
@@ -4579,7 +4938,7 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
4579
4938
  "cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
4580
4939
  // Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
4581
4940
  // `auth` gate is what separates editor from reviewer; this is the row scope.
4582
- "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
4941
+ "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_media_terms", "cms_widget_areas",
4583
4942
  ] as const;
4584
4943
  // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
4585
4944
  // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
@@ -4636,6 +4995,10 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
4636
4995
  policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
4637
4996
  policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
4638
4997
  policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
4998
+ // The media junction, for the same reason as the page one: `where: { terms: … }` on a
4999
+ // media row compiles to a subquery THROUGH it, so without the grant the library's tag
5000
+ // filter matches nothing and reads as "no files carry this tag".
5001
+ policy(`${p}:public:media-terms:read`, "cms_media_terms", "read", allow()),
4639
5002
  policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
4640
5003
  ],
4641
5004
  editor: editorPolicies,
@@ -5876,6 +6239,63 @@ export function createCollectionTasks(collections: readonly CollectionDef[]) {
5876
6239
  * between the revision insert and the page update leaves the page unpublished with an orphan
5877
6240
  * revision until the next at-least-once redelivery re-runs (the token still matches, so it
5878
6241
  * completes). Acceptable for a scheduled job; the interactive path is atomic. */
6242
+ /**
6243
+ * The CMS's own data migrations — spread into `app.migrations`.
6244
+ *
6245
+ * migrations: [...cmsMigrations]
6246
+ *
6247
+ * Opt-in like every other fragment this package ships (`cmsHandlers`, `cmsPolicies`,
6248
+ * `cmsTasks`), and with the same consequence for forgetting it: nothing breaks loudly. Media
6249
+ * rows written before the projection columns existed keep NULL `filename`/`contentType`, so
6250
+ * they sort together under a name sort and answer only the `other` type filter. New uploads
6251
+ * are unaffected — `createMedia` writes the columns itself.
6252
+ */
6253
+ export const cmsMigrations: readonly DataMigration[] = [
6254
+ {
6255
+ // Fill the columns `cms_media` grew for sorting and filtering, out of the `file` JSON that
6256
+ // has always held the same three values.
6257
+ id: "cms:2026-09-04-media-projection-columns",
6258
+ // `MigrationContext<typeof cmsSchema>` is what types `db` here — the `DataMigration`
6259
+ // contract is schema-agnostic, so an unparameterized ctx hands back untyped rows.
6260
+ async up({ db, driver }: MigrationContext<typeof cmsSchema>) {
6261
+ const d = driver.dialect;
6262
+ const t = d.id("cms_media");
6263
+ const set = (c: string, path: string) => `${d.id(c)} = json_extract(${d.id("file")}, '$.${path}')`;
6264
+ try {
6265
+ // One statement for the whole table. `WHERE filename IS NULL` makes it cheap on a
6266
+ // store that has nothing to do, and keeps it off rows a later upload already filled.
6267
+ await driver.exec(
6268
+ `UPDATE ${t} SET ${set("filename", "filename")}, ${set("contentType", "contentType")}, ${set("size", "size")} ` +
6269
+ `WHERE ${d.id("filename")} IS NULL AND ${d.id("contentType")} IS NULL`,
6270
+ [],
6271
+ );
6272
+ return;
6273
+ } catch {
6274
+ // `json_extract` is a JSON1 function. It is present in D1 and in every ordinary SQLite
6275
+ // build, but nothing in this repo has depended on it before and DO SQLite is
6276
+ // Cloudflare's own engine — so a missing function must not brick a tenant's boot,
6277
+ // which is exactly what a data migration's fail-closed contract would otherwise do.
6278
+ // The fallback walks the rows through the ORM, where the fileRef codec has already
6279
+ // parsed the same JSON for us. Slower, and bounded by how many media a tenant has.
6280
+ }
6281
+ // No chunking: this runs inside `blockConcurrencyWhile` on a tenant's first fetch, so a
6282
+ // very large library will stall that one request — which is still the right trade
6283
+ // against leaving half the table unsorted forever, since a migration runs ONCE.
6284
+ const rows = await db.find({ from: "cms_media", where: { filename: { isNull: true }, contentType: { isNull: true } } });
6285
+ for (const row of rows) {
6286
+ const file = row.file;
6287
+ if (!file || typeof file !== "object" || Array.isArray(file)) continue;
6288
+ const ref = file as { filename?: unknown; contentType?: unknown; size?: unknown };
6289
+ await db.update("cms_media", String(row.id), {
6290
+ filename: typeof ref.filename === "string" ? ref.filename : null,
6291
+ contentType: typeof ref.contentType === "string" ? ref.contentType : null,
6292
+ size: typeof ref.size === "number" ? ref.size : null,
6293
+ });
6294
+ }
6295
+ },
6296
+ },
6297
+ ];
6298
+
5879
6299
  export const cmsTasks = {
5880
6300
  "cms:publish": async (ctx: HandlerContext, payload: unknown) => {
5881
6301
  const { pageId, token } = asObj(payload) as { pageId?: string; token?: string };