@bobbykim/manguito-cms-api 0.6.0 → 0.8.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.
package/dist/index.mjs CHANGED
@@ -4,11 +4,13 @@ import {
4
4
  createFieldKeyMapFromProjection,
5
5
  deleteParagraphField,
6
6
  isColumnBacked,
7
+ oneOrMany,
8
+ paragraphItems,
7
9
  persistJunctionField,
8
10
  persistParagraphField,
9
11
  resolveRelationBareIds,
10
12
  resolveRelationField
11
- } from "./chunk-JVP33FI2.mjs";
13
+ } from "./chunk-IHEIX6P4.mjs";
12
14
 
13
15
  // src/paths.ts
14
16
  function normalizePrefix(prefix) {
@@ -36,7 +38,7 @@ function createVersionedPaths(prefix, version) {
36
38
 
37
39
  // src/app.ts
38
40
  import { Hono } from "hono";
39
- import { sql as sql9 } from "drizzle-orm";
41
+ import { sql as sql10 } from "drizzle-orm";
40
42
 
41
43
  // src/middleware/cors.ts
42
44
  function createCorsMiddleware(corsConfig) {
@@ -130,16 +132,20 @@ var ERROR_STATUS_MAP = {
130
132
  UNSUPPORTED_MIME_TYPE: 415,
131
133
  STORAGE_ERROR: 502,
132
134
  MEDIA_IN_USE: 409,
135
+ ITEM_IN_USE: 409,
133
136
  PRESIGNED_URL_EXPIRED: 410,
134
137
  RATE_LIMITED: 429,
135
138
  INTERNAL_ERROR: 500
136
139
  };
137
140
  var errorHandler = (err, c) => {
138
141
  console.error(err.stack ?? err.message);
139
- const apiErr = err;
140
- const code = apiErr.code ?? "INTERNAL_ERROR";
141
- const status = ERROR_STATUS_MAP[code] ?? 500;
142
- return c.json({ ok: false, error: { code, message: err.message } }, status);
142
+ const raw = err.code;
143
+ const deliberate = raw !== void 0 && raw !== "INTERNAL_ERROR" && raw in ERROR_STATUS_MAP;
144
+ if (!deliberate) {
145
+ return c.json({ ok: false, error: { code: "INTERNAL_ERROR", message: "Internal server error" } }, 500);
146
+ }
147
+ const status = ERROR_STATUS_MAP[raw] ?? 500;
148
+ return c.json({ ok: false, error: { code: raw, message: err.message } }, status);
143
149
  };
144
150
 
145
151
  // src/middleware/auth.ts
@@ -617,14 +623,16 @@ function isPlainRow(v) {
617
623
  return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
618
624
  }
619
625
  var MEDIA_FIELD_TYPES = /* @__PURE__ */ new Set(["image", "video", "file"]);
620
- function nestedTargets(fields) {
626
+ function nestedTargets(fields, map) {
621
627
  const out = [];
622
628
  for (const f of fields) {
623
629
  if (MEDIA_FIELD_TYPES.has(f.field_type)) continue;
624
630
  if (f.field_type !== "paragraph" && f.field_type !== "reference") continue;
625
631
  const ref = f.ui_component.ref;
626
632
  if (!ref) continue;
627
- out.push({ label: f.name, target: ref });
633
+ const label = isColumnBacked(f) ? map.labelFor(f.db_column.column_name) : f.name;
634
+ if (label === void 0) continue;
635
+ out.push({ label, target: ref });
628
636
  }
629
637
  return out;
630
638
  }
@@ -642,7 +650,7 @@ function buildProjectors(registry, fieldKeyMaps, fallbacks) {
642
650
  const typeFallbacks = fallbacks?.[typeName];
643
651
  projectors[typeName] = {
644
652
  map,
645
- nested: nestedTargets(type.fields),
653
+ nested: nestedTargets(type.fields, map),
646
654
  // Spread rather than assign: with exactOptionalPropertyTypes, an optional
647
655
  // property may be ABSENT but not present-and-undefined.
648
656
  ...typeFallbacks !== void 0 && { fallbacks: typeFallbacks }
@@ -726,7 +734,28 @@ function parseFilters(url, validFields, columnFor) {
726
734
  function isPublished(item) {
727
735
  return item["published"] === true;
728
736
  }
729
- function registerPublicContentRoutes(app, registry, repos, projectors, paths, listRateLimit, resolver) {
737
+ function registerPublicContentRoutes(app, registry, repos, projectors, paths, listRateLimit, resolver, resolverProjectors) {
738
+ const forResolver = resolverProjectors ?? projectors;
739
+ function pickProgrammatic(resolved, names) {
740
+ const out = {};
741
+ for (const name of names) if (name in resolved) out[name] = resolved[name];
742
+ return out;
743
+ }
744
+ async function respondItem(row, typeName, programmatic) {
745
+ const data = projectRow(row, typeName, projectors);
746
+ if (!resolver?.hasSchema(typeName)) return data;
747
+ const resolved = await resolver.resolveItem(typeName, projectRow(row, typeName, forResolver));
748
+ return { ...data, ...pickProgrammatic(resolved, programmatic) };
749
+ }
750
+ async function respondList(rows, typeName, programmatic) {
751
+ const data = rows.map((row) => projectRow(row, typeName, projectors));
752
+ if (!resolver?.hasSchema(typeName)) return data;
753
+ const resolved = await resolver.resolveList(
754
+ typeName,
755
+ rows.map((row) => projectRow(row, typeName, forResolver))
756
+ );
757
+ return data.map((d, i) => ({ ...d, ...pickProgrammatic(resolved[i], programmatic) }));
758
+ }
730
759
  function registerListRoute(path, handler) {
731
760
  if (listRateLimit) {
732
761
  app.get(path, listRateLimit, handler);
@@ -750,18 +779,29 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
750
779
  return c.json({ ok: true, data });
751
780
  });
752
781
  for (const [typeName, contentType] of Object.entries(registry.content_types)) {
782
+ let translateInclude2 = function(requested) {
783
+ const include = [];
784
+ for (const field of requested) {
785
+ const registryName = includeNames.get(field);
786
+ if (registryName === void 0) return { ok: false, field };
787
+ include.push(registryName);
788
+ }
789
+ return { ok: true, include };
790
+ };
791
+ var translateInclude = translateInclude2;
753
792
  const basePath = contentType.default_base_path;
754
793
  const repo = repos[typeName];
755
794
  if (!repo) continue;
756
795
  const fieldKeys = projectors[typeName].map;
757
- const relationFieldNames = /* @__PURE__ */ new Set();
796
+ const programmatic = contentType.fields.filter((f) => f.field_type === "programmatic").map((f) => f.name);
797
+ const includeNames = /* @__PURE__ */ new Map();
758
798
  for (const f of contentType.fields) {
759
799
  if (!RELATION_FIELD_TYPES.has(f.field_type)) continue;
760
800
  if (isColumnBacked(f)) {
761
801
  const label = fieldKeys.labelFor(f.db_column.column_name);
762
- if (label !== void 0) relationFieldNames.add(label);
802
+ if (label !== void 0) includeNames.set(label, f.name);
763
803
  } else {
764
- relationFieldNames.add(f.name);
804
+ includeNames.set(f.name, f.name);
765
805
  }
766
806
  }
767
807
  const filterableFieldNames = /* @__PURE__ */ new Set([
@@ -780,8 +820,11 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
780
820
  404
781
821
  );
782
822
  }
783
- let data = projectRow(result.data[0], typeName, projectors);
784
- if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
823
+ const data = await respondItem(
824
+ result.data[0],
825
+ typeName,
826
+ programmatic
827
+ );
785
828
  return c.json({ ok: true, data });
786
829
  });
787
830
  } else {
@@ -838,21 +881,20 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
838
881
  400
839
882
  );
840
883
  }
841
- const include = parseInclude(c.req.query("include"));
842
- for (const field of include) {
843
- if (!relationFieldNames.has(field)) {
844
- return c.json(
845
- {
846
- ok: false,
847
- error: {
848
- code: "INVALID_INCLUDE_FIELD",
849
- message: `'${field}' is not a valid relation field`
850
- }
851
- },
852
- 400
853
- );
854
- }
884
+ const translated = translateInclude2(parseInclude(c.req.query("include")));
885
+ if (!translated.ok) {
886
+ return c.json(
887
+ {
888
+ ok: false,
889
+ error: {
890
+ code: "INVALID_INCLUDE_FIELD",
891
+ message: `'${translated.field}' is not a valid relation field`
892
+ }
893
+ },
894
+ 400
895
+ );
855
896
  }
897
+ const include = translated.include;
856
898
  const sortColumn = fieldKeys.labels.includes(sortBy) ? fieldKeys.columnFor(sortBy) : sortBy;
857
899
  const result = await repo.findMany({
858
900
  published_only: true,
@@ -863,29 +905,25 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
863
905
  filters: filtersResult.filters,
864
906
  include
865
907
  });
866
- const labeled = result.data.map(
867
- (row) => projectRow(row, typeName, projectors)
868
- );
869
- const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
908
+ const data = await respondList(result.data, typeName, programmatic);
870
909
  return c.json({ ...result, data });
871
910
  });
872
911
  app.get(paths.item(basePath), async (c) => {
873
912
  const slug = c.req.param("slug");
874
- const include = parseInclude(c.req.query("include"));
875
- for (const field of include) {
876
- if (!relationFieldNames.has(field)) {
877
- return c.json(
878
- {
879
- ok: false,
880
- error: {
881
- code: "INVALID_INCLUDE_FIELD",
882
- message: `'${field}' is not a valid relation field`
883
- }
884
- },
885
- 400
886
- );
887
- }
913
+ const translated = translateInclude2(parseInclude(c.req.query("include")));
914
+ if (!translated.ok) {
915
+ return c.json(
916
+ {
917
+ ok: false,
918
+ error: {
919
+ code: "INVALID_INCLUDE_FIELD",
920
+ message: `'${translated.field}' is not a valid relation field`
921
+ }
922
+ },
923
+ 400
924
+ );
888
925
  }
926
+ const include = translated.include;
889
927
  const item = await repo.findBySlug(slug, include);
890
928
  if (!item || !isPublished(item)) {
891
929
  return c.json(
@@ -896,15 +934,15 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
896
934
  404
897
935
  );
898
936
  }
899
- let data = projectRow(item, typeName, projectors);
900
- if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
937
+ const data = await respondItem(item, typeName, programmatic);
901
938
  return c.json({ ok: true, data });
902
939
  });
903
940
  }
904
941
  }
905
- for (const [typeName] of Object.entries(registry.taxonomy_types)) {
942
+ for (const [typeName, taxonomyType] of Object.entries(registry.taxonomy_types)) {
906
943
  const repo = repos[typeName];
907
944
  if (!repo) continue;
945
+ const programmatic = taxonomyType.fields.filter((f) => f.field_type === "programmatic").map((f) => f.name);
908
946
  registerListRoute(paths.taxonomyCollection(typeName), async (c) => {
909
947
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
910
948
  if (!pagination.ok) {
@@ -924,10 +962,7 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
924
962
  page: pagination.page,
925
963
  per_page: pagination.per_page
926
964
  });
927
- const labeled = result.data.map(
928
- (row) => projectRow(row, typeName, projectors)
929
- );
930
- const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
965
+ const data = await respondList(result.data, typeName, programmatic);
931
966
  return c.json({ ...result, data });
932
967
  });
933
968
  app.get(paths.taxonomyItem(typeName), async (c) => {
@@ -942,8 +977,7 @@ function registerPublicContentRoutes(app, registry, repos, projectors, paths, li
942
977
  404
943
978
  );
944
979
  }
945
- let data = projectRow(item, typeName, projectors);
946
- if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
980
+ const data = await respondItem(item, typeName, programmatic);
947
981
  return c.json({ ok: true, data });
948
982
  });
949
983
  }
@@ -1015,7 +1049,55 @@ function registerPublicMediaRoutes(app, mediaRepo, paths, listRateLimit) {
1015
1049
  }
1016
1050
 
1017
1051
  // src/routes/admin/content.ts
1052
+ import { sql as sql3 } from "drizzle-orm";
1053
+
1054
+ // src/references-in-use.ts
1018
1055
  import { sql as sql2 } from "drizzle-orm";
1056
+ import { relationCardinality } from "@bobbykim/manguito-cms-core";
1057
+ function quoteIdent(name) {
1058
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
1059
+ return `"${name}"`;
1060
+ }
1061
+ function isRequiredSingleReferenceTo(f, targetType) {
1062
+ return f.field_type === "reference" && f.required && relationCardinality(f) === "one" && f.db_column?.foreign_key !== void 0 && f.ui_component.component === "typeahead-select" && f.ui_component.ref === targetType;
1063
+ }
1064
+ async function findRequiredReferrers(db, registry, targetType, id) {
1065
+ const owners = [
1066
+ ...Object.values(registry.content_types),
1067
+ ...Object.values(registry.taxonomy_types),
1068
+ ...Object.values(registry.paragraph_types)
1069
+ ];
1070
+ const out = [];
1071
+ for (const owner of owners) {
1072
+ for (const f of owner.fields) {
1073
+ if (!isRequiredSingleReferenceTo(f, targetType)) continue;
1074
+ const notSelf = owner.name === targetType ? sql2` AND id <> ${id}` : sql2``;
1075
+ const r = await db.execute(
1076
+ sql2`SELECT count(*)::int AS n FROM ${sql2.raw(quoteIdent(owner.db.table_name))} WHERE ${sql2.raw(quoteIdent(f.db_column.column_name))} = ${id}${notSelf}`
1077
+ );
1078
+ const n = r.rows[0].n;
1079
+ if (n > 0) out.push({ type_label: owner.label, field_label: f.label, count: n });
1080
+ }
1081
+ }
1082
+ return out;
1083
+ }
1084
+ function inUseMessage(referrers) {
1085
+ const total = referrers.reduce((sum, r) => sum + r.count, 0);
1086
+ const where = referrers.map((r) => `${r.count} in ${r.type_label} (${r.field_label})`).join(", ");
1087
+ return `This item is still used by ${total} item${total === 1 ? "" : "s"}: ${where}. Remove it from those first.`;
1088
+ }
1089
+ var IN_USE_RACE_MESSAGE = "This item is still in use. Remove it from the items that use it first.";
1090
+ function isForeignKeyViolation(err) {
1091
+ let e = err;
1092
+ for (let depth = 0; depth < 3 && typeof e === "object" && e !== null; depth++) {
1093
+ if (e.code === "23503") return true;
1094
+ e = e.cause;
1095
+ }
1096
+ return false;
1097
+ }
1098
+
1099
+ // src/routes/admin/content.ts
1100
+ import { relationCardinality as relationCardinality3 } from "@bobbykim/manguito-cms-core";
1019
1101
 
1020
1102
  // src/media-references.ts
1021
1103
  function extractMediaId(value) {
@@ -1051,14 +1133,60 @@ async function applyMediaReferenceDelta(delta, mediaRepo) {
1051
1133
  if (netRemove.length > 0) await mediaRepo.decrementReferenceCount(netRemove);
1052
1134
  }
1053
1135
 
1136
+ // src/relation-input.ts
1137
+ import { relationCardinality as relationCardinality2 } from "@bobbykim/manguito-cms-core";
1138
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1139
+ function isUuid(v) {
1140
+ return typeof v === "string" && UUID_RE.test(v);
1141
+ }
1142
+ function isPlainObject(v) {
1143
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1144
+ }
1145
+ function checkRelationInput(fields, body, registry, path = "") {
1146
+ const errors = [];
1147
+ for (const field of fields) {
1148
+ if (!(field.name in body)) continue;
1149
+ const cardinality = relationCardinality2(field);
1150
+ if (cardinality === null) continue;
1151
+ const value = body[field.name];
1152
+ const name = `${path}${field.name}`;
1153
+ if (field.field_type === "reference") {
1154
+ const ok = cardinality === "one" ? value === null || isUuid(value) : Array.isArray(value) && value.every(isUuid);
1155
+ if (!ok) {
1156
+ errors.push({
1157
+ field: name,
1158
+ message: cardinality === "one" ? `${name} holds one item: send a UUID string or null.` : `${name} holds a list: send an array of UUID strings.`
1159
+ });
1160
+ }
1161
+ continue;
1162
+ }
1163
+ const items = cardinality === "one" ? value === null ? [] : isPlainObject(value) ? [value] : void 0 : Array.isArray(value) && value.every(isPlainObject) ? value : void 0;
1164
+ if (items === void 0) {
1165
+ errors.push({
1166
+ field: name,
1167
+ message: cardinality === "one" ? `${name} holds one item: send an object or null.` : `${name} holds a list: send an array of objects.`
1168
+ });
1169
+ continue;
1170
+ }
1171
+ const comp = field.ui_component;
1172
+ const pType = comp.component === "paragraph-embed" ? registry.paragraph_types[comp.ref] : void 0;
1173
+ if (!pType) continue;
1174
+ items.forEach((item, i) => {
1175
+ const prefix = cardinality === "one" ? `${name}.` : `${name}[${i}].`;
1176
+ errors.push(...checkRelationInput(pType.fields, item, registry, prefix));
1177
+ });
1178
+ }
1179
+ return errors;
1180
+ }
1181
+
1054
1182
  // src/routes/admin/content.ts
1055
- function quoteIdent(name) {
1183
+ function quoteIdent2(name) {
1056
1184
  if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
1057
1185
  return `"${name}"`;
1058
1186
  }
1059
1187
  async function loadParagraphRows(db, registry, pType, parentId, fieldName) {
1060
1188
  const result = await db.execute(
1061
- sql2`SELECT * FROM ${sql2.raw(quoteIdent(pType.db.table_name))} WHERE parent_id = ${parentId} AND parent_field = ${fieldName} ORDER BY "order" ASC`
1189
+ sql3`SELECT * FROM ${sql3.raw(quoteIdent2(pType.db.table_name))} WHERE parent_id = ${parentId} AND parent_field = ${fieldName} ORDER BY "order" ASC`
1062
1190
  );
1063
1191
  const rows = result.rows;
1064
1192
  for (const nf of pType.fields) {
@@ -1068,14 +1196,17 @@ async function loadParagraphRows(db, registry, pType, parentId, fieldName) {
1068
1196
  const nType = registry.paragraph_types[comp.ref];
1069
1197
  if (!nType) continue;
1070
1198
  for (const row of rows) {
1071
- row[nf.name] = await loadParagraphRows(db, registry, nType, row["id"], nf.name);
1199
+ row[nf.name] = oneOrMany(
1200
+ relationCardinality3(nf) ?? "many",
1201
+ await loadParagraphRows(db, registry, nType, row["id"], nf.name)
1202
+ );
1072
1203
  }
1073
1204
  }
1074
1205
  return rows;
1075
1206
  }
1076
1207
  async function lookupBasePathId(db, pathOrName) {
1077
1208
  const r = await db.execute(
1078
- sql2`SELECT id FROM base_paths WHERE path = ${pathOrName} OR name = ${pathOrName} LIMIT 1`
1209
+ sql3`SELECT id FROM base_paths WHERE path = ${pathOrName} OR name = ${pathOrName} LIMIT 1`
1079
1210
  );
1080
1211
  return r.rows[0]?.id ?? null;
1081
1212
  }
@@ -1089,6 +1220,14 @@ function isEmpty(value) {
1089
1220
  function checkRequiredFields(fields, data) {
1090
1221
  return fields.filter((f) => f.required && isEmpty(data[f.name])).map((f) => ({ field: f.name, message: `${f.label} is required` }));
1091
1222
  }
1223
+ function relationShapeError(c, fields, body, registry) {
1224
+ const details = checkRelationInput(fields, body, registry);
1225
+ if (details.length === 0) return null;
1226
+ return c.json(
1227
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "Relation fields have the wrong shape", details } },
1228
+ 422
1229
+ );
1230
+ }
1092
1231
  function parseListQuery(url, schemaFieldNames, relationFieldNames, columnFor) {
1093
1232
  const searchParams = new URL(url).searchParams;
1094
1233
  const pagination = parsePagination(
@@ -1230,11 +1369,11 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1230
1369
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1231
1370
  const pType = registry.paragraph_types[comp.ref];
1232
1371
  if (!pType) continue;
1233
- row[f.name] = await loadParagraphRows(db, registry, pType, id, f.name);
1372
+ row[f.name] = oneOrMany(relationCardinality3(f) ?? "many", await loadParagraphRows(db, registry, pType, id, f.name));
1234
1373
  } else if (f.db_column.junction) {
1235
1374
  const j = f.db_column.junction;
1236
1375
  const r = await db.execute(
1237
- sql2`SELECT ${sql2.raw(quoteIdent(j.right_column))} FROM ${sql2.raw(quoteIdent(j.table_name))} WHERE ${sql2.raw(quoteIdent(j.left_column))} = ${id}`
1376
+ sql3`SELECT ${sql3.raw(quoteIdent2(j.right_column))} FROM ${sql3.raw(quoteIdent2(j.table_name))} WHERE ${sql3.raw(quoteIdent2(j.left_column))} = ${id}`
1238
1377
  );
1239
1378
  row[f.name] = r.rows.map(
1240
1379
  (row2) => row2[j.right_column]
@@ -1310,6 +1449,8 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1310
1449
  }
1311
1450
  );
1312
1451
  const writeNewItem = async (c, body) => {
1452
+ const shapeError = relationShapeError(c, contentType.fields, body, registry);
1453
+ if (shapeError) return shapeError;
1313
1454
  const storageBody = fieldKeys.toStorage(body);
1314
1455
  const fieldErrors = checkRequiredFields(requiredFields, body);
1315
1456
  if (fieldErrors.length > 0) {
@@ -1373,7 +1514,7 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1373
1514
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1374
1515
  const pType = registry.paragraph_types[comp.ref];
1375
1516
  if (!pType) continue;
1376
- const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1517
+ const items = paragraphItems(f, body[f.name]);
1377
1518
  mediaDeltas.push(await persistParagraphField(db, itemId, contentType.db.table_name, f.name, pType, items, registry));
1378
1519
  }
1379
1520
  for (const f of junctionFields) {
@@ -1444,6 +1585,8 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1444
1585
  }
1445
1586
  );
1446
1587
  const writeExistingItem = async (c, id, existing, body) => {
1588
+ const shapeError = relationShapeError(c, contentType.fields, body, registry);
1589
+ if (shapeError) return shapeError;
1447
1590
  const storageBody = fieldKeys.toStorage(body);
1448
1591
  if (body["published"] === true) {
1449
1592
  const publishDeny = await requirePermission("content:edit")(c, async () => {
@@ -1490,14 +1633,16 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1490
1633
  ];
1491
1634
  if (db) {
1492
1635
  for (const f of patchParagraphFields) {
1636
+ if (!(f.name in body)) continue;
1493
1637
  const comp = f.ui_component;
1494
1638
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1495
1639
  const pType = registry.paragraph_types[comp.ref];
1496
1640
  if (!pType) continue;
1497
- const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1641
+ const items = paragraphItems(f, body[f.name]);
1498
1642
  mediaDeltas.push(await persistParagraphField(db, id, contentType.db.table_name, f.name, pType, items, registry));
1499
1643
  }
1500
1644
  for (const f of patchJunctionFields) {
1645
+ if (!(f.name in body)) continue;
1501
1646
  const junction = f.db_column.junction;
1502
1647
  const relatedIds = Array.isArray(body[f.name]) ? body[f.name].filter((v) => typeof v === "string") : [];
1503
1648
  await persistJunctionField(db, id, junction, relatedIds);
@@ -1537,6 +1682,18 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1537
1682
  404
1538
1683
  );
1539
1684
  }
1685
+ const referrers = db ? await findRequiredReferrers(db, registry, typeName, id) : [];
1686
+ if (referrers.length > 0) {
1687
+ return c.json({ ok: false, error: { code: "ITEM_IN_USE", message: inUseMessage(referrers) } }, 409);
1688
+ }
1689
+ try {
1690
+ await repo.delete(id);
1691
+ } catch (err) {
1692
+ if (isForeignKeyViolation(err)) {
1693
+ return c.json({ ok: false, error: { code: "ITEM_IN_USE", message: IN_USE_RACE_MESSAGE } }, 409);
1694
+ }
1695
+ throw err;
1696
+ }
1540
1697
  const mediaDeltas = [
1541
1698
  topLevelMediaDelta(mediaFields, item, null)
1542
1699
  ];
@@ -1550,7 +1707,6 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1550
1707
  }
1551
1708
  }
1552
1709
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1553
- await repo.delete(id);
1554
1710
  return c.json({ ok: true });
1555
1711
  }
1556
1712
  );
@@ -1628,6 +1784,8 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1628
1784
  requirePermission("content:create"),
1629
1785
  async (c) => {
1630
1786
  const body = await c.req.json();
1787
+ const shapeError = relationShapeError(c, taxonomyType.fields, body, registry);
1788
+ if (shapeError) return shapeError;
1631
1789
  const storageBody = fieldKeys.toStorage(body);
1632
1790
  const fieldErrors = checkRequiredFields(requiredFields, body);
1633
1791
  if (fieldErrors.length > 0) {
@@ -1667,7 +1825,7 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1667
1825
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1668
1826
  const pType = registry.paragraph_types[comp.ref];
1669
1827
  if (!pType) continue;
1670
- const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1828
+ const items = paragraphItems(f, body[f.name]);
1671
1829
  mediaDeltas.push(await persistParagraphField(db, taxItemId, taxonomyType.db.table_name, f.name, pType, items, registry));
1672
1830
  }
1673
1831
  }
@@ -1690,6 +1848,8 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1690
1848
  404
1691
1849
  );
1692
1850
  }
1851
+ const shapeError = relationShapeError(c, taxonomyType.fields, body, registry);
1852
+ if (shapeError) return shapeError;
1693
1853
  if (body["published"] === true) {
1694
1854
  const publishDeny = await requirePermission("content:edit")(c, async () => {
1695
1855
  });
@@ -1731,11 +1891,12 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1731
1891
  ];
1732
1892
  if (db) {
1733
1893
  for (const f of taxPatchParagraphFields) {
1894
+ if (!(f.name in body)) continue;
1734
1895
  const comp = f.ui_component;
1735
1896
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1736
1897
  const pType = registry.paragraph_types[comp.ref];
1737
1898
  if (!pType) continue;
1738
- const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1899
+ const items = paragraphItems(f, body[f.name]);
1739
1900
  mediaDeltas.push(await persistParagraphField(db, id, taxonomyType.db.table_name, f.name, pType, items, registry));
1740
1901
  }
1741
1902
  }
@@ -1756,6 +1917,18 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1756
1917
  404
1757
1918
  );
1758
1919
  }
1920
+ const referrers = db ? await findRequiredReferrers(db, registry, typeName, id) : [];
1921
+ if (referrers.length > 0) {
1922
+ return c.json({ ok: false, error: { code: "ITEM_IN_USE", message: inUseMessage(referrers) } }, 409);
1923
+ }
1924
+ try {
1925
+ await repo.delete(id);
1926
+ } catch (err) {
1927
+ if (isForeignKeyViolation(err)) {
1928
+ return c.json({ ok: false, error: { code: "ITEM_IN_USE", message: IN_USE_RACE_MESSAGE } }, 409);
1929
+ }
1930
+ throw err;
1931
+ }
1759
1932
  const mediaDeltas = [
1760
1933
  topLevelMediaDelta(mediaFields, item, null)
1761
1934
  ];
@@ -1769,7 +1942,6 @@ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo,
1769
1942
  }
