@bobbykim/manguito-cms-api 0.3.3 → 0.4.1

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
@@ -200,22 +200,34 @@ function groupBy(items, key) {
200
200
  }
201
201
  return result;
202
202
  }
203
+ function needsFkResolution(row, fkColumn, fieldName) {
204
+ if (fkColumn !== fieldName && !(fkColumn in row)) return false;
205
+ const current = row[fieldName];
206
+ return !(typeof current === "object" && current !== null);
207
+ }
203
208
  async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOnly = false) {
204
209
  if (rows.length === 0) return;
210
+ const batch = [...new Set(rows)];
205
211
  if (rel.type === "paragraph") {
206
- const parentIds = rows.map((r) => r["id"]);
212
+ const parentIds = batch.map((r) => r["id"]);
207
213
  const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
208
214
  const result = await db.execute(
209
215
  import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) AND parent_field = ${fieldName} ORDER BY "order" ASC`
210
216
  );
211
217
  const byParent = groupBy(result.rows, "parent_id");
212
- for (const row of rows) {
218
+ for (const row of batch) {
213
219
  row[fieldName] = byParent[row["id"]] ?? [];
214
220
  }
215
221
  } else if (rel.type === "reference") {
216
- const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
222
+ const dropFk = rel.fk_column !== fieldName;
223
+ const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column, fieldName));
224
+ if (pending.length === 0) return;
225
+ const fkValues = pending.map((r) => r[rel.fk_column]).filter(Boolean);
217
226
  if (fkValues.length === 0) {
218
- for (const row of rows) row[fieldName] = null;
227
+ for (const row of pending) {
228
+ row[fieldName] = null;
229
+ if (dropFk) delete row[rel.fk_column];
230
+ }
219
231
  return;
220
232
  }
221
233
  const unique = [...new Set(fkValues)];
@@ -230,12 +242,13 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
230
242
  cache.set(`${rel.table}:${item["id"]}`, item);
231
243
  }
232
244
  }
233
- for (const row of rows) {
245
+ for (const row of pending) {
234
246
  const fkVal = row[rel.fk_column];
235
247
  row[fieldName] = fkVal ? cache.get(`${rel.table}:${fkVal}`) ?? null : null;
248
+ if (dropFk) delete row[rel.fk_column];
236
249
  }
237
250
  } else if (rel.type === "junction") {
238
- const parentIds = rows.map((r) => r["id"]);
251
+ const parentIds = batch.map((r) => r["id"]);
239
252
  const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
240
253
  const orderBy = rel.order_column ? import_drizzle_orm2.sql` ORDER BY "order" ASC` : import_drizzle_orm2.sql``;
241
254
  const junctionResult = await db.execute(
@@ -255,14 +268,20 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
255
268
  }
256
269
  }
257
270
  const byLeft = groupBy(jRows, rel.left_column);
258
- for (const row of rows) {
271
+ for (const row of batch) {
259
272
  const jEntries = byLeft[row["id"]] ?? [];
260
273
  row[fieldName] = jEntries.map((jr) => cache.get(`${rel.table}:${jr[rel.right_column]}`)).filter(Boolean);
261
274
  }
262
275
  } else if (rel.type === "media") {
263
- const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
276
+ const dropFk = rel.fk_column !== fieldName;
277
+ const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column, fieldName));
278
+ if (pending.length === 0) return;
279
+ const fkValues = pending.map((r) => r[rel.fk_column]).filter(Boolean);
264
280
  if (fkValues.length === 0) {
265
- for (const row of rows) row[fieldName] = null;
281
+ for (const row of pending) {
282
+ row[fieldName] = null;
283
+ if (dropFk) delete row[rel.fk_column];
284
+ }
266
285
  return;
267
286
  }
268
287
  const unique = [...new Set(fkValues)];
@@ -274,9 +293,10 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
274
293
  cache.set(`media:${item["id"]}`, item);
275
294
  }
276
295
  }
277
- for (const row of rows) {
296
+ for (const row of pending) {
278
297
  const fkVal = row[rel.fk_column];
279
298
  row[fieldName] = fkVal ? cache.get(`media:${fkVal}`) ?? null : null;
299
+ if (dropFk) delete row[rel.fk_column];
280
300
  }
281
301
  }
282
302
  }
@@ -313,6 +333,53 @@ var init_relations = __esm({
313
333
  }
314
334
  });
315
335
 
336
+ // src/field-keys.ts
337
+ function isColumnBacked(field) {
338
+ const col = field.db_column;
339
+ if (col === null) return false;
340
+ if (col.column_name === "") return false;
341
+ if (col.junction) return false;
342
+ return true;
343
+ }
344
+ function createFieldKeyMap(fields) {
345
+ const labelToColumn = /* @__PURE__ */ new Map();
346
+ const columnToLabel = /* @__PURE__ */ new Map();
347
+ for (const f of fields) {
348
+ if (!isColumnBacked(f)) continue;
349
+ labelToColumn.set(f.name, f.db_column.column_name);
350
+ columnToLabel.set(f.db_column.column_name, f.name);
351
+ }
352
+ for (const f of fields) {
353
+ const columnOwner = columnToLabel.get(f.name);
354
+ if (columnOwner !== void 0 && columnOwner !== f.name) {
355
+ throw new Error(
356
+ `Fatal: field key map failed to build \u2014 field label "${f.name}" collides with the storage column of field "${columnOwner}". A field label may not reuse another field's column name. Rename the field, then run \`manguito validate\` to check your schema.`
357
+ );
358
+ }
359
+ }
360
+ const diverges = [...labelToColumn].some(([label, column]) => label !== column);
361
+ function remap(input, lookup) {
362
+ const out = {};
363
+ for (const key of Object.keys(input)) {
364
+ out[lookup.get(key) ?? key] = input[key];
365
+ }
366
+ return out;
367
+ }
368
+ return {
369
+ toStorage: (input) => remap(input, labelToColumn),
370
+ toLabels: (row) => remap(row, columnToLabel),
371
+ columnFor: (label) => labelToColumn.get(label),
372
+ labelFor: (column) => columnToLabel.get(column),
373
+ labels: [...labelToColumn.keys()],
374
+ diverges
375
+ };
376
+ }
377
+ var init_field_keys = __esm({
378
+ "src/field-keys.ts"() {
379
+ "use strict";
380
+ }
381
+ });
382
+
316
383
  // src/graphql/scalars.ts
317
384
  var import_graphql, DateTimeScalar, JSONScalar;
