@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/dist/index.js CHANGED
@@ -603,6 +603,18 @@ export const cmsSchema = {
603
603
  // Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
604
604
  // write. Enforced in the handler, not the schema — one term table serves both.
605
605
  hierarchical: defaultTo(t.bool(), false),
606
+ // What this vocabulary classifies — a subset of `TAXONOMY_TARGETS`. NULL means EVERYTHING,
607
+ // which is both the backward-compatible reading for rows written before this column and a
608
+ // legitimate authored value ("Topics classifies whatever there is"). Without it a site with
609
+ // "Categories" for articles and tags for images offers both on both, so a photo can be
610
+ // filed under "Local news" and `getTermTree("category")` fills up with terms like "hero"
611
+ // that no page listing will ever use.
612
+ //
613
+ // A `t.json()` array rather than two booleans: the set of things a CMS classifies grows
614
+ // (collections are the obvious next one), and a column per target would need a migration
615
+ // each time. It is not queryable — JSON in a TEXT cell — but nothing pages by taxonomy:
616
+ // `listTaxonomies` reads them all and narrows in memory.
617
+ appliesTo: t.json(),
606
618
  createdAt: defaultTo(t.text(), expr.now()),
607
619
  })),
608
620
  cms_terms: Entity((t) => ({
@@ -622,6 +634,7 @@ export const cmsSchema = {
622
634
  taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
623
635
  parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
624
636
  pages: r.hasMany("cms_page_terms", "termId"),
637
+ media: r.hasMany("cms_media_terms", "termId"),
625
638
  }),
626
639
  // A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
627
640
  // are two different terms, and both are legitimate.
@@ -640,6 +653,23 @@ export const cmsSchema = {
640
653
  // One assignment per (page, term). Without it a double-submit leaves a page tagged
641
654
  // twice and every `with: { terms: true }` renders the term twice.
642
655
  { unique: [["pageId", "termId"]] }),
656
+ // The same junction for media. A SECOND table rather than one polymorphic
657
+ // `cms_object_terms(objectType, objectId, termId)`: a polymorphic key cannot carry a real
658
+ // foreign key, and the FKs are what does the work here — purging a media row or deleting a
659
+ // term takes its assignments with it, with no handler remembering to. A `where` traversal
660
+ // also needs a typed column to join on; `objectId` would have to be filtered by a
661
+ // discriminator the read engine has no way to require, so one forgotten `objectType`
662
+ // clause silently mixes a page's tags into a media query.
663
+ cms_media_terms: Entity((t) => ({
664
+ id: primaryKey(generated(t.uuid())),
665
+ mediaId: indexed(notNull(t.uuid())),
666
+ termId: indexed(notNull(t.uuid())),
667
+ }), (r) => ({
668
+ // `cascade` on both ends, as on the page junction. Note this fires on PURGE, not on
669
+ // trashing: `deleteMedia` only stamps `deletedAt`, so a restored file keeps its tags.
670
+ media: r.belongsTo("cms_media", "mediaId", { onDelete: "cascade" }),
671
+ term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
672
+ }), { unique: [["mediaId", "termId"]] }),
643
673
  // A named template region an admin fills without touching code — the sidebar, the footer
644
674
  // column, the pre-footer strip.
645
675
  //
@@ -665,16 +695,166 @@ export const cmsSchema = {
665
695
  cms_media: Entity((t) => ({
666
696
  id: primaryKey(generated(t.uuid())),
667
697
  file: t.fileRef(),
698
+ // The three fields the library SORTS and FILTERS by, projected out of `file` into real
699
+ // columns. `file` is a `t.fileRef()` — JSON in a TEXT cell — so its `filename`,
700
+ // `contentType` and `size` are invisible to `orderBy` and `where`, and sorting a library
701
+ // after the page has been fetched sorts one page, which is not sorting.
702
+ //
703
+ // A duplicate, deliberately, and the cost is named: they are written where a media row is
704
+ // CREATED and nowhere else (`updateMedia` never touches the file), so there is one writer
705
+ // and `file` stays the source of truth for serving. SQLite generated columns would remove
706
+ // the duplication outright — `GENERATED ALWAYS AS (json_extract(file,'$.filename'))` — but
707
+ // the schema DSL has no way to declare one, and `generated()` here means something else
708
+ // entirely (auto-mint a uuid on insert).
709
+ //
710
+ // Nullable, so adding them is a plain `ADD COLUMN` on an existing store rather than a
711
+ // gated table rebuild; `cmsMigrations` backfills what is already there.
712
+ filename: indexed(t.text()),
713
+ contentType: indexed(t.text()),
714
+ size: t.int(),
668
715
  alt: t.text(),
669
716
  // Soft delete, as on cms_pages. The R2 OBJECT is deliberately kept while a media row
670
717
  // is trashed — deleting the bytes would make restore a lie. `purgeMedia` drops both.
671
718
  deletedAt: indexed(t.text()),
672
719
  createdAt: defaultTo(t.text(), expr.now()),
720
+ }), (r) => ({
721
+ // Media is classified by the SAME vocabularies pages are, through the same `cms_terms`
722
+ // table — a deployment that declares "Topics" gets to tag a photo with one without
723
+ // declaring it twice, and the terms screen stays the one place a vocabulary is edited.
724
+ //
725
+ // The traversal is what makes tagging worth having: `where: { terms: { id } }` compiles
726
+ // to a subquery through the junction, so the library filters by term IN SQL like it
727
+ // filters by kind. A tag you cannot filter a paged library by is decoration.
728
+ terms: r.manyToMany("cms_terms", { through: "cms_media_terms", sourceColumn: "mediaId", targetColumn: "termId" }),
673
729
  })),
674
730
  };
731
+ /** …resolved to the order the read engine takes. `createdAt` is ISO-8601 TEXT (`expr.now()`),
732
+ * so a lexicographic sort on it IS chronological. */
733
+ const MEDIA_SORTS = {
734
+ newest: { column: "createdAt", dir: "desc" },
735
+ oldest: { column: "createdAt", dir: "asc" },
736
+ name: { column: "filename", dir: "asc" },
737
+ name_desc: { column: "filename", dir: "desc" },
738
+ largest: { column: "size", dir: "desc" },
739
+ smallest: { column: "size", dir: "asc" },
740
+ };
741
+ /** The coarse type buckets the library filters by — the first segment of a MIME type, which
742
+ * is the distinction someone browsing a library actually makes ("show me the images"). */
743
+ export const MEDIA_KINDS = ["image", "video", "audio", "document", "other"];
744
+ /** MIME prefixes for the buckets that have one. `document` is a LIST rather than a prefix
745
+ * because the useful documents share no MIME family — a PDF is `application/pdf` and a Word
746
+ * file is `application/vnd.openxmlformats-…`. */
747
+ const DOCUMENT_PREFIXES = ["application/pdf", "text/", "application/msword", "application/vnd."];
748
+ /** Trim and cap a search needle. Capped because it lands in a `LIKE` pattern: the engine
749
+ * escapes `%` and `_` for us, so this is not an injection guard — it is a bound on work a
750
+ * caller can ask the database to do per row. */
751
+ function mediaQuery(raw) {
752
+ const q = typeof raw === "string" ? raw.trim().slice(0, 120) : "";
753
+ return q === "" ? undefined : q;
754
+ }
755
+ /** Match a needle against the two fields that NAME a file: what it was uploaded as, and what
756
+ * an editor wrote about it. `alt` is included because it is the only human description a
757
+ * media row carries, and searching a library for "logo" should find the file somebody
758
+ * described as a logo even when the upload was called `IMG_2831.png`. */
759
+ function mediaSearchWhere(q) {
760
+ return { OR: [{ filename: { contains: q } }, { alt: { contains: q } }] };
761
+ }
762
+ /** The `where` clause for one bucket, or `undefined` for "everything".
763
+ *
764
+ * `other` is the interesting one: it is defined as NOT any of the buckets that have a
765
+ * definition, so a file type nobody anticipated still has exactly one home rather than
766
+ * disappearing from every filter. A row whose `contentType` is NULL — uploaded before the
767
+ * projection columns existed and never backfilled — lands there too, which is the honest
768
+ * place for it.
769
+ *
770
+ * That NULL case needs its OWN clause, and did not have one. SQL is three-valued: against a
771
+ * NULL column every `LIKE` is NULL, so the `OR` is NULL and `NOT NULL` is NULL — which is not
772
+ * TRUE, so the row is excluded. `other` therefore matched everything except the rows it
773
+ * documents as belonging there, and a legacy row was invisible under ALL FIVE chips, with only
774
+ * "clear the filter" to find it and nothing on screen saying why. Exactly the deployment that
775
+ * upgraded and did not spread `cmsMigrations`. */
776
+ function mediaKindWhere(kind) {
777
+ if (kind === undefined)
778
+ return undefined;
779
+ if (kind === "image" || kind === "video" || kind === "audio")
780
+ return { contentType: { startsWith: `${kind}/` } };
781
+ const documents = { OR: DOCUMENT_PREFIXES.map((p) => ({ contentType: { startsWith: p } })) };
782
+ if (kind === "document")
783
+ return documents;
784
+ const known = { OR: [{ contentType: { startsWith: "image/" } }, { contentType: { startsWith: "video/" } }, { contentType: { startsWith: "audio/" } }, documents] };
785
+ return { OR: [{ contentType: { isNull: true } }, { NOT: known }] };
786
+ }
787
+ // --- taxonomies: what a vocabulary classifies ---------------------------------------------
788
+ /** The object types a vocabulary can be applied to. A CLOSED vocabulary, like `MEDIA_KINDS`:
789
+ * it decides which panels offer a taxonomy AND which assignments are accepted, so an unknown
790
+ * value must not be storable. */
791
+ export const TAXONOMY_TARGETS = ["page", "media"];
792
+ /** Whether a vocabulary applies to `target`.
793
+ *
794
+ * The permissive readings all collapse to TRUE, deliberately: NULL (never narrowed, or written
795
+ * before the column existed), a non-array, an array of things that are not targets. A stored
796
+ * value nobody can interpret must not silently stop a vocabulary from working — the failure
797
+ * would be a taxonomy that has quietly vanished from every panel, with the row still there. */
798
+ export function taxonomyApplies(row, target) {
799
+ const list = row.appliesTo;
800
+ if (!Array.isArray(list))
801
+ return true;
802
+ const targets = list.filter((v) => TAXONOMY_TARGETS.includes(v));
803
+ return targets.length === 0 || targets.includes(target);
804
+ }
805
+ /** Parse an `appliesTo` input. `undefined` stays undefined (the field was not sent); `null`
806
+ * clears the narrowing back to "everything". An unknown target is a 400 rather than a silent
807
+ * drop — dropping it would store a NARROWER set than the caller asked for, which is the one
808
+ * direction that loses assignments. */
809
+ function parseAppliesTo(raw) {
810
+ if (raw === undefined)
811
+ return undefined;
812
+ if (raw === null)
813
+ return null;
814
+ if (!Array.isArray(raw))
815
+ throw new BadRequest("appliesTo must be a list or null");
816
+ const out = [];
817
+ for (const v of raw) {
818
+ if (typeof v !== "string" || !TAXONOMY_TARGETS.includes(v)) {
819
+ throw new BadRequest(`appliesTo must contain only ${TAXONOMY_TARGETS.join(", ")}`);
820
+ }
821
+ if (!out.includes(v))
822
+ out.push(v);
823
+ }
824
+ // A vocabulary that classifies nothing is not a narrowing, it is a vocabulary nobody can
825
+ // reach — and it reads identically to NULL in storage, which means the opposite.
826
+ if (out.length === 0)
827
+ throw new BadRequest("appliesTo must name at least one of " + TAXONOMY_TARGETS.join(", "));
828
+ return out;
829
+ }
830
+ /** Refuse term ids from a vocabulary that does not classify `target`.
831
+ *
832
+ * The write side, not just the UI. Hiding a vocabulary from a panel without changing what the
833
+ * server accepts is the `hideI18n` mistake this package already retired once: the control
834
+ * disappears, the request does not, and `appliesTo` becomes a hint rather than a rule. It also
835
+ * has to be here for the assignments the panel never made — a script, a migration, an older
836
+ * editor build that has not learned the capability.
837
+ *
838
+ * Reads the terms' taxonomies through `ctx.db`, so an unreadable vocabulary refuses the
839
+ * assignment rather than being waved through. */
840
+ async function assertTermsApplyTo(db, termIds, target) {
841
+ if (termIds.length === 0)
842
+ return;
843
+ const terms = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "taxonomyId"], limit: termIds.length });
844
+ const taxIds = [...new Set(terms.map((t) => String(t.taxonomyId)))];
845
+ if (taxIds.length === 0)
846
+ return;
847
+ const taxa = await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug", "appliesTo"], limit: taxIds.length });
848
+ const bad = taxa.filter((t) => !taxonomyApplies(t, target));
849
+ if (bad[0])
850
+ throw new BadRequest(`vocabulary '${String(bad[0].slug)}' does not apply to ${target}`);
851
+ }
675
852
  /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