1770
1943
  }
1771
1944
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1772
- await repo.delete(id);
1773
1945
  return c.json({ ok: true });
1774
1946
  }
1775
1947
  );
@@ -2231,7 +2403,7 @@ function registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, ma
2231
2403
 
2232
2404
  // src/routes/admin/auth.ts
2233
2405
  import { getCookie as getCookie2 } from "hono/cookie";
2234
- import { sql as sql3 } from "drizzle-orm";
2406
+ import { sql as sql4 } from "drizzle-orm";
2235
2407
 
2236
2408
  // src/auth/password.ts
2237
2409
  import { hashPassword, verifyPassword } from "@bobbykim/manguito-cms-core";
@@ -2297,7 +2469,7 @@ function registerAuthRoutes(app, db) {
2297
2469
  );
2298
2470
  }
2299
2471
  const userResult = await db.execute(
2300
- sql3`SELECT id, email, password_hash, role_id, token_version
2472
+ sql4`SELECT id, email, password_hash, role_id, token_version
2301
2473
  FROM users WHERE email = ${email} LIMIT 1`
2302
2474
  );
2303
2475
  const user = userResult.rows[0];
@@ -2310,7 +2482,7 @@ function registerAuthRoutes(app, db) {
2310
2482
  return c.json(invalidCredentials, 401);
2311
2483
  }
2312
2484
  const roleResult = await db.execute(
2313
- sql3`SELECT r.name AS name, u.must_change_password
2485
+ sql4`SELECT r.name AS name, u.must_change_password
2314
2486
  FROM roles r
2315
2487
  JOIN users u ON u.role_id = r.id
2316
2488
  WHERE r.id = ${user.role_id} AND u.id = ${user.id}
@@ -2370,7 +2542,7 @@ function registerAuthRoutes(app, db) {
2370
2542
  );
2371
2543
  }
2372
2544
  const rowResult = await db.execute(
2373
- sql3`SELECT u.token_version, r.name AS role
2545
+ sql4`SELECT u.token_version, r.name AS role
2374
2546
  FROM users u
2375
2547
  JOIN roles r ON r.id = u.role_id
2376
2548
  WHERE u.id = ${payload.user_id}
@@ -2394,7 +2566,7 @@ function registerAuthRoutes(app, db) {
2394
2566
  app.post("/admin/api/auth/logout", authMiddleware, async (c) => {
2395
2567
  const user = c.get("user");
2396
2568
  await db.execute(
2397
- sql3`UPDATE users SET token_version = token_version + 1 WHERE id = ${user.id}`
2569
+ sql4`UPDATE users SET token_version = token_version + 1 WHERE id = ${user.id}`
2398
2570
  );
2399
2571
  clearAuthCookie(c, "auth_token");
2400
2572
  clearAuthCookie(c, "refresh_token", "/admin/api/auth");
@@ -2403,7 +2575,7 @@ function registerAuthRoutes(app, db) {
2403
2575
  }
2404
2576
 
2405
2577
  // src/routes/admin/users.ts
2406
- import { sql as sql4 } from "drizzle-orm";
2578
+ import { sql as sql5 } from "drizzle-orm";
2407
2579
  import { randomBytes } from "crypto";
2408
2580
  function getActingUser(c) {
2409
2581
  return c.get("user");
@@ -2438,7 +2610,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2438
2610
  );
2439
2611
  }
2440
2612
  const hashResult = await db.execute(
2441
- sql4`SELECT password_hash FROM users WHERE id = ${actingUser.id} LIMIT 1`
2613
+ sql5`SELECT password_hash FROM users WHERE id = ${actingUser.id} LIMIT 1`
2442
2614
  );
2443
2615
  const hashRow = hashResult.rows[0];
2444
2616
  if (!hashRow || !await verifyPassword(currentPassword, hashRow.password_hash)) {
@@ -2449,7 +2621,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2449
2621
  }
2450
2622
  const newHash = await hashPassword(newPassword);
2451
2623
  await db.execute(
2452
- sql4`UPDATE users
2624
+ sql5`UPDATE users
2453
2625
  SET password_hash = ${newHash},
2454
2626
  must_change_password = false,
2455
2627
  token_version = token_version + 1,
@@ -2460,7 +2632,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2460
2632
  });
2461
2633
  app.get("/admin/api/users", requirePermission("users:read"), async (c) => {
2462
2634
  const result = await db.execute(
2463
- sql4`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2635
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2464
2636
  u.created_at, u.updated_at
2465
2637
  FROM users u
2466
2638
  JOIN roles r ON r.id = u.role_id
@@ -2471,7 +2643,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2471
2643
  app.get("/admin/api/users/:id", requirePermission("users:read"), async (c) => {
2472
2644
  const id = c.req.param("id");
2473
2645
  const result = await db.execute(
2474
- sql4`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2646
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2475
2647
  u.created_at, u.updated_at
2476
2648
  FROM users u
2477
2649
  JOIN roles r ON r.id = u.role_id
@@ -2516,7 +2688,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2516
2688
  );
2517
2689
  }
2518
2690
  const roleResult = await db.execute(
2519
- sql4`SELECT id FROM roles WHERE name = ${roleName} LIMIT 1`
2691
+ sql5`SELECT id FROM roles WHERE name = ${roleName} LIMIT 1`
2520
2692
  );
2521
2693
  const roleRow = roleResult.rows[0];
2522
2694
  if (!roleRow) {
@@ -2531,7 +2703,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2531
2703
  const tempPassword = generateTempPassword();
2532
2704
  const passwordHash = await hashPassword(tempPassword);
2533
2705
  const insertResult = await db.execute(
2534
- sql4`INSERT INTO users
2706
+ sql5`INSERT INTO users
2535
2707
  (id, email, password_hash, role_id, must_change_password, token_version,
2536
2708
  created_at, updated_at)
2537
2709
  VALUES
@@ -2586,7 +2758,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2586
2758
  );
2587
2759
  }
2588
2760
  const existingResult = await db.execute(
2589
- sql4`SELECT id, email, role_id FROM users WHERE id = ${id} LIMIT 1`
2761
+ sql5`SELECT id, email, role_id FROM users WHERE id = ${id} LIMIT 1`
2590
2762
  );
2591
2763
  const existing = existingResult.rows[0];
2592
2764
  if (!existing) {
@@ -2600,7 +2772,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2600
2772
  let changeRole = false;
2601
2773
  if (typeof body.role === "string" && body.role) {
2602
2774
  const roleResult = await db.execute(
2603
- sql4`SELECT id FROM roles WHERE name = ${body.role} LIMIT 1`
2775
+ sql5`SELECT id FROM roles WHERE name = ${body.role} LIMIT 1`
2604
2776
  );
2605
2777
  const roleRow = roleResult.rows[0];
2606
2778
  if (!roleRow) {
@@ -2620,7 +2792,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2620
2792
  }
2621
2793
  if (changeRole) {
2622
2794
  await db.execute(
2623
- sql4`UPDATE users
2795
+ sql5`UPDATE users
2624
2796
  SET email = ${newEmail},
2625
2797
  role_id = ${newRoleId},
2626
2798
  token_version = token_version + 1,
@@ -2629,7 +2801,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2629
2801
  );
2630
2802
  } else {
2631
2803
  await db.execute(
2632
- sql4`UPDATE users
2804
+ sql5`UPDATE users
2633
2805
  SET email = ${newEmail},
2634
2806
  role_id = ${newRoleId},
2635
2807
  updated_at = NOW()
@@ -2637,7 +2809,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2637
2809
  );
2638
2810
  }
2639
2811
  const updatedResult = await db.execute(
2640
- sql4`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2812
+ sql5`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2641
2813
  u.created_at, u.updated_at
2642
2814
  FROM users u
2643
2815
  JOIN roles r ON r.id = u.role_id
@@ -2674,7 +2846,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2674
2846
  );
2675
2847
  }
2676
2848
  const result = await db.execute(
2677
- sql4`DELETE FROM users WHERE id = ${id} RETURNING id`
2849
+ sql5`DELETE FROM users WHERE id = ${id} RETURNING id`
2678
2850
  );
2679
2851
  if (result.rows.length === 0) {
2680
2852
  return c.json(
@@ -2705,7 +2877,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2705
2877
  );
2706
2878
  }
2707
2879
  const existingResult = await db.execute(
2708
- sql4`SELECT id FROM users WHERE id = ${id} LIMIT 1`
2880
+ sql5`SELECT id FROM users WHERE id = ${id} LIMIT 1`
2709
2881
  );
2710
2882
  const existing = existingResult.rows[0];
2711
2883
  if (!existing) {
@@ -2717,7 +2889,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2717
2889
  const tempPassword = generateTempPassword();
2718
2890
  const passwordHash = await hashPassword(tempPassword);
2719
2891
  await db.execute(
2720
- sql4`UPDATE users
2892
+ sql5`UPDATE users
2721
2893
  SET password_hash = ${passwordHash},
2722
2894
  must_change_password = true,
2723
2895
  token_version = token_version + 1,
@@ -2730,10 +2902,10 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2730
2902
  }
2731
2903
 
2732
2904
  // src/routes/admin/config.ts
2733
- import { sql as sql5 } from "drizzle-orm";
2905
+ import { sql as sql6 } from "drizzle-orm";
2734
2906
  var API_VERSION = (() => {
2735
2907
  try {
2736
- return "0.6.0";
2908
+ return "0.8.0";
2737
2909
  } catch {
2738
2910
  return "0.0.0";
2739
2911
  }
@@ -2752,7 +2924,7 @@ function registerConfigRoute(app, config, registry, db) {
2752
2924
  permissions: r.permissions
2753
2925
  }));
2754
2926
  const userResult = await db.execute(
2755
- sql5`SELECT email, must_change_password FROM users WHERE id = ${actingUser.id} LIMIT 1`
2927
+ sql6`SELECT email, must_change_password FROM users WHERE id = ${actingUser.id} LIMIT 1`
2756
2928
  );
2757
2929
  const userRow = userResult.rows[0];
2758
2930
  return c.json({
@@ -2778,7 +2950,7 @@ function registerConfigRoute(app, config, registry, db) {
2778
2950
  }
2779
2951
 
2780
2952
  // src/routes/admin/schema.ts
2781
- import { sql as sql6 } from "drizzle-orm";
2953
+ import { sql as sql7 } from "drizzle-orm";
2782
2954
  function liveFields(fields) {
2783
2955
  return fields.filter((f) => f.removed !== true);
2784
2956
  }
@@ -2818,7 +2990,7 @@ function registerSchemaRoute(app, registry, db) {
2818
2990
  const data = await Promise.all(
2819
2991
  Object.values(registry.content_types).map(async (ct) => {
2820
2992
  const result = await db.execute(
2821
- sql6`SELECT COUNT(*)::int AS count FROM ${sql6.raw(`"${ct.db.table_name}"`)}`
2993
+ sql7`SELECT COUNT(*)::int AS count FROM ${sql7.raw(`"${ct.db.table_name}"`)}`
2822
2994
  );
2823
2995
  const count = Number(result.rows[0].count);
2824
2996
  return { name: ct.name, label: ct.label, only_one: ct.only_one, count };
@@ -2830,7 +3002,7 @@ function registerSchemaRoute(app, registry, db) {
2830
3002
  const data = await Promise.all(
2831
3003
  Object.values(registry.taxonomy_types).map(async (tt) => {
2832
3004
  const result = await db.execute(
2833
- sql6`SELECT COUNT(*)::int AS count FROM ${sql6.raw(`"${tt.db.table_name}"`)}`
3005
+ sql7`SELECT COUNT(*)::int AS count FROM ${sql7.raw(`"${tt.db.table_name}"`)}`
2834
3006
  );
2835
3007
  const count = Number(result.rows[0].count);
2836
3008
  return { name: tt.name, label: tt.label, count };
@@ -2842,14 +3014,14 @@ function registerSchemaRoute(app, registry, db) {
2842
3014
 
2843
3015
  // src/repositories/content.ts
2844
3016
  import crypto from "crypto";
2845
- import { sql as sql7 } from "drizzle-orm";
3017
+ import { sql as sql8 } from "drizzle-orm";
2846
3018
  var SORTABLE_FIELDS2 = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
2847
3019
  function codeError(code, message) {
2848
3020
  const err = new Error(message);
2849
3021
  err.code = code;
2850
3022
  return err;
2851
3023
  }
2852
- function quoteIdent2(name) {
3024
+ function quoteIdent3(name) {
2853
3025
  if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
2854
3026
  throw new Error(`Unsafe SQL identifier: ${name}`);
2855
3027
  }
@@ -2864,43 +3036,43 @@ function quoteField(name) {
2864
3036
  function buildConditions(published_only, filters, search) {
2865
3037
  const conds = [];
2866
3038
  if (published_only) {
2867
- conds.push(sql7`published = true`);
3039
+ conds.push(sql8`published = true`);
2868
3040
  }
2869
3041
  if (search && search.term.trim() !== "" && search.columns.length > 0) {
2870
3042
  const pattern = `%${search.term.trim()}%`;
2871
- const orConds = search.columns.map((col) => sql7`${sql7.raw(quoteField(col))} ILIKE ${pattern}`);
2872
- conds.push(sql7`(${sql7.join(orConds, sql7` OR `)})`);
3043
+ const orConds = search.columns.map((col) => sql8`${sql8.raw(quoteField(col))} ILIKE ${pattern}`);
3044
+ conds.push(sql8`(${sql8.join(orConds, sql8` OR `)})`);
2873
3045
  }
2874
3046
  if (filters) {
2875
3047
  for (const [field, value] of Object.entries(filters)) {
2876
- const col = sql7.raw(quoteField(field));
3048
+ const col = sql8.raw(quoteField(field));
2877
3049
  if (Array.isArray(value)) {
2878
3050
  if (value.length === 0) continue;
2879
- const inList = sql7.join(
2880
- value.map((v) => sql7`${v}`),
2881
- sql7`, `
3051
+ const inList = sql8.join(
3052
+ value.map((v) => sql8`${v}`),
3053
+ sql8`, `
2882
3054
  );
2883
- conds.push(sql7`${col} IN (${inList})`);
3055
+ conds.push(sql8`${col} IN (${inList})`);
2884
3056
  } else if (typeof value === "object" && value !== null) {
2885
3057
  const op = value;
2886
- if (op.gt !== void 0) conds.push(sql7`${col} > ${op.gt}`);
2887
- if (op.gte !== void 0) conds.push(sql7`${col} >= ${op.gte}`);
2888
- if (op.lt !== void 0) conds.push(sql7`${col} < ${op.lt}`);
2889
- if (op.lte !== void 0) conds.push(sql7`${col} <= ${op.lte}`);
3058
+ if (op.gt !== void 0) conds.push(sql8`${col} > ${op.gt}`);
3059
+ if (op.gte !== void 0) conds.push(sql8`${col} >= ${op.gte}`);
3060
+ if (op.lt !== void 0) conds.push(sql8`${col} < ${op.lt}`);
3061
+ if (op.lte !== void 0) conds.push(sql8`${col} <= ${op.lte}`);
2890
3062
  } else {
2891
- conds.push(sql7`${col} = ${value}`);
3063
+ conds.push(sql8`${col} = ${value}`);
2892
3064
  }
2893
3065
  }
2894
3066
  }
2895
3067
  return conds;
2896
3068
  }
2897
3069
  function whereFragment(conds) {
2898
- return conds.length > 0 ? sql7` WHERE ${sql7.join(conds, sql7` AND `)}` : sql7``;
3070
+ return conds.length > 0 ? sql8` WHERE ${sql8.join(conds, sql8` AND `)}` : sql8``;
2899
3071
  }
2900
3072
  function createDrizzleContentRepository(db, tableName, options = {}) {
2901
3073
  const { relations = {}, publishedRelations = false, sortableColumns } = options;
2902
3074
  function tableRaw() {
2903
- return sql7.raw(quoteIdent2(tableName));
3075
+ return sql8.raw(quoteIdent3(tableName));
2904
3076
  }
2905
3077
  async function resolveRows(rows, include, cache) {
2906
3078
  if (rows.length === 0) return rows;
@@ -2943,14 +3115,14 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
2943
3115
  const offset = (page - 1) * per_page;
2944
3116
  const conds = buildConditions(published_only, filters, search);
2945
3117
  const where = whereFragment(conds);
2946
- const sortCol = sql7.raw(quoteIdent2(sort_by));
2947
- const sortDir = sql7.raw(sort_order === "desc" ? "DESC" : "ASC");
3118
+ const sortCol = sql8.raw(quoteIdent3(sort_by));
3119
+ const sortDir = sql8.raw(sort_order === "desc" ? "DESC" : "ASC");
2948
3120
  const countResult = await db.execute(
2949
- sql7`SELECT COUNT(*) AS total FROM ${tableRaw()}${where}`
3121
+ sql8`SELECT COUNT(*) AS total FROM ${tableRaw()}${where}`
2950
3122
  );
2951
3123
  const total = Number(countResult.rows[0]?.["total"] ?? 0);
2952
3124
  const dataResult = await db.execute(
2953
- sql7`SELECT * FROM ${tableRaw()}${where} ORDER BY ${sortCol} ${sortDir} LIMIT ${per_page} OFFSET ${offset}`
3125
+ sql8`SELECT * FROM ${tableRaw()}${where} ORDER BY ${sortCol} ${sortDir} LIMIT ${per_page} OFFSET ${offset}`
2954
3126
  );
2955
3127
  const rows = dataResult.rows;
2956
3128
  const cache = /* @__PURE__ */ new Map();
@@ -2971,7 +3143,7 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
2971
3143
  },
2972
3144
  async findOne(id, include = []) {
2973
3145
  const result = await db.execute(
2974
- sql7`SELECT * FROM ${tableRaw()} WHERE id = ${id} LIMIT 1`
3146
+ sql8`SELECT * FROM ${tableRaw()} WHERE id = ${id} LIMIT 1`
2975
3147
  );
2976
3148
  if (result.rows.length === 0) return null;
2977
3149
  const rows = result.rows;
@@ -2981,7 +3153,7 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
2981
3153
  },
2982
3154
  async findBySlug(slug, include = []) {
2983
3155
  const result = await db.execute(
2984
- sql7`SELECT * FROM ${tableRaw()} WHERE slug = ${slug} LIMIT 1`
3156
+ sql8`SELECT * FROM ${tableRaw()} WHERE slug = ${slug} LIMIT 1`
2985
3157
  );
2986
3158
  if (result.rows.length === 0) return null;
2987
3159
  const rows = result.rows;
@@ -2997,10 +3169,10 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
2997
3169
  updated_at: /* @__PURE__ */ new Date()
2998
3170
  };
2999
3171
  const entries = Object.entries(record).filter(([, v]) => v !== void 0);
3000
- const colsSql = sql7.join(entries.map(([k]) => sql7.raw(quoteIdent2(k))), sql7`, `);
3001
- const valsSql = sql7.join(entries.map(([, v]) => sql7`${v}`), sql7`, `);
3172
+ const colsSql = sql8.join(entries.map(([k]) => sql8.raw(quoteIdent3(k))), sql8`, `);
3173
+ const valsSql = sql8.join(entries.map(([, v]) => sql8`${v}`), sql8`, `);
3002
3174
  const result = await db.execute(
3003
- sql7`INSERT INTO ${tableRaw()} (${colsSql}) VALUES (${valsSql}) RETURNING *`
3175
+ sql8`INSERT INTO ${tableRaw()} (${colsSql}) VALUES (${valsSql}) RETURNING *`
3004
3176
  );
3005
3177
  return result.rows[0];
3006
3178
  },
@@ -3013,25 +3185,25 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
3013
3185
  if (entries.length === 0) {
3014
3186
  return this.findOne(id);
3015
3187
  }
3016
- const setClauses = sql7.join(
3017
- entries.map(([k, v]) => sql7`${sql7.raw(quoteIdent2(k))} = ${v}`),
3018
- sql7`, `
3188
+ const setClauses = sql8.join(
3189
+ entries.map(([k, v]) => sql8`${sql8.raw(quoteIdent3(k))} = ${v}`),
3190
+ sql8`, `
3019
3191
  );
3020
3192
  const result = await db.execute(
3021
- sql7`UPDATE ${tableRaw()} SET ${setClauses} WHERE id = ${id} RETURNING *`
3193
+ sql8`UPDATE ${tableRaw()} SET ${setClauses} WHERE id = ${id} RETURNING *`
3022
3194
  );
3023
3195
  if (result.rows.length === 0) return null;
3024
3196
  return result.rows[0];
3025
3197
  },
3026
3198
  async delete(id) {
3027
- await db.execute(sql7`DELETE FROM ${tableRaw()} WHERE id = ${id}`);
3199
+ await db.execute(sql8`DELETE FROM ${tableRaw()} WHERE id = ${id}`);
3028
3200
  },
3029
3201
  async findAll(opts = {}) {
3030
3202
  const { include = [], published_only } = opts;
3031
3203
  const conds = buildConditions(published_only, void 0, void 0);
3032
3204
  const where = whereFragment(conds);
3033
3205
  const result = await db.execute(
3034
- sql7`SELECT * FROM ${tableRaw()}${where} ORDER BY created_at ASC`
3206
+ sql8`SELECT * FROM ${tableRaw()}${where} ORDER BY created_at ASC`
3035
3207
  );
3036
3208
  const rows = result.rows;
3037
3209
  const cache = /* @__PURE__ */ new Map();
@@ -3043,23 +3215,23 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
3043
3215
 
3044
3216
  // src/repositories/media.ts
3045
3217
  import crypto2 from "crypto";
3046
- import { sql as sql8 } from "drizzle-orm";
3218
+ import { sql as sql9 } from "drizzle-orm";
3047
3219
  function createMediaRepository(db) {
3048
- const TABLE = sql8.raw('"media"');
3220
+ const TABLE = sql9.raw('"media"');
3049
3221
  return {
3050
3222
  async findMany(opts = {}) {
3051
3223
  const { page = 1, per_page = 10, type, orphaned } = opts;
3052
3224
  const offset = (page - 1) * per_page;
3053
3225
  const conds = [];
3054
- if (type) conds.push(sql8`type = ${type}`);
3055
- if (orphaned) conds.push(sql8`reference_count = 0`);
3056
- const where = conds.length > 0 ? sql8` WHERE ${sql8.join(conds, sql8` AND `)}` : sql8``;
3057
- const countResult = await db.execute(sql8`SELECT COUNT(*) AS total FROM ${TABLE}${where}`);
3226
+ if (type) conds.push(sql9`type = ${type}`);
3227
+ if (orphaned) conds.push(sql9`reference_count = 0`);
3228
+ const where = conds.length > 0 ? sql9` WHERE ${sql9.join(conds, sql9` AND `)}` : sql9``;
3229
+ const countResult = await db.execute(sql9`SELECT COUNT(*) AS total FROM ${TABLE}${where}`);
3058
3230
  const total = Number(
3059
3231
  countResult.rows[0]?.["total"] ?? 0
3060
3232
  );
3061
3233
  const dataResult = await db.execute(
3062
- sql8`SELECT * FROM ${TABLE}${where} ORDER BY created_at DESC LIMIT ${per_page} OFFSET ${offset}`
3234
+ sql9`SELECT * FROM ${TABLE}${where} ORDER BY created_at DESC LIMIT ${per_page} OFFSET ${offset}`
3063
3235
  );
3064
3236
  const total_pages = Math.ceil(total / per_page);
3065
3237
  return {
@@ -3077,7 +3249,7 @@ function createMediaRepository(db) {
3077
3249
  },
3078
3250
  async findOne(id) {
3079
3251
  const result = await db.execute(
3080
- sql8`SELECT * FROM ${TABLE} WHERE id = ${id} LIMIT 1`
3252
+ sql9`SELECT * FROM ${TABLE} WHERE id = ${id} LIMIT 1`
3081
3253
  );
3082
3254
  if (result.rows.length === 0) return null;
3083
3255
  return result.rows[0];
@@ -3091,10 +3263,10 @@ function createMediaRepository(db) {
3091
3263
  updated_at: /* @__PURE__ */ new Date()
3092
3264
  };
3093
3265
  const entries = Object.entries(record).filter(([, v]) => v !== void 0);
3094
- const colsSql = sql8.join(entries.map(([k]) => sql8.raw(`"${k}"`)), sql8`, `);
3095
- const valsSql = sql8.join(entries.map(([, v]) => sql8`${v}`), sql8`, `);
3266
+ const colsSql = sql9.join(entries.map(([k]) => sql9.raw(`"${k}"`)), sql9`, `);
3267
+ const valsSql = sql9.join(entries.map(([, v]) => sql9`${v}`), sql9`, `);
3096
3268
  const result = await db.execute(
3097
- sql8`INSERT INTO ${TABLE} (${colsSql}) VALUES (${valsSql}) RETURNING *`
3269
+ sql9`INSERT INTO ${TABLE} (${colsSql}) VALUES (${valsSql}) RETURNING *`
3098
3270
  );
3099
3271
  return result.rows[0];
3100
3272
  },
@@ -3104,31 +3276,31 @@ function createMediaRepository(db) {
3104
3276
  ([k, v]) => k !== "id" && v !== void 0
3105
3277
  );
3106
3278
  if (entries.length === 0) return this.findOne(id);
3107
- const setClauses = sql8.join(
3108
- entries.map(([k, v]) => sql8`${sql8.raw(`"${k}"`)} = ${v}`),
3109
- sql8`, `
3279
+ const setClauses = sql9.join(
3280
+ entries.map(([k, v]) => sql9`${sql9.raw(`"${k}"`)} = ${v}`),
3281
+ sql9`, `
3110
3282
  );
3111
3283
  const result = await db.execute(
3112
- sql8`UPDATE ${TABLE} SET ${setClauses} WHERE id = ${id} RETURNING *`
3284
+ sql9`UPDATE ${TABLE} SET ${setClauses} WHERE id = ${id} RETURNING *`
3113
3285
  );
3114
3286
  if (result.rows.length === 0) return null;
3115
3287
  return result.rows[0];
3116
3288
  },
3117
3289
  async delete(id) {
3118
- await db.execute(sql8`DELETE FROM ${TABLE} WHERE id = ${id}`);
3290
+ await db.execute(sql9`DELETE FROM ${TABLE} WHERE id = ${id}`);
3119
3291
  },
3120
3292
  async incrementReferenceCount(ids) {
3121
3293
  if (ids.length === 0) return;
3122
- const inList = sql8.join(ids.map((id) => sql8`${id}`), sql8`, `);
3294
+ const inList = sql9.join(ids.map((id) => sql9`${id}`), sql9`, `);
3123
3295
  await db.execute(
3124
- sql8`UPDATE ${TABLE} SET reference_count = reference_count + 1, updated_at = NOW() WHERE id IN (${inList})`
3296
+ sql9`UPDATE ${TABLE} SET reference_count = reference_count + 1, updated_at = NOW() WHERE id IN (${inList})`
3125
3297
  );
3126
3298
  },
3127
3299
  async decrementReferenceCount(ids) {
3128
3300
  if (ids.length === 0) return;
3129
- const inList = sql8.join(ids.map((id) => sql8`${id}`), sql8`, `);
3301
+ const inList = sql9.join(ids.map((id) => sql9`${id}`), sql9`, `);
3130
3302
  await db.execute(
3131
- sql8`UPDATE ${TABLE} SET reference_count = GREATEST(reference_count - 1, 0), updated_at = NOW() WHERE id IN (${inList})`
3303
+ sql9`UPDATE ${TABLE} SET reference_count = GREATEST(reference_count - 1, 0), updated_at = NOW() WHERE id IN (${inList})`
3132
3304
  );
3133
3305
  }
3134
3306
  };
@@ -3261,7 +3433,7 @@ function createCmsApp(options) {
3261
3433
  const requirePermission = createPermissionMiddleware(rolesRegistry);
3262
3434
  const getUserRole = async (userId) => {
3263
3435
  const result = await db.execute(
3264
- sql9`SELECT r.name AS role
3436
+ sql10`SELECT r.name AS role
3265
3437
  FROM users u
3266
3438
  JOIN roles r ON r.id = u.role_id
3267
3439
  WHERE u.id = ${userId}
@@ -3389,7 +3561,7 @@ function createCmsApp(options) {
3389
3561
  app.use(path, attachDeprecationHeaders);
3390
3562
  }
3391
3563
  }
3392
- registerPublicContentRoutes(app, registry, surface.repos, surface.projectors, surface.paths, listRateLimit, programmaticResolver);
3564
+ registerPublicContentRoutes(app, registry, surface.repos, surface.projectors, surface.paths, listRateLimit, programmaticResolver, projectors);
3393
3565
  }
3394
3566
  if (options.versions !== void 0) {
3395
3567
  app.all(`${prefix}/:version/*`, async (c, next) => {
@@ -3431,7 +3603,7 @@ function createCmsApp(options) {
3431
3603
  handler: null,
3432
3604
  error: null
3433
3605
  }));
3434
- const ready = import("./handler-XZVDLKZ2.mjs").then(({ createGraphQLHandler }) => {
3606
+ const ready = import("./handler-VEOWJRAL.mjs").then(({ createGraphQLHandler }) => {
3435
3607
  gqlPasses.forEach((pass, i) => {
3436
3608
  const mount = mounts[i];
3437
3609
  try {
@@ -3455,7 +3627,8 @@ function createCmsApp(options) {
3455
3627
  endpoint: mount.path,
3456
3628
  currentVersion: model.current,
3457
3629
  ...projection !== void 0 && { projection },
3458
- ...currentProjection !== void 0 && { currentProjection }
3630
+ ...currentProjection !== void 0 && { currentProjection },
3631
+ currentFieldKeyMaps: fieldKeyMaps
3459
3632
  }
3460
3633
  );
3461
3634
  } catch (err) {