318
385
  var init_scalars = __esm({
@@ -433,7 +500,7 @@ var init_naming = __esm({
433
500
  function buildSortFieldEnum(typeName) {
434
501
  return new import_graphql3.GraphQLEnumType({
435
502
  name: `${typeName}SortField`,
436
- values: Object.fromEntries(SORTABLE.map((s) => [s.gql, { value: s.column }]))
503
+ values: Object.fromEntries(SORTABLE.map((s) => [s.gql, { value: s.value }]))
437
504
  });
438
505
  }
439
506
  function comparableFilterInput(name, scalar) {
@@ -484,12 +551,13 @@ function buildFilterInputType(type) {
484
551
  if (Object.keys(fields).length === 0) return null;
485
552
  return new import_graphql3.GraphQLInputObjectType({ name: `${graphqlTypeName(type.name)}Filter`, fields });
486
553
  }
487
- function translateFilters(input, nameMap) {
554
+ function translateFilters(input, nameMap, columnFor) {
488
555
  const out = {};
489
556
  if (!input) return out;
490
557
  for (const [gqlField, raw] of Object.entries(input)) {
491
558
  if (raw === null || typeof raw !== "object") continue;
492
- const column = nameMap.toSchema(gqlField);
559
+ const label = nameMap.toSchema(gqlField);
560
+ const column = columnFor?.(label) ?? label;
493
561
  const spec = raw;
494
562
  if (spec["eq"] !== void 0) {
495
563
  out[column] = spec["eq"];
@@ -518,9 +586,9 @@ var init_filters = __esm({
518
586
  values: { ASC: { value: "asc" }, DESC: { value: "desc" } }
519
587
  });
520
588
  SORTABLE = [
521
- { gql: "title", column: "title" },
522
- { gql: "createdAt", column: "created_at" },
523
- { gql: "updatedAt", column: "updated_at" }
589
+ { gql: "title", value: "title" },
590
+ { gql: "createdAt", value: "created_at" },
591
+ { gql: "updatedAt", value: "updated_at" }
524
592
  ];
525
593
  StringFilter = equalityFilterInput("StringFilter", import_graphql3.GraphQLString);
526
594
  BooleanFilter = new import_graphql3.GraphQLInputObjectType({
@@ -541,29 +609,34 @@ function codeError2(code, message) {
541
609
  function isPublished2(item) {
542
610
  return !!item && item["published"] === true;
543
611
  }
544
- function scalarFieldResolver(schemaName) {
545
- return (parent) => parent[schemaName];
612
+ function resolveFieldValue(field, row) {
613
+ const key = isColumnBacked(field) ? field.db_column.column_name : field.name;
614
+ return row[key] ?? null;
615
+ }
616
+ function scalarFieldResolver(field) {
617
+ return (parent) => resolveFieldValue(field, parent);
546
618
  }
547
619
  function relationFieldResolver(typeName, schemaFieldName) {
548
620
  return (parent, _args, ctx) => ctx.loaders.load(typeName, schemaFieldName, parent);
549
621
  }
550
- function programmaticFieldResolver(typeName, schemaFieldName, mediaFieldNames = []) {
622
+ function programmaticFieldResolver(typeName, schemaFieldName, mediaFieldNames = [], fieldKeys) {
551
623
  return async (parent, _args, ctx) => {
552
624
  let p = ctx.programmaticMemo.get(parent);
553
625
  if (!p) {
554
- p = resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames);
626
+ p = resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames, fieldKeys);
555
627
  ctx.programmaticMemo.set(parent, p);
556
628
  }
557
629
  return (await p)[schemaFieldName];
558
630
  };
559
631
  }
560
- async function resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames) {
561
- if (mediaFieldNames.length === 0) return ctx.resolver.resolveItem(typeName, parent);
632
+ async function resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames, fieldKeys) {
633
+ const toLabels = (row) => fieldKeys ? fieldKeys.toLabels(row) : row;
634
+ if (mediaFieldNames.length === 0) return ctx.resolver.resolveItem(typeName, toLabels(parent));
562
635
  const enriched = { ...parent };
563
636
  await Promise.all(mediaFieldNames.map((name) => ctx.loaders.load(typeName, name, enriched)));
564
- return ctx.resolver.resolveItem(typeName, enriched);
637
+ return ctx.resolver.resolveItem(typeName, toLabels(enriched));
565
638
  }
566
- function collectionResolver(typeName, nameMap) {
639
+ function collectionResolver(typeName, nameMap, fieldKeys) {
567
640
  return async (_root, args, ctx) => {
568
641
  const page = args.page ?? 1;
569
642
  const perPage = args.perPage ?? 10;
@@ -574,13 +647,15 @@ function collectionResolver(typeName, nameMap) {
574
647
  throw codeError2("INVALID_PAGINATION", "perPage must be between 1 and 100");
575
648
  }
576
649
  const repo = ctx.repos[typeName];
650
+ const sortBy = args.sortBy ?? "created_at";
651
+ const sortColumn = fieldKeys?.columnFor(sortBy) ?? sortBy;
577
652
  const result = await repo.findMany({
578
653
  published_only: true,
579
654
  page,
580
655
  per_page: perPage,
581
- sort_by: args.sortBy ?? "created_at",
656
+ sort_by: sortColumn,
582
657
  sort_order: args.sortOrder ?? "asc",
583
- filters: translateFilters(args.filter, nameMap)
658
+ filters: translateFilters(args.filter, nameMap, fieldKeys?.columnFor)
584
659
  });
585
660
  return { data: result.data, meta: result.meta };
586
661
  };
@@ -612,11 +687,12 @@ var init_resolvers = __esm({
612
687
  "use strict";
613
688
  import_graphql4 = require("graphql");
614
689
  init_filters();
690
+ init_field_keys();
615
691
  }
616
692
  });
617
693
 
618
694
  // src/graphql/schema.ts
619
- function buildGraphQLSchema(registry) {
695
+ function buildGraphQLSchema(registry, fieldKeyMaps = {}) {
620
696
  const objectTypes = /* @__PURE__ */ new Map();
621
697
  const enumTypes = /* @__PURE__ */ new Map();
622
698
  for (const [name, enumType] of Object.entries(registry.enum_types)) {
@@ -692,11 +768,16 @@ function buildGraphQLSchema(registry) {
692
768
  const outType = outputTypeForField(field);
693
769
  let resolve;
694
770
  if (field.field_type === "programmatic") {
695
- resolve = programmaticFieldResolver(machineName, field.name, mediaFieldNames);
771
+ resolve = programmaticFieldResolver(
772
+ machineName,
773
+ field.name,
774
+ mediaFieldNames,
775
+ fieldKeyMaps[machineName]
776
+ );
696
777
  } else if (field.field_type === "reference" || field.field_type === "paragraph" || field.field_type === "image" || field.field_type === "video" || field.field_type === "file") {
697
778
  resolve = relationFieldResolver(machineName, field.name);
698
779
  } else {
699
- resolve = scalarFieldResolver(field.name);
780
+ resolve = scalarFieldResolver(field);
700
781
  }
701
782
  fields[gqlName] = { type: outType, resolve };
702
783
  }
@@ -752,7 +833,7 @@ function buildGraphQLSchema(registry) {
752
833
  sortOrder: { type: SortOrderEnum },
753
834
  ...filterType ? { filter: { type: filterType } } : {}
754
835
  },
755
- resolve: collectionResolver(name, nameMap)
836
+ resolve: collectionResolver(name, nameMap, fieldKeyMaps[name])
756
837
  };
757
838
  queryFields[singleQueryName(name)] = {
758
839
  type: objType,
@@ -772,7 +853,7 @@ function buildGraphQLSchema(registry) {
772
853
  queryFields[collectionQueryName(name)] = {
773
854
  type: new import_graphql5.GraphQLNonNull(listType),
774
855
  args: { page: { type: import_graphql5.GraphQLInt }, perPage: { type: import_graphql5.GraphQLInt } },
775
- resolve: collectionResolver(name, buildFieldNameMap(tt.fields.map((f) => f.name)))
856
+ resolve: collectionResolver(name, buildFieldNameMap(tt.fields.map((f) => f.name)), fieldKeyMaps[name])
776
857
  };
777
858
  queryFields[singleQueryName(name)] = {
778
859
  type: objType,
@@ -914,8 +995,8 @@ function isClientSafeGraphQLError(error) {
914
995
  }
915
996
  return false;
916
997
  }
917
- function createGraphQLHandler(registry, repos, resolver, db, options) {
918
- const schema = buildGraphQLSchema(registry);
998
+ function createGraphQLHandler(registry, repos, fieldKeyMaps, resolver, db, options) {
999
+ const schema = buildGraphQLSchema(registry, fieldKeyMaps);
919
1000
  const { plugins } = buildArmorPlugin({
920
1001
  maxDepth: options.maxDepth,
921
1002
  maxComplexity: options.maxComplexity
@@ -967,6 +1048,25 @@ __export(index_exports, {
967
1048
  });
968
1049
  module.exports = __toCommonJS(index_exports);
969
1050
 
1051
+ // src/paths.ts
1052
+ function normalizePrefix(prefix) {
1053
+ if (prefix === void 0) return "/api";
1054
+ const withSlash = prefix.startsWith("/") ? prefix : `/${prefix}`;
1055
+ const trimmed = withSlash.replace(/\/+$/, "");
1056
+ return trimmed === "" ? "/api" : trimmed;
1057
+ }
1058
+ function createPublicPaths(prefix) {
1059
+ return {
1060
+ collection: (basePath) => `${prefix}/${basePath}`,
1061
+ item: (basePath) => `${prefix}/${basePath}/:slug`,
1062
+ taxonomyCollection: (typeName) => `${prefix}/taxonomy/${typeName}`,
1063
+ taxonomyItem: (typeName) => `${prefix}/taxonomy/${typeName}/:id`,
1064
+ mediaCollection: () => `${prefix}/media`,
1065
+ mediaItem: () => `${prefix}/media/:id`,
1066
+ openapi: () => `${prefix}/openapi.json`
1067
+ };
1068
+ }
1069
+
970
1070
  // src/app.ts
971
1071
  var import_hono = require("hono");
972
1072
  var import_drizzle_orm10 = require("drizzle-orm");
@@ -1545,6 +1645,54 @@ function createProgrammaticResolver(resolvers, options) {
1545
1645
  return { resolveItem, resolveList, hasSchema };
1546
1646
  }
1547
1647
 
1648
+ // src/projector.ts
1649
+ function isPlainRow(v) {
1650
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
1651
+ }
1652
+ var MEDIA_FIELD_TYPES = /* @__PURE__ */ new Set(["image", "video", "file"]);
1653
+ function nestedTargets(fields) {
1654
+ const out = [];
1655
+ for (const f of fields) {
1656
+ if (MEDIA_FIELD_TYPES.has(f.field_type)) continue;
1657
+ if (f.field_type !== "paragraph" && f.field_type !== "reference") continue;
1658
+ const ref = f.ui_component.ref;
1659
+ if (!ref) continue;
1660
+ out.push({ label: f.name, target: ref });
1661
+ }
1662
+ return out;
1663
+ }
1664
+ function buildProjectors(registry, fieldKeyMaps) {
1665
+ const projectors = {};
1666
+ const sources = [
1667
+ registry.content_types,
1668
+ registry.taxonomy_types,
1669
+ registry.paragraph_types
1670
+ ];
1671
+ for (const source of sources) {
1672
+ for (const [typeName, type] of Object.entries(source)) {
1673
+ const map = fieldKeyMaps[typeName];
1674
+ if (!map) continue;
1675
+ projectors[typeName] = { map, nested: nestedTargets(type.fields) };
1676
+ }
1677
+ }
1678
+ return projectors;
1679
+ }
1680
+ function projectRow(row, typeName, projectors) {
1681
+ const p = projectors[typeName];
1682
+ if (!p) return row;
1683
+ const out = p.map.toLabels(row);
1684
+ for (const { label, target } of p.nested) {
1685
+ const v = out[label];
1686
+ if (v === null || v === void 0) continue;
1687
+ if (Array.isArray(v)) {
1688
+ out[label] = v.map((item) => isPlainRow(item) ? projectRow(item, target, projectors) : item);
1689
+ } else if (isPlainRow(v)) {
1690
+ out[label] = projectRow(v, target, projectors);
1691
+ }
1692
+ }
1693
+ return out;
1694
+ }
1695
+
1548
1696
  // src/routes/query-params.ts
1549
1697
  var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
1550
1698
  var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
@@ -1565,28 +1713,31 @@ function parseInclude(includeParam) {
1565
1713
  if (!includeParam) return [];
1566
1714
  return includeParam.split(",").map((s) => s.trim()).filter(Boolean);
1567
1715
  }
1568
- function parseFilters(url, validFields) {
1716
+ function parseFilters(url, validFields, columnFor) {
1569
1717
  const { searchParams } = new URL(url);
1570
1718
  const filters = {};
1719
+ const toKey = (label) => columnFor?.(label) ?? label;
1571
1720
  for (const [key, value] of searchParams.entries()) {
1572
1721
  const simpleMatch = /^filter\[([^\]]+)\]$/.exec(key);
1573
1722
  const opMatch = /^filter\[([^\]]+)\]\[([^\]]+)\]$/.exec(key);
1574
1723
  if (simpleMatch) {
1575
1724
  const field = simpleMatch[1];
1576
1725
  if (!validFields.has(field)) return { ok: false, invalidField: field };
1577
- const existing = filters[field];
1726
+ const storageKey = toKey(field);
1727
+ const existing = filters[storageKey];
1578
1728
  if (existing !== void 0) {
1579
- filters[field] = Array.isArray(existing) ? [...existing, value] : [existing, value];
1729
+ filters[storageKey] = Array.isArray(existing) ? [...existing, value] : [existing, value];
1580
1730
  } else {
1581
- filters[field] = value;
1731
+ filters[storageKey] = value;
1582
1732
  }
1583
1733
  } else if (opMatch) {
1584
1734
  const field = opMatch[1];
1585
1735
  const operator = opMatch[2];
1586
1736
  if (!validFields.has(field)) return { ok: false, invalidField: field };
1587
1737
  if (!["gt", "gte", "lt", "lte"].includes(operator)) continue;
1588
- const existing = filters[field] ?? {};
1589
- filters[field] = { ...existing, [operator]: value };
1738
+ const storageKey = toKey(field);
1739
+ const existing = filters[storageKey] ?? {};
1740
+ filters[storageKey] = { ...existing, [operator]: value };
1590
1741
  }
1591
1742
  }
1592
1743
  return { ok: true, filters };
@@ -1596,7 +1747,7 @@ function parseFilters(url, validFields) {
1596
1747
  function isPublished(item) {
1597
1748
  return item["published"] === true;
1598
1749
  }
1599
- function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolver) {
1750
+ function registerPublicContentRoutes(app, registry, repos, projectors, paths, listRateLimit, resolver) {
1600
1751
  function registerListRoute(path, handler) {
1601
1752
  if (listRateLimit) {
1602
1753
  app.get(path, listRateLimit, handler);
@@ -1604,7 +1755,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1604
1755
  app.get(path, handler);
1605
1756
  }
1606
1757
  }
1607
- registerListRoute("/api/content", (c) => {
1758
+ registerListRoute(paths.collection("content"), (c) => {
1608
1759
  const data = Object.values(registry.content_types).map((ct) => ({
1609
1760
  name: ct.name,
1610
1761
  label: ct.label,
@@ -1612,7 +1763,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1612
1763
  }));
1613
1764
  return c.json({ ok: true, data });
1614
1765
  });
1615
- registerListRoute("/api/taxonomy", (c) => {
1766
+ registerListRoute(paths.collection("taxonomy"), (c) => {
1616
1767
  const data = Object.values(registry.taxonomy_types).map((tt) => ({
1617
1768
  name: tt.name,
1618
1769
  label: tt.label
@@ -1631,7 +1782,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1631
1782
  ...contentType.system_fields.map((f) => f.name)
1632
1783
  ]);
1633
1784
  if (contentType.only_one) {
1634
- app.get(`/api/${basePath}`, async (c) => {
1785
+ app.get(paths.collection(basePath), async (c) => {
1635
1786
  const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
1636
1787
  if (result.data.length === 0) {
1637
1788
  return c.json(
@@ -1639,12 +1790,14 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1639
1790
  404
1640
1791
  );
1641
1792
  }
1642
- let data = result.data[0];
1793
+ let data = projectRow(result.data[0], typeName, projectors);
1643
1794
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
1644
1795
  return c.json({ ok: true, data });
1645
1796
  });
1646
1797
  } else {
1647
- registerListRoute(`/api/${basePath}`, async (c) => {
1798
+ registerListRoute(paths.collection(basePath), async (c) => {
1799
+ const projector = projectors[typeName];
1800
+ const fieldKeys = projector.map;
1648
1801
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
1649
1802
  if (!pagination.ok) {
1650
1803
  return c.json(
@@ -1684,7 +1837,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1684
1837
  400
1685
1838
  );
1686
1839
  }
1687
- const filtersResult = parseFilters(c.req.url, filterableFieldNames);
1840
+ const filtersResult = parseFilters(c.req.url, filterableFieldNames, fieldKeys.columnFor);
1688
1841
  if (!filtersResult.ok) {
1689
1842
  return c.json(
1690
1843
  {
@@ -1712,22 +1865,23 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1712
1865
  );
1713
1866
  }
1714
1867
  }
1868
+ const sortColumn = fieldKeys.columnFor(sortBy) ?? sortBy;
1715
1869
  const result = await repo.findMany({
1716
1870
  published_only: true,
1717
1871
  page: pagination.page,
1718
1872
  per_page: pagination.per_page,
1719
- sort_by: sortBy,
1873
+ sort_by: sortColumn,
1720
1874
  sort_order: sortOrder,
1721
1875
  filters: filtersResult.filters,
1722
1876
  include
1723
1877
  });
1724
- if (resolver?.hasSchema(typeName)) {
1725
- const resolved = await resolver.resolveList(typeName, result.data);
1726
- return c.json({ ...result, data: resolved });
1727
- }
1728
- return c.json(result);
1878
+ const labeled = result.data.map(
1879
+ (row) => projectRow(row, typeName, projectors)
1880
+ );
1881
+ const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
1882
+ return c.json({ ...result, data });
1729
1883
  });
1730
- app.get(`/api/${basePath}/:slug`, async (c) => {
1884
+ app.get(paths.item(basePath), async (c) => {
1731
1885
  const slug = c.req.param("slug");
1732
1886
  const include = parseInclude(c.req.query("include"));
1733
1887
  for (const field of include) {
@@ -1754,7 +1908,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1754
1908
  404
1755
1909
  );
1756
1910
  }
1757
- let data = item;
1911
+ let data = projectRow(item, typeName, projectors);
1758
1912
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
1759
1913
  return c.json({ ok: true, data });
1760
1914
  });
@@ -1763,7 +1917,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1763
1917
  for (const [typeName] of Object.entries(registry.taxonomy_types)) {
1764
1918
  const repo = repos[typeName];
1765
1919
  if (!repo) continue;
1766
- registerListRoute(`/api/taxonomy/${typeName}`, async (c) => {
1920
+ registerListRoute(paths.taxonomyCollection(typeName), async (c) => {
1767
1921
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
1768
1922
  if (!pagination.ok) {
1769
1923
  return c.json(
@@ -1782,13 +1936,13 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1782
1936
  page: pagination.page,
1783
1937
  per_page: pagination.per_page
1784
1938
  });
1785
- if (resolver?.hasSchema(typeName)) {
1786
- const resolved = await resolver.resolveList(typeName, result.data);
1787
- return c.json({ ...result, data: resolved });
1788
- }
1789
- return c.json(result);
1939
+ const labeled = result.data.map(
1940
+ (row) => projectRow(row, typeName, projectors)
1941
+ );
1942
+ const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
1943
+ return c.json({ ...result, data });
1790
1944
  });
1791
- app.get(`/api/taxonomy/${typeName}/:id`, async (c) => {
1945
+ app.get(paths.taxonomyItem(typeName), async (c) => {
1792
1946
  const id = c.req.param("id");
1793
1947
  const item = await repo.findOne(id);
1794
1948
  if (!item || !isPublished(item)) {
@@ -1800,7 +1954,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
1800
1954
  404
1801
1955
  );
1802
1956
  }
1803
- let data = item;
1957
+ let data = projectRow(item, typeName, projectors);
1804
1958
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
1805
1959
  return c.json({ ok: true, data });
1806
1960
  });
@@ -1816,7 +1970,7 @@ function parsePagination2(pageStr, perPageStr) {
1816
1970
  if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
1817
1971
  return { ok: true, page, per_page };
1818
1972
  }
1819
- function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
1973
+ function registerPublicMediaRoutes(app, mediaRepo, paths, listRateLimit) {
1820
1974
  const mediaListHandler = async (c) => {
1821
1975
  const pagination = parsePagination2(c.req.query("page"), c.req.query("per_page"));
1822
1976
  if (!pagination.ok) {
@@ -1855,11 +2009,11 @@ function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
1855
2009
  return c.json(result);
1856
2010
  };
1857
2011
  if (listRateLimit) {
1858
- app.get("/api/media", listRateLimit, mediaListHandler);
2012
+ app.get(paths.mediaCollection(), listRateLimit, mediaListHandler);
1859
2013
  } else {
1860
- app.get("/api/media", mediaListHandler);
2014
+ app.get(paths.mediaCollection(), mediaListHandler);
1861
2015
  }
1862
- app.get("/api/media/:id", async (c) => {
2016
+ app.get(paths.mediaItem(), async (c) => {
1863
2017
  const id = c.req.param("id");
1864
2018
  const item = await mediaRepo.findOne(id);
1865
2019
  if (!item) {
@@ -1883,9 +2037,11 @@ function topLevelMediaDelta(mediaFields, before, after) {
1883
2037
  const added = [];
1884
2038
  const removed = [];
1885
2039
  for (const f of mediaFields) {
1886
- if (after !== null && !(f.name in after)) continue;
1887
- const oldId = before ? extractMediaId(before[f.name]) : null;
1888
- const newId = after ? extractMediaId(after[f.name]) : null;
2040
+ const key = f.db_column?.column_name;
2041
+ if (key === void 0 || key === "") continue;
2042
+ if (after !== null && !(key in after)) continue;
2043
+ const oldId = before ? extractMediaId(before[key]) : null;
2044
+ const newId = after ? extractMediaId(after[key]) : null;
1889
2045
  if (newId === oldId) continue;
1890
2046
  if (oldId) removed.push(oldId);
1891
2047
  if (newId) added.push(newId);
@@ -1909,6 +2065,7 @@ async function applyMediaReferenceDelta(delta, mediaRepo) {
1909
2065
 
1910
2066
  // src/routes/admin/content.ts
1911
2067
  init_relations();
2068
+ init_field_keys();
1912
2069
  function quoteIdent2(name) {
1913
2070
  if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
1914
2071
  return `"${name}"`;
@@ -1946,7 +2103,7 @@ function isEmpty(value) {
1946
2103
  function checkRequiredFields(fields, data) {
1947
2104
  return fields.filter((f) => f.required && isEmpty(data[f.name])).map((f) => ({ field: f.name, message: `${f.label} is required` }));
1948
2105
  }
1949
- function parseListQuery(url, schemaFieldNames, relationFieldNames) {
2106
+ function parseListQuery(url, schemaFieldNames, relationFieldNames, columnFor) {
1950
2107
  const searchParams = new URL(url).searchParams;
1951
2108
  const pagination = parsePagination(
1952
2109
  searchParams.get("page") ?? void 0,
@@ -1981,7 +2138,7 @@ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1981
2138
  status: 400
1982
2139
  };
1983
2140
  }
1984
- const filtersResult = parseFilters(url, schemaFieldNames);
2141
+ const filtersResult = parseFilters(url, schemaFieldNames, columnFor);
1985
2142
  if (!filtersResult.ok) {
1986
2143
  return {
1987
2144
  ok: false,
@@ -2013,7 +2170,7 @@ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
2013
2170
  search
2014
2171
  };
2015
2172
  }
2016
- function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db) {
2173
+ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo, requirePermission, db) {
2017
2174
  for (const [typeName, contentType] of Object.entries(registry.content_types)) {
2018
2175
  const basePath = `content/${typeName}`;
2019
2176
  const repo = repos[typeName];
@@ -2025,6 +2182,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2025
2182
  const relationFieldNames = new Set(
2026
2183
  contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
2027
2184
  );
2185
+ const projector = projectors[typeName];
2186
+ const fieldKeys = projector.map;
2028
2187
  const requiredFields = contentType.fields.filter((f) => f.required);
2029
2188
  const mediaFields = contentType.fields.filter(
2030
2189
  (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
@@ -2038,17 +2197,18 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2038
2197
  `/admin/api/${basePath}`,
2039
2198
  requirePermission("content:read"),
2040
2199
  async (c) => {
2041
- const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
2200
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames, fieldKeys.columnFor);
2042
2201
  if (!parsed.ok) {
2043
2202
  return c.json({ ok: false, error: parsed.response }, parsed.status);
2044
2203
  }
2045
2204
  const publishedParam = c.req.query("published");
2046
2205
  const extraFilters = {};
2047
2206
  if (publishedParam === "false") extraFilters["published"] = false;
2207
+ const sortColumn = fieldKeys.columnFor(parsed.sortBy) ?? parsed.sortBy;
2048
2208
  const findOpts = {
2049
2209
  page: parsed.pagination.page,
2050
2210
  per_page: parsed.pagination.per_page,
2051
- sort_by: parsed.sortBy,
2211
+ sort_by: sortColumn,
2052
2212
  sort_order: parsed.sortOrder,
2053
2213
  filters: { ...parsed.filters, ...extraFilters },
2054
2214
  include: parsed.include
@@ -2058,7 +2218,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2058
2218
  findOpts.search = { term: parsed.search, columns: searchableColumns };
2059
2219
  }
2060
2220
  const result = await repo.findMany(findOpts);
2061
- return c.json(result);
2221
+ const data = result.data.map(
2222
+ (row) => projectRow(row, typeName, projectors)
2223
+ );
2224
+ return c.json({ ...result, data });
2062
2225
  }
2063
2226
  );
2064
2227
  app.get(
@@ -2093,7 +2256,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2093
2256
  }
2094
2257
  }
2095
2258
  }
2096
- return c.json({ ok: true, data: item });
2259
+ const data = projectRow(item, typeName, projectors);
2260
+ return c.json({ ok: true, data });
2097
2261
  }
2098
2262
  );
2099
2263
  app.post(
@@ -2160,6 +2324,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2160
2324
  }
2161
2325
  );
2162
2326
  const writeNewItem = async (c, body) => {
2327
+ const storageBody = fieldKeys.toStorage(body);
2163
2328
  const fieldErrors = checkRequiredFields(requiredFields, body);
2164
2329
  if (fieldErrors.length > 0) {
2165
2330
  return c.json(
@@ -2183,9 +2348,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2183
2348
  const junctionFields = contentType.fields.filter(
2184
2349
  (f) => f.db_column !== null && f.db_column.junction !== void 0
2185
2350
  );
2186
- const columnFields = contentType.fields.filter(
2187
- (f) => f.db_column !== null && f.db_column.junction === void 0
2188
- );
2351
+ const columnBackedFields = contentType.fields.filter(isColumnBacked);
2189
2352
  const columnData = {
2190
2353
  published: body["published"] ?? false
2191
2354
  };
@@ -2194,9 +2357,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2194
2357
  } else {
2195
2358
  columnData["slug"] = body["slug"];
2196
2359
  }
2197
- for (const f of columnFields) {
2198
- const val = body[f.name];
2199
- columnData[f.db_column.column_name] = val !== void 0 ? val : null;
2360
+ for (const f of columnBackedFields) {
2361
+ const key = f.db_column.column_name;
2362
+ if (!(key in storageBody)) continue;
2363
+ columnData[key] = storageBody[key];
2200
2364
  }
2201
2365
  if (db) {
2202
2366
  const basePathId = await lookupBasePathId(db, contentType.default_base_path);
@@ -2216,7 +2380,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2216
2380
  }
2217
2381
  const item = await repo.create(columnData);
2218
2382
  const itemId = item["id"];
2219
- const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
2383
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, storageBody)];
2220
2384
  if (db) {
2221
2385
  for (const f of paragraphFields) {
2222
2386
  const comp = f.ui_component;
@@ -2233,7 +2397,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2233
2397
  }
2234
2398
  }
2235
2399
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
2236
- return c.json({ ok: true, data: item }, 201);
2400
+ const data = projectRow(item, typeName, projectors);
2401
+ return c.json({ ok: true, data }, 201);
2237
2402
  };
2238
2403
  app.patch(
2239
2404
  `/admin/api/${basePath}/:id`,
@@ -2293,11 +2458,12 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2293
2458
  }
2294
2459
  );
2295
2460
  const writeExistingItem = async (c, id, existing, body) => {
2461
+ const storageBody = fieldKeys.toStorage(body);
2296
2462
  if (body["published"] === true) {
2297
2463
  const publishDeny = await requirePermission("content:edit")(c, async () => {
2298
2464
  });
2299
2465
  if (publishDeny) return publishDeny;
2300
- const merged = { ...existing, ...body };
2466
+ const merged = { ...fieldKeys.toLabels(existing), ...body };
2301
2467
  const fieldErrors = checkRequiredFields(requiredFields, merged);
2302
2468
  if (fieldErrors.length > 0) {
2303
2469
  return c.json(
@@ -2317,17 +2483,14 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2317
2483
  const patchJunctionFields = contentType.fields.filter(
2318
2484
  (f) => f.db_column !== null && f.db_column.junction !== void 0
2319
2485
  );
2320
- const patchColumnFields = contentType.fields.filter(
2321
- (f) => f.db_column !== null && f.db_column.junction === void 0
2322
- );
2486
+ const patchColumnBackedFields = contentType.fields.filter(isColumnBacked);
2323
2487
  const patchData = {};
2324
2488
  if (!contentType.only_one && "slug" in body) patchData["slug"] = body["slug"];
2325
2489
  if ("published" in body) patchData["published"] = body["published"];
2326
- for (const f of patchColumnFields) {
2327
- if (f.name in body) {
2328
- const val = body[f.name];
2329
- patchData[f.db_column.column_name] = val !== void 0 ? val : null;
2330
- }
2490
+ for (const f of patchColumnBackedFields) {
2491
+ const key = f.db_column.column_name;
2492
+ if (!(key in storageBody)) continue;
2493
+ patchData[key] = storageBody[key];
2331
2494
  }
2332
2495
  const updated = await repo.update(id, patchData);
2333
2496
  if (!updated) {
@@ -2337,7 +2500,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2337
2500
  );
2338
2501
  }
2339
2502
  const mediaDeltas = [
2340
- topLevelMediaDelta(mediaFields, existing, body)
2503
+ topLevelMediaDelta(mediaFields, existing, storageBody)
2341
2504
  ];
2342
2505
  if (db) {
2343
2506
  for (const f of patchParagraphFields) {
@@ -2355,7 +2518,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2355
2518
  }
2356
2519
  }
2357
2520
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
2358
- return c.json({ ok: true, data: updated });
2521
+ const data = projectRow(updated, typeName, projectors);
2522
+ return c.json({ ok: true, data });
2359
2523
  };
2360
2524
  if (contentType.only_one) {
2361
2525
  app.put(
@@ -2415,6 +2579,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2415
2579
  const relationFieldNames = new Set(
2416
2580
  taxonomyType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
2417
2581
  );
2582
+ const projector = projectors[typeName];
2583
+ const fieldKeys = projector.map;
2418
2584
  const requiredFields = taxonomyType.fields.filter((f) => f.required);
2419
2585
  const mediaFields = taxonomyType.fields.filter(
2420
2586
  (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
@@ -2428,17 +2594,18 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2428
2594
  `/admin/api/taxonomy/${typeName}`,
2429
2595
  requirePermission("content:read"),
2430
2596
  async (c) => {
2431
- const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
2597
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames, fieldKeys.columnFor);
2432
2598
  if (!parsed.ok) {
2433
2599
  return c.json({ ok: false, error: parsed.response }, parsed.status);
2434
2600
  }
2435
2601
  const publishedParam = c.req.query("published");
2436
2602
  const extraFilters = {};
2437
2603
  if (publishedParam === "false") extraFilters["published"] = false;
2604
+ const sortColumn = fieldKeys.columnFor(parsed.sortBy) ?? parsed.sortBy;
2438
2605
  const findOpts = {
2439
2606
  page: parsed.pagination.page,
2440
2607
  per_page: parsed.pagination.per_page,
2441
- sort_by: parsed.sortBy,
2608
+ sort_by: sortColumn,
2442
2609
  sort_order: parsed.sortOrder,
2443
2610
  filters: { ...parsed.filters, ...extraFilters },
2444
2611
  include: parsed.include
@@ -2448,7 +2615,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2448
2615
  findOpts.search = { term: parsed.search, columns: searchableColumns };
2449
2616
  }
2450
2617
  const result = await repo.findMany(findOpts);
2451
- return c.json(result);
2618
+ const data = result.data.map(
2619
+ (row) => projectRow(row, typeName, projectors)
2620
+ );
2621
+ return c.json({ ...result, data });
2452
2622
  }
2453
2623
  );
2454
2624
  app.get(
@@ -2463,7 +2633,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2463
2633
  404
2464
2634
  );
2465
2635
  }
2466
- return c.json({ ok: true, data: item });
2636
+ const data = projectRow(item, typeName, projectors);
2637
+ return c.json({ ok: true, data });
2467
2638
  }
2468
2639
  );
2469
2640
  app.post(
@@ -2471,6 +2642,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2471
2642
  requirePermission("content:create"),
2472
2643
  async (c) => {
2473
2644
  const body = await c.req.json();
2645
+ const storageBody = fieldKeys.toStorage(body);
2474
2646
  const fieldErrors = checkRequiredFields(requiredFields, body);
2475
2647
  if (fieldErrors.length > 0) {
2476
2648
  return c.json(
@@ -2490,20 +2662,19 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2490
2662
  });
2491
2663
  if (publishDeny) return publishDeny;
2492
2664
  }
2493
- const taxColumnFields = taxonomyType.fields.filter(
2494
- (f) => f.db_column !== null && f.db_column.junction === void 0
2495
- );
2496
2665
  const taxParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
2666
+ const taxColumnBackedFields = taxonomyType.fields.filter(isColumnBacked);
2497
2667
  const taxColumnData = {
2498
2668
  published: body["published"] ?? false
2499
2669
  };
2500
- for (const f of taxColumnFields) {
2501
- const val = body[f.name];
2502
- taxColumnData[f.db_column.column_name] = val !== void 0 ? val : null;
2670
+ for (const f of taxColumnBackedFields) {
2671
+ const key = f.db_column.column_name;
2672
+ if (!(key in storageBody)) continue;
2673
+ taxColumnData[key] = storageBody[key];
2503
2674
  }
2504
2675
  const item = await repo.create(taxColumnData);
2505
2676
  const taxItemId = item["id"];
2506
- const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
2677
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, storageBody)];
2507
2678
  if (db) {
2508
2679
  for (const f of taxParagraphFields) {
2509
2680
  const comp = f.ui_component;
@@ -2515,7 +2686,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2515
2686
  }
2516
2687
  }
2517
2688
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
2518
- return c.json({ ok: true, data: item }, 201);
2689
+ const data = projectRow(item, typeName, projectors);
2690
+ return c.json({ ok: true, data }, 201);
2519
2691
  }
2520
2692
  );
2521
2693
  app.patch(
@@ -2524,6 +2696,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2524
2696
  async (c) => {
2525
2697
  const id = c.req.param("id");
2526
2698
  const body = await c.req.json();
2699
+ const storageBody = fieldKeys.toStorage(body);
2527
2700
  const existing = await repo.findOne(id);
2528
2701
  if (!existing) {
2529
2702
  return c.json(
@@ -2535,7 +2708,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2535
2708
  const publishDeny = await requirePermission("content:edit")(c, async () => {
2536
2709
  });
2537
2710
  if (publishDeny) return publishDeny;
2538
- const merged = { ...existing, ...body };
2711
+ const merged = { ...fieldKeys.toLabels(existing), ...body };
2539
2712
  const fieldErrors = checkRequiredFields(requiredFields, merged);
2540
2713
  if (fieldErrors.length > 0) {
2541
2714
  return c.json(
@@ -2551,17 +2724,14 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2551
2724
  );
2552
2725
  }
2553
2726
  }
2554
- const taxPatchColumnFields = taxonomyType.fields.filter(
2555
- (f) => f.db_column !== null && f.db_column.junction === void 0
2556
- );
2557
2727
  const taxPatchParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
2728
+ const taxPatchColumnBackedFields = taxonomyType.fields.filter(isColumnBacked);
2558
2729
  const taxPatchData = {};
2559
2730
  if ("published" in body) taxPatchData["published"] = body["published"];
2560
- for (const f of taxPatchColumnFields) {
2561
- if (f.name in body) {
2562
- const val = body[f.name];
2563
- taxPatchData[f.db_column.column_name] = val !== void 0 ? val : null;
2564
- }
2731
+ for (const f of taxPatchColumnBackedFields) {
2732
+ const key = f.db_column.column_name;
2733
+ if (!(key in storageBody)) continue;
2734
+ taxPatchData[key] = storageBody[key];
2565
2735
  }
2566
2736
  const updated = await repo.update(id, taxPatchData);
2567
2737
  if (!updated) {
@@ -2571,7 +2741,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2571
2741
  );
2572
2742
  }
2573
2743
  const mediaDeltas = [
2574
- topLevelMediaDelta(mediaFields, existing, body)
2744
+ topLevelMediaDelta(mediaFields, existing, storageBody)
2575
2745
  ];
2576
2746
  if (db) {
2577
2747
  for (const f of taxPatchParagraphFields) {
@@ -2584,7 +2754,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
2584
2754
  }
2585
2755
  }
2586
2756
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
2587
- return c.json({ ok: true, data: updated });
2757
+ const data = projectRow(updated, typeName, projectors);
2758
+ return c.json({ ok: true, data });
2588
2759
  }
2589
2760
  );
2590
2761
  app.delete(
@@ -3576,7 +3747,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
3576
3747
  var import_drizzle_orm6 = require("drizzle-orm");
3577
3748
  var API_VERSION = (() => {
3578
3749
  try {
3579
- return "0.3.3";
3750
+ return "0.4.1";
3580
3751
  } catch {
3581
3752
  return "0.0.0";
3582
3753
  }
@@ -3739,7 +3910,7 @@ function whereFragment(conds) {
3739
3910
  return conds.length > 0 ? import_drizzle_orm8.sql` WHERE ${import_drizzle_orm8.sql.join(conds, import_drizzle_orm8.sql` AND `)}` : import_drizzle_orm8.sql``;
3740
3911
  }
3741
3912
  function createDrizzleContentRepository(db, tableName, options = {}) {
3742
- const { relations = {}, publishedRelations = false } = options;
3913
+ const { relations = {}, publishedRelations = false, sortableColumns } = options;
3743
3914
  function tableRaw() {
3744
3915
  return import_drizzle_orm8.sql.raw(quoteIdent3(tableName));
3745
3916
  }
@@ -3777,8 +3948,9 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
3777
3948
  sort_order = "asc",
3778
3949
  search
3779
3950
  } = opts;
3780
- if (!SORTABLE_FIELDS2.has(sort_by)) {
3781
- throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable. Allowed: title, created_at, updated_at`);
3951
+ const allowed = sortableColumns ?? SORTABLE_FIELDS2;
3952
+ if (!allowed.has(sort_by)) {
3953
+ throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable.`);
3782
3954
  }
3783
3955
  const offset = (page - 1) * per_page;
3784
3956
  const conds = buildConditions(published_only, filters, search);
@@ -3978,6 +4150,7 @@ function createMediaRepository(db) {
3978
4150
  }
3979
4151
 
3980
4152
  // src/app.ts
4153
+ init_field_keys();
3981
4154
  var MISSING_STORAGE_ERROR = `\u2717 api.storage is required but not configured.
3982
4155
  Add a storage adapter to your manguito.config.ts:
3983
4156
 
@@ -3991,7 +4164,8 @@ function createCmsApp(options) {
3991
4164
  if (!options.storage) {
3992
4165
  throw new Error(MISSING_STORAGE_ERROR);
3993
4166
  }
3994
- const prefix = options.prefix ?? "/api";
4167
+ const prefix = normalizePrefix(options.prefix);
4168
+ const publicPaths = createPublicPaths(prefix);
3995
4169
  const { storage, registry, db, rateLimit, media, cors } = options;
3996
4170
  const cmsName = options.name ?? "Manguito CMS";
3997
4171
  const maxFileSize = media?.max_file_size;
@@ -4025,16 +4199,38 @@ function createCmsApp(options) {
4025
4199
  return result.rows[0]?.role ?? null;
4026
4200
  };
4027
4201
  const requireHierarchy = createHierarchyMiddleware(rolesRegistry, getUserRole);
4202
+ const fieldKeyMaps = Object.fromEntries([
4203
+ ...Object.entries(registry.content_types).map(([typeName, ct]) => [
4204
+ typeName,
4205
+ createFieldKeyMap(ct.fields)
4206
+ ]),
4207
+ ...Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
4208
+ typeName,
4209
+ createFieldKeyMap(tt.fields)
4210
+ ]),
4211
+ ...Object.entries(registry.paragraph_types).map(([typeName, pt]) => [
4212
+ typeName,
4213
+ createFieldKeyMap(pt.fields)
4214
+ ])
4215
+ ]);
4216
+ const projectors = buildProjectors(registry, fieldKeyMaps);
4217
+ const sortableColumnsFor = (typeName) => new Set(
4218
+ [...SORTABLE_FIELDS].map((label) => fieldKeyMaps[typeName].columnFor(label) ?? label)
4219
+ );
4028
4220
  const contentRepos = Object.fromEntries(
4029
4221
  Object.entries(registry.content_types).map(([typeName, ct]) => [
4030
4222
  typeName,
4031
- createDrizzleContentRepository(db, ct.db.table_name)
4223
+ createDrizzleContentRepository(db, ct.db.table_name, {
4224
+ sortableColumns: sortableColumnsFor(typeName)
4225
+ })
4032
4226
  ])
4033
4227
  );
4034
4228
  const taxonomyRepos = Object.fromEntries(
4035
4229
  Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
4036
4230
  typeName,
4037
- createDrizzleContentRepository(db, tt.db.table_name)
4231
+ createDrizzleContentRepository(db, tt.db.table_name, {
4232
+ sortableColumns: sortableColumnsFor(typeName)
4233
+ })
4038
4234
  ])
4039
4235
  );
4040
4236
  const repos = { ...contentRepos, ...taxonomyRepos };
@@ -4044,7 +4240,8 @@ function createCmsApp(options) {
4044
4240
  typeName,
4045
4241
  createDrizzleContentRepository(db, ct.db.table_name, {
4046
4242
  relations: buildRelationsMap(ct.fields, registry),
4047
- publishedRelations: true
4243
+ publishedRelations: true,
4244
+ sortableColumns: sortableColumnsFor(typeName)
4048
4245
  })
4049
4246
  ])
4050
4247
  );
@@ -4053,7 +4250,8 @@ function createCmsApp(options) {
4053
4250
  typeName,
4054
4251
  createDrizzleContentRepository(db, tt.db.table_name, {
4055
4252
  relations: buildRelationsMap(tt.fields, registry),
4056
- publishedRelations: true
4253
+ publishedRelations: true,
4254
+ sortableColumns: sortableColumnsFor(typeName)
4057
4255
  })
4058
4256
  ])
4059
4257
  );
@@ -4061,33 +4259,41 @@ function createCmsApp(options) {
4061
4259
  const graphqlRepos = Object.fromEntries([
4062
4260
  ...Object.entries(registry.content_types).map(([typeName, ct]) => [
4063
4261
  typeName,
4064
- createDrizzleContentRepository(db, ct.db.table_name)
4262
+ createDrizzleContentRepository(db, ct.db.table_name, {
4263
+ sortableColumns: sortableColumnsFor(typeName)
4264
+ })
4065
4265
  ]),
4066
4266
  ...Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
4067
4267
  typeName,
4068
- createDrizzleContentRepository(db, tt.db.table_name)
4268
+ createDrizzleContentRepository(db, tt.db.table_name, {
4269
+ sortableColumns: sortableColumnsFor(typeName)
4270
+ })
4069
4271
  ])
4070
4272
  ]);
4071
4273
  registerAuthRoutes(app, db);
4072
4274
  app.use("/admin/api/*", authMiddleware);
4073
4275
  app.use("/admin/api/*", mustChangePasswordCheck);
4074
- app.get("/api/openapi.json", (c) => {
4276
+ app.get(publicPaths.openapi(), (c) => {
4075
4277
  const paths = {};
4076
4278
  for (const ct of Object.values(registry.content_types)) {
4077
4279
  const base = ct.default_base_path;
4078
4280
  if (ct.only_one) {
4079
- paths[`/api/${base}`] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
4281
+ paths[publicPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
4080
4282
  } else {
4081
- paths[`/api/${base}`] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
4082
- paths[`/api/${base}/{slug}`] = { get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] } };
4283
+ paths[publicPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
4284
+ paths[publicPaths.item(base).replace(":slug", "{slug}")] = {
4285
+ get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] }
4286
+ };
4083
4287
  }
4084
4288
  }
4085
4289
  for (const tt of Object.values(registry.taxonomy_types)) {
4086
- paths[`/api/taxonomy/${tt.name}`] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
4087
- paths[`/api/taxonomy/${tt.name}/{id}`] = { get: { summary: `Get ${tt.label} by id`, tags: [tt.label] } };
4290
+ paths[publicPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
4291
+ paths[publicPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
4292
+ get: { summary: `Get ${tt.label} by id`, tags: [tt.label] }
4293
+ };
4088
4294
  }
4089
- paths["/api/media"] = { get: { summary: "List media items", tags: ["Media"] } };
4090
- paths["/api/media/{id}"] = { get: { summary: "Get media item by id", tags: ["Media"] } };
4295
+ paths[publicPaths.mediaCollection()] = { get: { summary: "List media items", tags: ["Media"] } };
4296
+ paths[publicPaths.mediaItem().replace(":id", "{id}")] = { get: { summary: "Get media item by id", tags: ["Media"] } };
4091
4297
  return c.json({
4092
4298
  openapi: "3.0.3",
4093
4299
  info: { title: "Manguito CMS Public API", version: "1.0.0" },
@@ -4102,14 +4308,14 @@ function createCmsApp(options) {
4102
4308
  paths: {}
4103
4309
  })
4104
4310
  );
4105
- registerPublicContentRoutes(app, registry, publicRepos, listRateLimit, programmaticResolver);
4106
- registerPublicMediaRoutes(app, mediaRepo, listRateLimit);
4311
+ registerPublicContentRoutes(app, registry, publicRepos, projectors, publicPaths, listRateLimit, programmaticResolver);
4312
+ registerPublicMediaRoutes(app, mediaRepo, publicPaths, listRateLimit);
4107
4313
  if (options.graphql?.enabled) {
4108
4314
  const gqlOptions = options.graphql;
4109
4315
  let gqlHandler = null;
4110
4316
  let initError = null;
4111
4317
  const ready = Promise.resolve().then(() => (init_handler(), handler_exports)).then(({ createGraphQLHandler: createGraphQLHandler2 }) => {
4112
- gqlHandler = createGraphQLHandler2(registry, graphqlRepos, programmaticResolver, db, gqlOptions);
4318
+ gqlHandler = createGraphQLHandler2(registry, graphqlRepos, fieldKeyMaps, programmaticResolver, db, gqlOptions);
4113
4319
  }).catch((err) => {
4114
4320
  initError = err;
4115
4321
  const message = err instanceof Error ? err.message : String(err);
@@ -4149,7 +4355,7 @@ function createCmsApp(options) {
4149
4355
  );
4150
4356
  registerSchemaRoute(app, registry, db);
4151
4357
  registerUserRoutes(app, db, requirePermission, requireHierarchy);
4152
- registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db);
4358
+ registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo, requirePermission, db);
4153
4359
  registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize);
4154
4360
  return { prefix, app };
4155
4361
  }
@@ -4190,7 +4396,7 @@ function resolveGraphQL(cfg) {
4190
4396
  };
4191
4397
  }
4192
4398
  function createAPIAdapter(options = {}) {
4193
- const prefix = options.prefix ?? "/api";
4399
+ const prefix = normalizePrefix(options.prefix);
4194
4400
  const media = options.media?.max_file_size !== void 0 ? { max_file_size: options.media.max_file_size } : void 0;
4195
4401
  return {
4196
4402
  prefix,