676
853
  * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
677
- export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
854
+ export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, ADMIN_ELEMENT_TYPES, ADMIN_PAGE_KINDS, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
855
+ /** Custom admin PANELS — a project's own React screen inside the editor's chrome, for the
856
+ * screens a server-driven vocabulary cannot carry. See `./panel`. */
857
+ export { adminPanel, isAdminPanel } from "./panel";
678
858
  /**
679
859
  * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
680
860
  *
@@ -2503,9 +2683,35 @@ export function createCmsHandlers(opts = {}) {
2503
2683
  // same way it creates its content types. A vocabulary that is hierarchical allows
2504
2684
  // `parentId` on its terms; a flat one refuses it rather than storing something no
2505
2685
  // listing renders.
2506
- /** Every vocabulary. PUBLIC like content types, a taxonomy's slug is structural (it
2507
- * is a URL segment) and a front end routes on it. */
2508
- listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
2686
+ /** Every vocabulary, or just the ones that classify `target`. PUBLIC like content
2687
+ * types, a taxonomy's slug is structural (it is a URL segment) and a front end routes on
2688
+ * it.
2689
+ *
2690
+ * Narrowed HERE rather than in each caller, so the page panel, the media panel and the
2691
+ * write-side guard cannot disagree about what a vocabulary applies to. In memory, because
2692
+ * `appliesTo` is a `t.json()` column that `where` cannot see into — which costs nothing:
2693
+ * this handler already reads every taxonomy, and nothing pages by them.
2694
+ *
2695
+ * No `target` means EVERY vocabulary, which is what the Taxonomies screen needs: the one
2696
+ * place that edits `appliesTo` must be able to see a vocabulary it has narrowed away. */
2697
+ listTaxonomies: query(async (ctx, input) => {
2698
+ const rows = await cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT });
2699
+ const target = input?.target;
2700
+ return target === undefined ? rows : rows.filter((r) => taxonomyApplies(r, target));
2701
+ }, {
2702
+ input: (raw) => {
2703
+ const t = asObj(raw).target;
2704
+ // Unrecognised narrows to nothing rather than falling back to everything: this one
2705
+ // decides what a panel OFFERS, and answering "all of them" to a question the server
2706
+ // did not understand is how a media panel ends up listing page-only vocabularies.
2707
+ if (t === undefined || t === null)
2708
+ return {};
2709
+ if (typeof t !== "string" || !TAXONOMY_TARGETS.includes(t)) {
2710
+ throw new BadRequest(`target must be one of ${TAXONOMY_TARGETS.join(", ")}`);
2711
+ }
2712
+ return { target: t };
2713
+ },
2714
+ }),
2509
2715
  createTaxonomy: mutation(async (ctx, input) => {
2510
2716
  const db = cdb(ctx);
2511
2717
  const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
@@ -2517,6 +2723,9 @@ export function createCmsHandlers(opts = {}) {
2517
2723
  pluralLabel: input.pluralLabel ?? null,
2518
2724
  description: input.description ?? null,
2519
2725
  hierarchical: input.hierarchical ?? false,
2726
+ // Unsent means EVERY target, which is what a vocabulary created before this existed
2727
+ // means too — one reading of NULL, so an old row and a new one behave alike.
2728
+ appliesTo: input.appliesTo ?? null,
2520
2729
  });
2521
2730
  }, {
2522
2731
  ...editor,
@@ -2528,6 +2737,7 @@ export function createCmsHandlers(opts = {}) {
2528
2737
  pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
2529
2738
  description: typeof o.description === "string" ? o.description : undefined,
2530
2739
  hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
2740
+ appliesTo: parseAppliesTo(o.appliesTo),
2531
2741
  };
2532
2742
  },
2533
2743
  }),
@@ -2536,7 +2746,14 @@ export function createCmsHandlers(opts = {}) {
2536
2746
  *
2537
2747
  * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
2538
2748
  * would leave a stored hierarchy that no reader renders and no writer can clear, and
2539
- * flattening the terms silently is a destructive edit behind a checkbox. */
2749
+ * flattening the terms silently is a destructive edit behind a checkbox.
2750
+ *
2751
+ * NARROWING `appliesTo` is refused on exactly the same grounds, and it is the same bug:
2752
+ * dropping a target this vocabulary is already used for would strand those assignments —
2753
+ * still stored, still returned by `listPageTerms`/`listMediaTerms`, but invisible in the
2754
+ * panel that could remove them, because the panel only renders vocabularies that apply.
2755
+ * Unassign them first; then the narrowing is a settings change rather than a silent
2756
+ * orphaning. WIDENING is always fine — it strands nothing. */
2540
2757
  updateTaxonomy: mutation(async (ctx, input) => {
2541
2758
  const db = cdb(ctx);
2542
2759
  const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
@@ -2548,8 +2765,20 @@ export function createCmsHandlers(opts = {}) {
2548
2765
  if (nested[0])
2549
2766
  throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
2550
2767
  }
2768
+ if (input.appliesTo !== undefined && input.appliesTo !== null) {
2769
+ const next = input.appliesTo;
2770
+ for (const target of TAXONOMY_TARGETS) {
2771
+ // Only a target this vocabulary applies to TODAY and would not after the patch.
2772
+ if (next.includes(target) || !taxonomyApplies(row, target))
2773
+ continue;
2774
+ const junction = target === "page" ? "cms_page_terms" : "cms_media_terms";
2775
+ const used = await db.find({ from: junction, where: { term: { taxonomyId: input.id } }, select: ["id"], limit: 1 });
2776
+ if (used[0])
2777
+ throw new BadRequest(`this vocabulary is still assigned to ${target === "page" ? "pages" : "media"} — remove those assignments before narrowing it`);
2778
+ }
2779
+ }
2551
2780
  const patch = {};
2552
- for (const k of ["label", "pluralLabel", "description", "hierarchical"]) {
2781
+ for (const k of ["label", "pluralLabel", "description", "hierarchical", "appliesTo"]) {
2553
2782
  if (input[k] !== undefined)
2554
2783
  patch[k] = input[k];
2555
2784
  }
@@ -2569,6 +2798,9 @@ export function createCmsHandlers(opts = {}) {
2569
2798
  out.description = typeof o.description === "string" ? o.description : null;
2570
2799
  if (typeof o.hierarchical === "boolean")
2571
2800
  out.hierarchical = o.hierarchical;
2801
+ const appliesTo = parseAppliesTo(o.appliesTo);
2802
+ if (appliesTo !== undefined)
2803
+ out.appliesTo = appliesTo;
2572
2804
  return out;
2573
2805
  },
2574
2806
  }),
@@ -2752,6 +2984,7 @@ export function createCmsHandlers(opts = {}) {
2752
2984
  const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
2753
2985
  if (found.length !== wanted.size)
2754
2986
  throw new BadRequest("one or more termIds are not terms");
2987
+ await assertTermsApplyTo(db, [...wanted], "page");
2755
2988
  }
2756
2989
  const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
2757
2990
  const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
@@ -2951,7 +3184,15 @@ export function createCmsHandlers(opts = {}) {
2951
3184
  filename: input.ref.filename,
2952
3185
  uploadedAt: Date.now(),
2953
3186
  };
2954
- return cdb(ctx).insert("cms_media", { file, alt: input.alt ?? null });
3187
+ // The projection columns go in beside `file`, from the SAME resolved values — never
3188
+ // from `input`, which is the client's claim about a blob it has just uploaded.
3189
+ return cdb(ctx).insert("cms_media", {
3190
+ file,
3191
+ filename: file.filename ?? null,
3192
+ contentType: file.contentType ?? null,
3193
+ size: file.size ?? null,
3194
+ alt: input.alt ?? null,
3195
+ });
2955
3196
  }, {
2956
3197
  ...editor,
2957
3198
  input: (raw) => {
@@ -2973,8 +3214,40 @@ export function createCmsHandlers(opts = {}) {
2973
3214
  listMedia: query((ctx, input) => {
2974
3215
  const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
2975
3216
  const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
2976
- return cdb(ctx).find({ from: "cms_media", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
2977
- }, viewer),
3217
+ // The narrowings AND together a search inside a type filter inside a tag is all
3218
+ // three. Built as a list so none has to know whether the others are present.
3219
+ const clauses = [
3220
+ mediaKindWhere(input?.kind),
3221
+ input?.q ? mediaSearchWhere(input.q) : undefined,
3222
+ // A relation traversal, compiled to a subquery through `cms_media_terms`. Filtering
3223
+ // by term therefore costs the same page of rows as filtering by kind — the whole
3224
+ // reason the assignments are a junction rather than a JSON array on the media row.
3225
+ input?.term ? { terms: { id: input.term } } : undefined,
3226
+ ].filter((c) => c !== undefined);
3227
+ const where = clauses.length === 0 ? undefined : clauses.length === 1 ? clauses[0] : { AND: clauses };
3228
+ return cdb(ctx).find({ from: "cms_media", where, orderBy: MEDIA_SORTS[input?.sort ?? "newest"], limit, offset });
3229
+ }, {
3230
+ ...viewer,
3231
+ // Parsed, not cast: `sort` names an ORDER BY and `kind` a WHERE, and both arrive from a
3232
+ // browser. Anything unrecognised falls back to the default rather than erroring — a
3233
+ // stale bookmark carrying a sort this build dropped should show the library, not a 400.
3234
+ input: (raw) => {
3235
+ const o = asObj(raw);
3236
+ const sort = typeof o.sort === "string" && o.sort in MEDIA_SORTS ? o.sort : undefined;
3237
+ const kind = typeof o.kind === "string" && MEDIA_KINDS.includes(o.kind) ? o.kind : undefined;
3238
+ return {
3239
+ limit: typeof o.limit === "number" ? o.limit : undefined,
3240
+ offset: typeof o.offset === "number" ? o.offset : undefined,
3241
+ sort,
3242
+ kind,
3243
+ q: mediaQuery(o.q),
3244
+ // An id, not a slug: a slug identifies a term only within its vocabulary, and the
3245
+ // filter has no vocabulary to resolve it against. An id that is not a term matches
3246
+ // nothing, which is the same answer as a term with no files.
3247
+ term: typeof o.term === "string" && o.term !== "" ? o.term : undefined,
3248
+ };
3249
+ },
3250
+ }),
2978
3251
  getMedia: query(async (ctx, input) => {
2979
3252
  const rows = await cdb(ctx).find({ from: "cms_media", where: { id: input.id }, limit: 1 });
2980
3253
  return rows[0] ?? null;
@@ -3002,6 +3275,74 @@ export function createCmsHandlers(opts = {}) {
3002
3275
  return { id: o.id, alt: typeof o.alt === "string" ? o.alt : null };
3003
3276
  },
3004
3277
  }),
3278
+ /** A media asset's assigned terms.
3279
+ *
3280
+ * The media row is read FIRST, through `ctx.db`, so the caller's own scope decides
3281
+ * whether this answers — the junction and `cms_terms` are granted unscoped, so without
3282
+ * it anyone holding an id could read a TRASHED file's tags and the non-empty answer
3283
+ * would confirm the file exists. Same rule as `listPageTerms`, for the same reason. */
3284
+ listMediaTerms: query(async (ctx, input) => {
3285
+ const db = cdb(ctx);
3286
+ const media = await db.find({ from: "cms_media", where: { id: input.mediaId }, select: ["id"], limit: 1 });
3287
+ if (!media[0])
3288
+ return [];
3289
+ const links = await db.find({ from: "cms_media_terms", where: { mediaId: input.mediaId }, select: ["termId"], limit: MAX_TERMS });
3290
+ const ids = links.map((l) => String(l.termId));
3291
+ if (ids.length === 0)
3292
+ return [];
3293
+ const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
3294
+ return rows;
3295
+ }, {
3296
+ ...viewer,
3297
+ input: (raw) => {
3298
+ const id = asObj(raw).mediaId;
3299
+ if (typeof id !== "string" || id === "")
3300
+ throw new BadRequest("mediaId is required");
3301
+ return { mediaId: id };
3302
+ },
3303
+ }),
3304
+ /** Replace a media asset's term assignments wholesale — set semantics, like
3305
+ * `setPageTerms`, and for the same reason: the panel holds the whole selection, and two
3306
+ * calls each patching one end of it race into a state neither asked for. */
3307
+ setMediaTerms: mutation(async (ctx, input) => {
3308
+ const db = cdb(ctx);
3309
+ const media = await db.find({ from: "cms_media", where: { id: input.mediaId }, select: ["id"], limit: 1 });
3310
+ if (!media[0])
3311
+ throw notFound("media");
3312
+ const wanted = new Set(input.termIds);
3313
+ if (wanted.size > 0) {
3314
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
3315
+ if (found.length !== wanted.size)
3316
+ throw new BadRequest("one or more termIds are not terms");
3317
+ await assertTermsApplyTo(db, [...wanted], "media");
3318
+ }
3319
+ const existing = await db.find({ from: "cms_media_terms", where: { mediaId: input.mediaId }, select: ["id", "termId"], limit: MAX_TERMS });
3320
+ const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
3321
+ for (const [termId, linkId] of have)
3322
+ if (!wanted.has(termId))
3323
+ await db.delete("cms_media_terms", linkId);
3324
+ for (const termId of wanted)
3325
+ if (!have.has(termId))
3326
+ await db.insert("cms_media_terms", { mediaId: input.mediaId, termId });
3327
+ return { ok: true, count: wanted.size };
3328
+ }, {
3329
+ ...editor,
3330
+ input: (raw) => {
3331
+ const o = asObj(raw);
3332
+ if (typeof o.mediaId !== "string" || o.mediaId === "")
3333
+ throw new BadRequest("mediaId is required");
3334
+ if (!Array.isArray(o.termIds))
3335
+ throw new BadRequest("termIds must be a list");
3336
+ const ids = o.termIds.map((v) => {
3337
+ if (typeof v !== "string" || v === "")
3338
+ throw new BadRequest("termIds must be a list of ids");
3339
+ return v;
3340
+ });
3341
+ if (ids.length > MAX_TERMS)
3342
+ throw new BadRequest(`a file may carry at most ${MAX_TERMS} terms`);
3343
+ return { mediaId: o.mediaId, termIds: ids };
3344
+ },
3345
+ }),
3005
3346
  /** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
3006
3347
  * `restoreMedia` a lie, and a block still referencing the id would render a dead url
3007
3348
  * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
@@ -3420,6 +3761,11 @@ export function createCmsHandlers(opts = {}) {
3420
3761
  // the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
3421
3762
  // fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
3422
3763
  codeDefinedTypes: true,
3764
+ // Media carries taxonomy terms, and `listMedia` understands `term`. Declared for the
3765
+ // usual reason: an older server has neither handler, so the detail panel's Tags
3766
+ // section would 404 on open and the library's tag filter would send an argument that
3767
+ // is ignored — a filter that visibly does nothing. Absent ⇒ neither is drawn.
3768
+ mediaTerms: true,
3423
3769
  // PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
3424
3770
  // so a reviewer-only session reaches this handler and every read handler — but every
3425
3771
  // WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
@@ -4035,7 +4381,7 @@ export function cmsPolicies(opts = {}) {
4035
4381
  "cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
4036
4382
  // Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
4037
4383
  // `auth` gate is what separates editor from reviewer; this is the row scope.
4038
- "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
4384
+ "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_media_terms", "cms_widget_areas",
4039
4385
  ];
4040
4386
  // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
4041
4387
  // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
@@ -4092,6 +4438,10 @@ export function cmsPolicies(opts = {}) {
4092
4438
  policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
4093
4439
  policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
4094
4440
  policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
4441
+ // The media junction, for the same reason as the page one: `where: { terms: … }` on a
4442
+ // media row compiles to a subquery THROUGH it, so without the grant the library's tag
4443
+ // filter matches nothing and reads as "no files carry this tag".
4444
+ policy(`${p}:public:media-terms:read`, "cms_media_terms", "read", allow()),
4095
4445
  policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
4096
4446
  ],
4097
4447
  editor: editorPolicies,
@@ -5165,6 +5515,61 @@ export function createCollectionTasks(collections) {
5165
5515
  * between the revision insert and the page update leaves the page unpublished with an orphan
5166
5516
  * revision until the next at-least-once redelivery re-runs (the token still matches, so it
5167
5517
  * completes). Acceptable for a scheduled job; the interactive path is atomic. */
5518
+ /**
5519
+ * The CMS's own data migrations — spread into `app.migrations`.
5520
+ *
5521
+ * migrations: [...cmsMigrations]
5522
+ *
5523
+ * Opt-in like every other fragment this package ships (`cmsHandlers`, `cmsPolicies`,
5524
+ * `cmsTasks`), and with the same consequence for forgetting it: nothing breaks loudly. Media
5525
+ * rows written before the projection columns existed keep NULL `filename`/`contentType`, so
5526
+ * they sort together under a name sort and answer only the `other` type filter. New uploads
5527
+ * are unaffected — `createMedia` writes the columns itself.
5528
+ */
5529
+ export const cmsMigrations = [
5530
+ {
5531
+ // Fill the columns `cms_media` grew for sorting and filtering, out of the `file` JSON that
5532
+ // has always held the same three values.
5533
+ id: "cms:2026-09-04-media-projection-columns",
5534
+ // `MigrationContext<typeof cmsSchema>` is what types `db` here — the `DataMigration`
5535
+ // contract is schema-agnostic, so an unparameterized ctx hands back untyped rows.
5536
+ async up({ db, driver }) {
5537
+ const d = driver.dialect;
5538
+ const t = d.id("cms_media");
5539
+ const set = (c, path) => `${d.id(c)} = json_extract(${d.id("file")}, '$.${path}')`;
5540
+ try {
5541
+ // One statement for the whole table. `WHERE filename IS NULL` makes it cheap on a
5542
+ // store that has nothing to do, and keeps it off rows a later upload already filled.
5543
+ await driver.exec(`UPDATE ${t} SET ${set("filename", "filename")}, ${set("contentType", "contentType")}, ${set("size", "size")} ` +
5544
+ `WHERE ${d.id("filename")} IS NULL AND ${d.id("contentType")} IS NULL`, []);
5545
+ return;
5546
+ }
5547
+ catch {
5548
+ // `json_extract` is a JSON1 function. It is present in D1 and in every ordinary SQLite
5549
+ // build, but nothing in this repo has depended on it before and DO SQLite is
5550
+ // Cloudflare's own engine — so a missing function must not brick a tenant's boot,
5551
+ // which is exactly what a data migration's fail-closed contract would otherwise do.
5552
+ // The fallback walks the rows through the ORM, where the fileRef codec has already
5553
+ // parsed the same JSON for us. Slower, and bounded by how many media a tenant has.
5554
+ }
5555
+ // No chunking: this runs inside `blockConcurrencyWhile` on a tenant's first fetch, so a
5556
+ // very large library will stall that one request — which is still the right trade
5557
+ // against leaving half the table unsorted forever, since a migration runs ONCE.
5558
+ const rows = await db.find({ from: "cms_media", where: { filename: { isNull: true }, contentType: { isNull: true } } });
5559
+ for (const row of rows) {
5560
+ const file = row.file;
5561
+ if (!file || typeof file !== "object" || Array.isArray(file))
5562
+ continue;
5563
+ const ref = file;
5564
+ await db.update("cms_media", String(row.id), {
5565
+ filename: typeof ref.filename === "string" ? ref.filename : null,
5566
+ contentType: typeof ref.contentType === "string" ? ref.contentType : null,
5567
+ size: typeof ref.size === "number" ? ref.size : null,
5568
+ });
5569
+ }
5570
+ },
5571
+ },
5572
+ ];
5168
5573
  export const cmsTasks = {
5169
5574
  "cms:publish": async (ctx, payload) => {
5170
5575
  const { pageId, token } = asObj(payload);
@@ -0,0 +1,53 @@
1
+ /** One custom admin panel: a nav entry the browser bundle fills in.
2
+ *
3
+ * There is no `render` here and there is not meant to be one — the rendering half is a
4
+ * React component the deployment's panel bundle registers under the same `slug`. Everything
5
+ * that decides whether the entry EXISTS is here, on the server, where it can be enforced.
6
+ */
7
+ export interface AdminPanelDef {
8
+ /** Discriminates a panel from an `AdminPageDef` in the one registry they share. Set by
9
+ * {@link adminPanel}; it is a required field rather than an inferred one so a hand-built
10
+ * object literal cannot be a half-declared panel. */
11
+ readonly kind: "panel";
12
+ /** URL + registry key: served at `/apps/:slug` in the editor, and the id the browser
13
+ * bundle registers its component under. */
14
+ readonly slug: string;
15
+ /** Nav label. */
16
+ readonly label: string;
17
+ /** Optional nav icon (emoji or short string). */
18
+ readonly icon?: string;
19
+ /** Where it sits in the nav — see `NAV_ORDER`. Defaults to `NAV_ORDER.adminPages`. */
20
+ readonly navOrder?: number;
21
+ /** Roles that may open it. Defaults to the deployment's `editorRoles`, exactly as a Block
22
+ * Kit page's does — one registry, one gate.
23
+ *
24
+ * This is the ONLY authorization a panel gets for free. A panel's own code runs in the
25
+ * browser, so every call it makes is an ordinary RPC under the caller's own identity and
26
+ * ACL; this list decides who is shown the screen, not what the screen may do. */
27
+ readonly roles?: readonly string[];
28
+ }
29
+ /**
30
+ * Declare a custom admin panel. Spread the result into `createAdminPageHandlers` alongside
31
+ * any `adminPage()`s:
32
+ *
33
+ * const curation = adminPanel("curation", {
34
+ * label: "Curation",
35
+ * icon: "🎛",
36
+ * navOrder: NAV_ORDER.media + 10,
37
+ * roles: ["editor", "admin"],
38
+ * });
39
+ *
40
+ * handlers = { ...createAdminPageHandlers([desk, curation], { editorRoles }) };
41
+ *
42
+ * The matching component is registered by the deployment's panel bundle — see the
43
+ * "Custom admin panels" section of the CMS docs.
44
+ */
45
+ export declare function adminPanel(slug: string, opts: Omit<AdminPanelDef, "slug" | "kind">): AdminPanelDef;
46
+ /** Whether a registry entry is a panel (and so has no server-side render).
47
+ *
48
+ * Reads the discriminant rather than testing for the ABSENCE of `render`: "no render" is
49
+ * also what a malformed page looks like, and `validateAdminPages` has to be able to tell a
50
+ * panel from a page someone forgot to finish. */
51
+ export declare function isAdminPanel(def: {
52
+ readonly kind?: string;
53
+ }): def is AdminPanelDef;