@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.
@@ -6,7 +6,7 @@ function isColumnBacked(field) {
6
6
  if (col.junction) return false;
7
7
  return true;
8
8
  }
9
- function buildFieldKeyMap(pairs, allLabels, droppedKeys) {
9
+ function buildFieldKeyMap(pairs, allLabels, droppedColumns, droppedLabels) {
10
10
  const labelToColumn = /* @__PURE__ */ new Map();
11
11
  const columnToLabel = /* @__PURE__ */ new Map();
12
12
  for (const { label, column } of pairs) {
@@ -14,7 +14,7 @@ function buildFieldKeyMap(pairs, allLabels, droppedKeys) {
14
14
  columnToLabel.set(column, label);
15
15
  }
16
16
  for (const { label, ownColumn } of allLabels) {
17
- if (ownColumn === label) continue;
17
+ if (ownColumn !== void 0) continue;
18
18
  const columnOwner = columnToLabel.get(label);
19
19
  if (columnOwner !== void 0 && columnOwner !== label) {
20
20
  throw new Error(
@@ -23,23 +23,33 @@ function buildFieldKeyMap(pairs, allLabels, droppedKeys) {
23
23
  }
24
24
  }
25
25
  for (const label of labelToColumn.keys()) {
26
- if (droppedKeys.has(label)) labelToColumn.delete(label);
26
+ if (droppedLabels.has(label)) labelToColumn.delete(label);
27
27
  }
28
28
  for (const column of columnToLabel.keys()) {
29
- if (droppedKeys.has(column)) columnToLabel.delete(column);
29
+ if (droppedColumns.has(column)) columnToLabel.delete(column);
30
30
  }
31
31
  const diverges = [...labelToColumn].some(([label, column]) => label !== column);
32
- function remap(input, lookup) {
32
+ function remap(input, lookup, dropped, droppedIfUnmapped) {
33
33
  const out = {};
34
34
  for (const key of Object.keys(input)) {
35
- if (droppedKeys.has(key)) continue;
36
- out[lookup.get(key) ?? key] = input[key];
35
+ const mapped = lookup.get(key);
36
+ if (mapped !== void 0) {
37
+ out[mapped] = input[key];
38
+ continue;
39
+ }
40
+ if (dropped.has(key) || droppedIfUnmapped?.has(key)) continue;
41
+ out[key] = input[key];
37
42
  }
38
43
  return out;
39
44
  }
40
45
  return {
41
- toStorage: (input) => remap(input, labelToColumn),
42
- toLabels: (row) => remap(row, columnToLabel),
46
+ // toStorage reads LABEL-keyed input: a live label maps and writes
47
+ // regardless of either drop set; an unmapped key is refused if it is
48
+ // EITHER the tombstone's own name (droppedLabels) or a raw, unexposed
49
+ // column (droppedColumns) — see remap's comment.
50
+ // toLabels reads COLUMN-keyed input, so it consults only droppedColumns.
51
+ toStorage: (input) => remap(input, labelToColumn, droppedLabels, droppedColumns),
52
+ toLabels: (row) => remap(row, columnToLabel, droppedColumns),
43
53
  columnFor: (label) => labelToColumn.get(label),
44
54
  labelFor: (column) => columnToLabel.get(column),
45
55
  labels: [...labelToColumn.keys()],
@@ -47,14 +57,15 @@ function buildFieldKeyMap(pairs, allLabels, droppedKeys) {
47
57
  };
48
58
  }
49
59
  function createFieldKeyMap(fields) {
50
- const droppedKeys = /* @__PURE__ */ new Set();
60
+ const droppedColumns = /* @__PURE__ */ new Set();
61
+ const droppedLabels = /* @__PURE__ */ new Set();
51
62
  const pairs = [];
52
63
  for (const f of fields) {
53
64
  if (!isColumnBacked(f)) continue;
54
65
  pairs.push({ label: f.name, column: f.db_column.column_name });
55
66
  if (f.removed === true) {
56
- droppedKeys.add(f.name);
57
- droppedKeys.add(f.db_column.column_name);
67
+ droppedLabels.add(f.name);
68
+ droppedColumns.add(f.db_column.column_name);
58
69
  }
59
70
  }
60
71
  return buildFieldKeyMap(
@@ -65,36 +76,40 @@ function createFieldKeyMap(fields) {
65
76
  // forbids present-and-undefined.
66
77
  ...isColumnBacked(f) && { ownColumn: f.db_column.column_name }
67
78
  })),
68
- droppedKeys
79
+ droppedColumns,
80
+ droppedLabels
69
81
  );
70
82
  }
71
83
  function createFieldKeyMapFromProjection(projectionType, allFields) {
72
84
  const pairs = projectionType.fields.map((f) => ({ label: f.exposed_as, column: f.column_name }));
73
- const projected = new Set(pairs.map((p) => p.label));
74
- const dropped = /* @__PURE__ */ new Set();
85
+ const projectedColumns = new Set(pairs.map((p) => p.column));
86
+ const projectedLabels = new Set(pairs.map((p) => p.label));
87
+ const droppedColumns = /* @__PURE__ */ new Set();
88
+ const droppedLabels = /* @__PURE__ */ new Set();
75
89
  for (const f of allFields) {
76
90
  if (!isColumnBacked(f)) continue;
77
- dropped.add(f.name);
78
- dropped.add(f.db_column.column_name);
91
+ if (!projectedColumns.has(f.db_column.column_name)) droppedColumns.add(f.db_column.column_name);
92
+ if (!projectedLabels.has(f.name)) droppedLabels.add(f.name);
79
93
  }
80
- for (const p of pairs) dropped.delete(p.column);
81
- for (const label of projected) dropped.delete(label);
82
94
  return buildFieldKeyMap(
83
95
  pairs,
84
96
  allFields.map((f) => ({
85
97
  label: f.name,
86
- // See buildFieldKeyMap's collision check: this is what lets it tell a
87
- // version exposing a field's own column under a new label (fine) from a
88
- // label that reuses another field's column (not fine).
98
+ // See buildFieldKeyMap's collision check: `ownColumn` marks every
99
+ // column-backed field so the check skips it entirely — only a
100
+ // paragraph/junction/programmatic label (no column of its own) can
101
+ // still collide.
89
102
  ...isColumnBacked(f) && { ownColumn: f.db_column.column_name }
90
103
  })),
91
- dropped
104
+ droppedColumns,
105
+ droppedLabels
92
106
  );
93
107
  }
94
108
 
95
109
  // src/relations.ts
96
110
  import crypto from "crypto";
97
111
  import { sql } from "drizzle-orm";
112
+ import { relationCardinality } from "@bobbykim/manguito-cms-core";
98
113
  function quoteIdent(name) {
99
114
  if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
100
115
  return `"${name}"`;
@@ -129,7 +144,7 @@ function nestedParagraphFields(pType, registry) {
129
144
  const comp = f.ui_component;
130
145
  if (comp.component !== "paragraph-embed" || !comp.ref) continue;
131
146
  const nType = registry.paragraph_types[comp.ref];
132
- if (nType) out.push({ fieldName: f.name, nType });
147
+ if (nType) out.push({ fieldName: f.name, nType, field: f });
133
148
  }
134
149
  return out;
135
150
  }
@@ -180,7 +195,7 @@ async function persistParagraphField(db, itemId, parentType, fieldName, pType, i
180
195
  if (typeof val === "string" && val !== "") added.push(val);
181
196
  }
182
197
  for (const n of nested) {
183
- const nestedItems = Array.isArray(pItem[n.fieldName]) ? pItem[n.fieldName] : [];
198
+ const nestedItems = paragraphItems(n.field, pItem[n.fieldName]);
184
199
  const d = await persistParagraphField(
185
200
  db,
186
201
  rowId,
@@ -221,6 +236,15 @@ async function persistJunctionField(db, itemId, junction, relatedIds) {
221
236
  await db.execute(sql`INSERT INTO ${tbl} (${left}, ${right}) VALUES (${itemId}, ${rid})`);
222
237
  }
223
238
  }
239
+ function oneOrMany(cardinality, list) {
240
+ return cardinality === "one" ? list[0] ?? null : list;
241
+ }
242
+ function paragraphItems(field, value) {
243
+ if (relationCardinality(field) === "one") {
244
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? [value] : [];
245
+ }
246
+ return Array.isArray(value) ? value : [];
247
+ }
224
248
  function buildRelationsMap(fields, registry) {
225
249
  const relations = {};
226
250
  for (const field of fields) {
@@ -231,7 +255,11 @@ function buildRelationsMap(fields, registry) {
231
255
  const ref = field.ui_component.ref;
232
256
  const pType = ref ? registry.paragraph_types[ref] : void 0;
233
257
  if (!pType) continue;
234
- relations[field.name] = { type: "paragraph", table: pType.db.table_name };
258
+ relations[field.name] = {
259
+ type: "paragraph",
260
+ table: pType.db.table_name,
261
+ cardinality: relationCardinality(field) ?? "many"
262
+ };
235
263
  } else if (field.field_type === "reference") {
236
264
  if (!field.db_column) continue;
237
265
  if (field.db_column.junction) {
@@ -264,9 +292,8 @@ function groupBy(items, key) {
264
292
  }
265
293
  return result;
266
294
  }
267
- function needsFkResolution(row, fkColumn, fieldName) {
268
- if (fkColumn !== fieldName && !(fkColumn in row)) return false;
269
- const current = row[fieldName];
295
+ function needsFkResolution(row, fkColumn) {
296
+ const current = row[fkColumn];
270
297
  return !(typeof current === "object" && current !== null);
271
298
  }
272
299
  async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOnly = false) {
@@ -280,18 +307,14 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
280
307
  );
281
308
  const byParent = groupBy(result.rows, "parent_id");
282
309
  for (const row of batch) {
283
- row[fieldName] = byParent[row["id"]] ?? [];
310
+ row[fieldName] = oneOrMany(rel.cardinality, byParent[row["id"]] ?? []);
284
311
  }
285
312
  } else if (rel.type === "reference") {
286
- const dropFk = rel.fk_column !== fieldName;
287
- const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column, fieldName));
313
+ const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column));
288
314
  if (pending.length === 0) return;
289
315
  const fkValues = pending.map((r) => r[rel.fk_column]).filter(Boolean);
290
316
  if (fkValues.length === 0) {
291
- for (const row of pending) {
292
- row[fieldName] = null;
293
- if (dropFk) delete row[rel.fk_column];
294
- }
317
+ for (const row of pending) row[rel.fk_column] = null;
295
318
  return;
296
319
  }
297
320
  const unique = [...new Set(fkValues)];
@@ -308,8 +331,7 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
308
331
  }
309
332
  for (const row of pending) {
310
333
  const fkVal = row[rel.fk_column];
311
- row[fieldName] = fkVal ? cache.get(`${rel.table}:${fkVal}`) ?? null : null;
312
- if (dropFk) delete row[rel.fk_column];
334
+ row[rel.fk_column] = fkVal ? cache.get(`${rel.table}:${fkVal}`) ?? null : null;
313
335
  }
314
336
  } else if (rel.type === "junction") {
315
337
  const parentIds = batch.map((r) => r["id"]);
@@ -337,15 +359,11 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
337
359
  row[fieldName] = jEntries.map((jr) => cache.get(`${rel.table}:${jr[rel.right_column]}`)).filter(Boolean);
338
360
  }
339
361
  } else if (rel.type === "media") {
340
- const dropFk = rel.fk_column !== fieldName;
341
- const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column, fieldName));
362
+ const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column));
342
363
  if (pending.length === 0) return;
343
364
  const fkValues = pending.map((r) => r[rel.fk_column]).filter(Boolean);
344
365
  if (fkValues.length === 0) {
345
- for (const row of pending) {
346
- row[fieldName] = null;
347
- if (dropFk) delete row[rel.fk_column];
348
- }
366
+ for (const row of pending) row[rel.fk_column] = null;
349
367
  return;
350
368
  }
351
369
  const unique = [...new Set(fkValues)];
@@ -359,8 +377,7 @@ async function resolveRelationField(db, rows, fieldName, rel, cache, publishedOn
359
377
  }
360
378
  for (const row of pending) {
361
379
  const fkVal = row[rel.fk_column];
362
- row[fieldName] = fkVal ? cache.get(`media:${fkVal}`) ?? null : null;
363
- if (dropFk) delete row[rel.fk_column];
380
+ row[rel.fk_column] = fkVal ? cache.get(`media:${fkVal}`) ?? null : null;
364
381
  }
365
382
  }
366
383
  }
@@ -374,7 +391,7 @@ async function resolveRelationBareIds(db, rows, fieldName, rel) {
374
391
  );
375
392
  const byParent = groupBy(result.rows, "parent_id");
376
393
  for (const row of rows) {
377
- row[fieldName] = (byParent[row["id"]] ?? []).map((r) => r["id"]);
394
+ row[fieldName] = oneOrMany(rel.cardinality, (byParent[row["id"]] ?? []).map((r) => r["id"]));
378
395
  }
379
396
  } else if (rel.type === "junction") {
380
397
  const parentIds = rows.map((r) => r["id"]);
@@ -396,8 +413,10 @@ export {
396
413
  persistParagraphField,
397
414
  deleteParagraphField,
398
415
  persistJunctionField,
416
+ oneOrMany,
417
+ paragraphItems,
399
418
  buildRelationsMap,
400
419
  resolveRelationField,
401
420
  resolveRelationBareIds
402
421
  };
403
- //# sourceMappingURL=chunk-JVP33FI2.mjs.map
422
+ //# sourceMappingURL=chunk-IHEIX6P4.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/field-keys.ts","../src/relations.ts"],"sourcesContent":["import type { DbColumn, ParsedField } from '@bobbykim/manguito-cms-core'\n\n// ─── Label ↔ storage key mapping ──────────────────────────────────────────────\n//\n// A field has two names: its public LABEL (`field.name`, what API consumers and\n// the admin panel see) and its STORAGE key (`db_column.column_name`, the actual\n// Postgres column). They are identical today, but schema versioning makes a\n// rename change only the label — the column keeps its original name for the life\n// of the data. Every place that reads or writes a DB row must therefore use the\n// storage key, and every place that reads a request body or writes a response\n// must use the label.\n//\n// Only column-backed fields participate. Paragraph fields have no column\n// (their association lives on the paragraph table via parent_field) and\n// many-to-many references have no column either (the junction table owns the\n// association), so both keep the field name as their identity and are excluded.\n\n// A type predicate, not a plain boolean: narrowing `db_column` to non-null lets\n// the compiler enforce the invariant at every call site instead of each one\n// re-asserting it with `db_column!`.\nexport function isColumnBacked(field: ParsedField): field is ParsedField & { db_column: DbColumn } {\n const col = field.db_column\n if (col === null) return false\n if (col.column_name === '') return false\n if (col.junction) return false\n return true\n}\n\nexport type FieldKeyMap = {\n /** Request body (label-keyed) → storage-keyed. Unknown keys pass through. */\n toStorage(input: Record<string, unknown>): Record<string, unknown>\n /** DB row (storage-keyed) → label-keyed. Unknown keys pass through. */\n toLabels(row: Record<string, unknown>): Record<string, unknown>\n columnFor(label: string): string | undefined\n labelFor(column: string): string | undefined\n /** Labels of column-backed fields — the valid filter/sort surface. */\n labels: string[]\n /** False when every label equals its column, letting callers skip the copy. */\n diverges: boolean\n}\n\n/**\n * One field's place in the label space: the label it is known by, plus the\n * storage column it owns. `ownColumn` is ABSENT — never present-and-undefined,\n * per exactOptionalPropertyTypes — for a field with no column of its own.\n */\ntype LabelEntry = { label: string; ownColumn?: string }\n\n/**\n * The shared core: given a label↔column mapping and the FULL label space to\n * check against, produce a FieldKeyMap. Both `createFieldKeyMap` and\n * `createFieldKeyMapFromProjection` are thin adapters over this — the\n * collision check, the `diverges` computation and `remap` live here exactly\n * once.\n *\n * `pairs` may include entries that must NOT survive into the final map —\n * `createFieldKeyMap` passes tombstoned columns in alongside live ones,\n * exactly as the original single-function implementation did, so that the\n * collision check below (which runs against the unfiltered map) still\n * catches a paragraph/junction/programmatic field's label colliding with a\n * TOMBSTONE's column. Any pair whose label appears in `droppedLabels`, or\n * whose column appears in `droppedColumns`, is stripped from\n * `labelToColumn`/`columnToLabel` respectively, immediately after the check,\n * before `diverges` and `labels` are computed — so the caller does not need\n * to pre-filter `pairs` itself, only tell this function what to drop in each\n * key space.\n *\n * `allLabels` is every field's label, including fields with no column of\n * their own. That is not tidiness: a paragraph, many-to-many or programmatic\n * field's label is written into the same key space as storage columns before\n * `toLabels` runs, so one named after another field's column would overwrite\n * that column's value and then be renamed onto the other field's label.\n * Both constructors must pass the complete set.\n *\n * Each entry carries `ownColumn` — the column that field itself is backed by,\n * absent for a paragraph, many-to-many or programmatic field. The collision\n * check below needs it to tell which fields have no column of their own:\n * only those can collide, since a column-backed field's name is never a row\n * key.\n *\n * `droppedColumns`/`droppedLabels` also drive `remap`: each must actively\n * remove a key from a mapped object rather than merely leave it unmapped,\n * because `remap` passes an unmapped key through unchanged — a\n * retained-but-unexposed column would otherwise reach the output under its\n * raw name. They are kept as two SEPARATE sets, not one shared set, because\n * `toLabels` reads column-keyed input and `toStorage` reads label-keyed\n * input: a label and a column can be the same text, and one shared set would\n * let dropping one strip the other's unrelated pair.\n */\nfunction buildFieldKeyMap(\n pairs: Array<{ label: string; column: string }>,\n allLabels: LabelEntry[],\n droppedColumns: Set<string>,\n droppedLabels: Set<string>\n): FieldKeyMap {\n const labelToColumn = new Map<string, string>()\n const columnToLabel = new Map<string, string>()\n for (const { label, column } of pairs) {\n labelToColumn.set(label, column)\n columnToLabel.set(column, label)\n }\n\n // A label that is also some OTHER field's column name would make toLabels\n // ambiguous: two source keys would map onto one destination key.\n //\n // Checked against EVERY label in `allLabels`, not just the ones with a\n // pair of their own. Paragraph, many-to-many and programmatic fields have\n // no column of their own, but their labels are written into the same key\n // space as storage columns before toLabels runs (routes/admin/content.ts,\n // relations.ts, programmatic/resolve.ts). A paragraph field named after\n // another field's column would overwrite that column's value on the row\n // and then be renamed onto the other field's label — serving the wrong\n // value under the wrong key.\n //\n // Deliberately run BEFORE dropped pairs are stripped from `columnToLabel`\n // below: a paragraph/junction/programmatic field's label may collide with\n // a TOMBSTONE's column (the column still physically exists on the row),\n // and that must keep throwing. Stripping first would silently pass this\n // configuration. A column-backed field's label can no longer collide at\n // all (the skip below excludes it), live or tombstoned — only a field\n // with no column of its own is ever checked.\n //\n // Not detected: two fields declaring the SAME column name. A field can now\n // declare its own `column`, so this is no longer structurally impossible —\n // but core rejects it with `DUPLICATE_COLUMN` at parse time, so it is still\n // unreachable here.\n for (const { label, ownColumn } of allLabels) {\n // Only a field with no column of its own can collide. Its NAME is written\n // into the row as a key — paragraph, junction and programmatic values land\n // under it — so a name equal to some column would be relabelled as that\n // column's field. A column-backed field's name is never a row key: its\n // value lives under its column, and toLabels maps the column, not the name.\n if (ownColumn !== undefined) continue\n const columnOwner = columnToLabel.get(label)\n if (columnOwner !== undefined && columnOwner !== label) {\n throw new Error(\n `Fatal: field key map failed to build — field label \"${label}\" collides with the ` +\n `storage column of field \"${columnOwner}\". A field label may not reuse another ` +\n `field's column name. Rename the field, then run \\`manguito validate\\` to check your schema.`\n )\n }\n }\n\n // Only now strip dropped pairs — the collision check above has already run\n // against the full map. Each map is stripped by the set for ITS key space:\n // labelToColumn is keyed by label, columnToLabel by column. One shared set\n // let a label and a column of the same text strip each other's pair.\n for (const label of labelToColumn.keys()) {\n if (droppedLabels.has(label)) labelToColumn.delete(label)\n }\n for (const column of columnToLabel.keys()) {\n if (droppedColumns.has(column)) columnToLabel.delete(column)\n }\n\n const diverges = [...labelToColumn].some(([label, column]) => label !== column)\n\n function remap(\n input: Record<string, unknown>,\n lookup: Map<string, string>,\n dropped: Set<string>,\n droppedIfUnmapped?: Set<string>\n ): Record<string, unknown> {\n // Always returns a NEW object, even when nothing diverges. Returning `input`\n // unchanged would make aliasing depend on the schema, so a bug where a\n // caller mutates a row after mapping would reproduce only on renamed\n // fields. A shallow copy per row is not worth that class of bug.\n //\n // A key that IS a live label/column is mapped first, unconditionally —\n // `lookup` itself already excludes anything dropped (the strip loops\n // above), so a mapped hit can only be a live field. Only once a key is\n // found UNMAPPED do the drop sets apply, and `toStorage` checks both of\n // them there: `dropped` (droppedLabels — a tombstone's own name, refusing\n // a write addressed to it) and `droppedIfUnmapped` (droppedColumns — a\n // raw, unexposed column supplied directly, such as a tombstone's retained\n // column). Mapping first is what lets a live label write even when its\n // text happens to equal a tombstone's column (`description`/`d2` live,\n // `x`/`description` tombstone: `description` still maps to `d2`) while an\n // unmapped raw `description` would still be refused if it were itself a\n // dropped column. `toLabels` passes no `droppedIfUnmapped`, so it is\n // unaffected: a dropped column was already stripped from `lookup` and so\n // is always \"unmapped\", and is refused by `dropped` exactly as before.\n //\n // Unknown (and undropped) keys pass through unchanged.\n const out: Record<string, unknown> = {}\n for (const key of Object.keys(input)) {\n const mapped = lookup.get(key)\n if (mapped !== undefined) {\n out[mapped] = input[key]\n continue\n }\n if (dropped.has(key) || droppedIfUnmapped?.has(key)) continue\n out[key] = input[key]\n }\n return out\n }\n\n return {\n // toStorage reads LABEL-keyed input: a live label maps and writes\n // regardless of either drop set; an unmapped key is refused if it is\n // EITHER the tombstone's own name (droppedLabels) or a raw, unexposed\n // column (droppedColumns) — see remap's comment.\n // toLabels reads COLUMN-keyed input, so it consults only droppedColumns.\n toStorage: (input) => remap(input, labelToColumn, droppedLabels, droppedColumns),\n toLabels: (row) => remap(row, columnToLabel, droppedColumns),\n columnFor: (label) => labelToColumn.get(label),\n labelFor: (column) => columnToLabel.get(column),\n labels: [...labelToColumn.keys()],\n diverges,\n }\n}\n\n/**\n * Built ONCE per content type at startup, not per request. Throws on a key\n * collision, matching how createCmsApp refuses to boot on a broken roles\n * registry: an ambiguous mapping would silently corrupt responses.\n *\n * Tombstones (`field.removed === true`) are column-backed — the parser keeps\n * their column alive for older live versions — but the CURRENT version must\n * never serve or accept them. Their pair is passed into the shared core\n * alongside the live ones (so the collision check below still sees them —\n * see `buildFieldKeyMap`'s doc comment), and then stripped from the returned\n * map via `droppedColumns`/`droppedLabels` — which also drive `remap`:\n * without that, an unmapped retained column would pass through `remap`\n * unchanged and reach the response under its raw column name.\n */\nexport function createFieldKeyMap(fields: ParsedField[]): FieldKeyMap {\n const droppedColumns = new Set<string>()\n const droppedLabels = new Set<string>()\n const pairs: Array<{ label: string; column: string }> = []\n\n for (const f of fields) {\n if (!isColumnBacked(f)) continue\n // Keyed by `f.name`/`f.db_column.column_name` for every column-backed\n // field, live or tombstoned, so buildFieldKeyMap's post-check stripping\n // can find and remove a tombstone's own entry. That leans on field names\n // being unique across live and tombstoned fields within one type — true\n // today (core rejects a duplicate name at parse time) but only\n // load-bearing here because this refactor now writes a tombstone's pair\n // into the shared map at all; the original single-function\n // implementation `continue`d past a tombstone before ever writing one,\n // so the collision was structurally impossible.\n pairs.push({ label: f.name, column: f.db_column.column_name })\n if (f.removed === true) {\n // A tombstone's NAME refuses writes addressed to it; its COLUMN keeps\n // the retained data out of reads. Two sets, so neither can collide with\n // a live field's key from the other space.\n droppedLabels.add(f.name)\n droppedColumns.add(f.db_column.column_name)\n }\n }\n\n // `ownColumn` marks every column-backed field here too — a label IS the\n // field's name on this path — so the collision check skips it exactly as\n // it does for the projection variant below; only a paragraph, many-to-many\n // or programmatic field (no `ownColumn`) is ever inspected.\n return buildFieldKeyMap(\n pairs,\n fields.map((f) => ({\n label: f.name,\n // Conditional spread, not a plain assignment — exactOptionalPropertyTypes\n // forbids present-and-undefined.\n ...(isColumnBacked(f) && { ownColumn: f.db_column.column_name }),\n })),\n droppedColumns,\n droppedLabels\n )\n}\n\n/**\n * From a version's projection. The projection is already the label↔column\n * mapping and already excludes tombstones and non-column-backed fields, so\n * there is nothing to filter — but the collision check still needs every\n * field's label, which is why `allFields` is required.\n */\nexport function createFieldKeyMapFromProjection(\n projectionType: { fields: Array<{ column_name: string; exposed_as: string; fallback?: unknown }> },\n allFields: ParsedField[]\n): FieldKeyMap {\n const pairs = projectionType.fields.map((f) => ({ label: f.exposed_as, column: f.column_name }))\n const projectedColumns = new Set(pairs.map((p) => p.column))\n const projectedLabels = new Set(pairs.map((p) => p.label))\n // A column this version does not expose is dropped from READS; a name it\n // does not expose is dropped from WRITES. Computed independently: with one\n // shared set, un-dropping a projected label could un-drop an unrelated\n // field's column of the same text — which is exactly #2.\n const droppedColumns = new Set<string>()\n const droppedLabels = new Set<string>()\n for (const f of allFields) {\n if (!isColumnBacked(f)) continue\n if (!projectedColumns.has(f.db_column.column_name)) droppedColumns.add(f.db_column.column_name)\n if (!projectedLabels.has(f.name)) droppedLabels.add(f.name)\n }\n\n return buildFieldKeyMap(\n pairs,\n allFields.map((f) => ({\n label: f.name,\n // See buildFieldKeyMap's collision check: `ownColumn` marks every\n // column-backed field so the check skips it entirely — only a\n // paragraph/junction/programmatic label (no column of its own) can\n // still collide.\n ...(isColumnBacked(f) && { ownColumn: f.db_column.column_name }),\n })),\n droppedColumns,\n droppedLabels\n )\n}\n","import crypto from 'node:crypto'\nimport { sql } from 'drizzle-orm'\nimport type { ParsedField, ParsedParagraphType, SchemaRegistry } from '@bobbykim/manguito-cms-core'\nimport { relationCardinality, type Cardinality } from '@bobbykim/manguito-cms-core'\nimport type { DrizzlePostgresInstance } from '@bobbykim/manguito-cms-db'\nimport type { MediaDelta } from './media-references.js'\n\n// ─── Relation persistence ─────────────────────────────────────────────────────\n//\n// Owns paragraph/junction/reference/media relation table SQL — both the write\n// side (persist/delete below) and the read side (descriptors + resolvers further\n// down). Each persist/delete returns the MediaDelta of media ids it gained and\n// lost (see media-references.ts), which the caller merges and reconciles once.\n// The repository's resolveRows() drives the read resolvers.\n\nfunction quoteIdent(name: string): string {\n if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`)\n return `\"${name}\"`\n}\n\n// Paragraph rows live on a child table with their own image/video/file columns\n// (e.g. a \"photo card\" block embeds an image) — these need the same reference-count\n// bookkeeping as top-level content fields, one level down.\nfunction paragraphMediaFieldNames(pType: ParsedParagraphType): Set<string> {\n return new Set(\n pType.fields\n .filter((f) => f.field_type === 'image' || f.field_type === 'video' || f.field_type === 'file')\n .map((f) => f.name)\n )\n}\n\nasync function fetchParagraphMediaIds(\n db: DrizzlePostgresInstance,\n pType: ParsedParagraphType,\n itemId: string,\n fieldName: string\n): Promise<string[]> {\n const mediaColumns = pType.fields\n .filter((f) => f.field_type === 'image' || f.field_type === 'video' || f.field_type === 'file')\n .map((f) => f.db_column!.column_name)\n if (mediaColumns.length === 0) return []\n\n const tbl = sql.raw(quoteIdent(pType.db.table_name))\n const cols = sql.join(mediaColumns.map((c) => sql.raw(quoteIdent(c))), sql`, `)\n const result = await db.execute(\n sql`SELECT ${cols} FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`\n )\n\n const ids: string[] = []\n for (const row of result.rows as Record<string, unknown>[]) {\n for (const col of mediaColumns) {\n const v = row[col]\n if (typeof v === 'string' && v !== '') ids.push(v)\n }\n }\n return ids\n}\n\n// A paragraph type's own paragraph fields — the one level of nesting ADR\n// core/0005 permits. Empty without a registry, which is how callers that have no\n// nesting to persist opt out.\nfunction nestedParagraphFields(\n pType: ParsedParagraphType,\n registry?: SchemaRegistry\n): Array<{ fieldName: string; nType: ParsedParagraphType; field: ParsedField }> {\n if (!registry) return []\n const out: Array<{ fieldName: string; nType: ParsedParagraphType; field: ParsedField }> = []\n for (const f of pType.fields) {\n if (f.db_column !== null) continue\n const comp = f.ui_component as { component: string; ref?: string }\n if (comp.component !== 'paragraph-embed' || !comp.ref) continue\n const nType = registry.paragraph_types[comp.ref]\n if (nType) out.push({ fieldName: f.name, nType, field: f })\n }\n return out\n}\n\n// Ids of the paragraph rows currently stored for one field of one parent.\nasync function fetchParagraphRowIds(\n db: DrizzlePostgresInstance,\n pType: ParsedParagraphType,\n itemId: string,\n fieldName: string\n): Promise<string[]> {\n const tbl = sql.raw(quoteIdent(pType.db.table_name))\n const result = await db.execute(\n sql`SELECT id FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`\n )\n return (result.rows as Record<string, unknown>[])\n .map((r) => r['id'])\n .filter((id): id is string => typeof id === 'string')\n}\n\n// Replaces every paragraph row for one field of one parent (delete + reinsert),\n// returning the media ids removed (old rows) and added (new rows).\n//\n// `registry` enables one level of paragraph nesting (ADR core/0005): a nested\n// paragraph field has no column on its parent's table, so without this its items\n// were silently dropped on write. Nested rows hang off the *paragraph* row's id,\n// and are torn down with it so the delete+reinsert cannot orphan them.\nexport async function persistParagraphField(\n db: DrizzlePostgresInstance,\n itemId: string,\n parentType: string,\n fieldName: string,\n pType: ParsedParagraphType,\n items: unknown[],\n registry?: SchemaRegistry\n): Promise<MediaDelta> {\n const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName)\n const nested = nestedParagraphFields(pType, registry)\n\n // Tear down nested rows before their parents disappear, collecting the media\n // they referenced so reference counts stay correct.\n if (nested.length > 0) {\n const staleIds = await fetchParagraphRowIds(db, pType, itemId, fieldName)\n for (const rowId of staleIds) {\n for (const n of nested) {\n const d = await deleteParagraphField(db, rowId, n.fieldName, n.nType, registry)\n removed.push(...d.removed)\n }\n }\n }\n\n const tbl = sql.raw(quoteIdent(pType.db.table_name))\n await db.execute(sql`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`)\n\n const mediaFieldNames = paragraphMediaFieldNames(pType)\n const added: string[] = []\n\n for (let i = 0; i < items.length; i++) {\n const pItem = items[i] as Record<string, unknown>\n const rowId = crypto.randomUUID()\n const data: Record<string, unknown> = {\n id: rowId,\n parent_id: itemId,\n parent_type: parentType,\n parent_field: fieldName,\n order: i,\n created_at: new Date(),\n updated_at: new Date(),\n }\n for (const pf of pType.fields) {\n if (!pf.db_column || pf.db_column.junction) continue\n data[pf.db_column.column_name] = pItem[pf.name] ?? null\n }\n const cols = sql.join(Object.keys(data).map((k) => sql.raw(quoteIdent(k))), sql`, `)\n const vals = sql.join(Object.values(data).map((v) => sql`${v}`), sql`, `)\n await db.execute(sql`INSERT INTO ${tbl} (${cols}) VALUES (${vals})`)\n\n for (const name of mediaFieldNames) {\n const val = pItem[name]\n if (typeof val === 'string' && val !== '') added.push(val)\n }\n\n // Persist this row's nested paragraph items against the row just inserted.\n for (const n of nested) {\n const nestedItems = paragraphItems(n.field, pItem[n.fieldName])\n const d = await persistParagraphField(\n db,\n rowId,\n pType.db.table_name,\n n.fieldName,\n n.nType,\n nestedItems,\n registry\n )\n added.push(...d.added)\n removed.push(...d.removed)\n }\n }\n\n return { added, removed }\n}\n\n// Deletes all paragraph rows for one field of one parent (used when the parent is\n// being deleted — paragraph rows have no FK/cascade back to their parent, so they'd\n// leak otherwise). Returns the media those rows referenced, all removed.\nexport async function deleteParagraphField(\n db: DrizzlePostgresInstance,\n itemId: string,\n fieldName: string,\n pType: ParsedParagraphType,\n registry?: SchemaRegistry\n): Promise<MediaDelta> {\n const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName)\n\n // Cascade to nested paragraph rows, which hang off these rows' ids.\n const nested = nestedParagraphFields(pType, registry)\n if (nested.length > 0) {\n const rowIds = await fetchParagraphRowIds(db, pType, itemId, fieldName)\n for (const rowId of rowIds) {\n for (const n of nested) {\n const d = await deleteParagraphField(db, rowId, n.fieldName, n.nType, registry)\n removed.push(...d.removed)\n }\n }\n }\n\n const tbl = sql.raw(quoteIdent(pType.db.table_name))\n await db.execute(sql`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`)\n return { added: [], removed }\n}\n\n// Replaces every junction row for one many-to-many field of one parent.\nexport async function persistJunctionField(\n db: DrizzlePostgresInstance,\n itemId: string,\n junction: { table_name: string; left_column: string; right_column: string },\n relatedIds: string[]\n): Promise<void> {\n const tbl = sql.raw(quoteIdent(junction.table_name))\n const left = sql.raw(quoteIdent(junction.left_column))\n const right = sql.raw(quoteIdent(junction.right_column))\n await db.execute(sql`DELETE FROM ${tbl} WHERE ${left} = ${itemId}`)\n for (const rid of relatedIds) {\n await db.execute(sql`INSERT INTO ${tbl} (${left}, ${right}) VALUES (${itemId}, ${rid})`)\n }\n}\n\n// ─── Relation descriptors and resolution (read side) ──────────────────────────\n//\n// Relation read resolution. These were lifted out of the repository so that all\n// paragraph/junction/reference/media table SQL lives in one module alongside the\n// write persistence above. The repository's resolveRows() calls into here.\n\nexport type ParagraphRelationDef = {\n type: 'paragraph'\n table: string\n // 'one' reads as the single row (or null); 'many' as the ordered list.\n cardinality: Cardinality\n}\n\nexport type ReferenceRelationDef = {\n type: 'reference'\n table: string\n fk_column: string\n}\n\nexport type JunctionRelationDef = {\n type: 'junction'\n table: string\n junction_table: string\n left_column: string\n right_column: string\n // Only ordered junctions have an \"order\" column (db codegen emits it only when\n // order_column is true) — the resolver must not ORDER BY it otherwise.\n order_column: boolean\n}\n\nexport type MediaRelationDef = {\n type: 'media'\n fk_column: string\n}\n\nexport type RelationDef =\n | ParagraphRelationDef\n | ReferenceRelationDef\n | JunctionRelationDef\n | MediaRelationDef\n\n// A 'one' relation reads as its single row (or null); a 'many' relation as the\n// list. Rows arrive ordered by \"order\", so a 'one' field holding legacy extra\n// rows reads its lowest-order row.\nexport function oneOrMany<T>(cardinality: Cardinality, list: T[]): T | T[] | null {\n return cardinality === 'one' ? (list[0] ?? null) : list\n}\n\n// The rows persistParagraphField stores for one paragraph field's request value.\n// A 'one' field carries an object (or null), a 'many' field an array. Callers\n// validate shape first (checkRelationInput), so any other value means \"none\".\nexport function paragraphItems(field: ParsedField, value: unknown): unknown[] {\n if (relationCardinality(field) === 'one') {\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? [value] : []\n }\n return Array.isArray(value) ? value : []\n}\n\n// Derives a content type's relations map from its parsed fields, so paragraph/\n// reference/media fields are actually resolved instead of silently dropped.\n// Junction and foreign_key info is already on each field's db_column; paragraph\n// fields store their target table on the paragraph type, hence the registry.\nexport function buildRelationsMap(\n fields: ParsedField[],\n registry: SchemaRegistry\n): Record<string, RelationDef> {\n const relations: Record<string, RelationDef> = {}\n\n for (const field of fields) {\n if (field.field_type === 'image' || field.field_type === 'video' || field.field_type === 'file') {\n if (!field.db_column) continue\n relations[field.name] = { type: 'media', fk_column: field.db_column.column_name }\n } else if (field.field_type === 'paragraph') {\n const ref = (field.ui_component as { ref?: string }).ref\n const pType = ref ? registry.paragraph_types[ref] : undefined\n if (!pType) continue\n relations[field.name] = {\n type: 'paragraph',\n table: pType.db.table_name,\n cardinality: relationCardinality(field) ?? 'many',\n }\n } else if (field.field_type === 'reference') {\n if (!field.db_column) continue\n if (field.db_column.junction) {\n const j = field.db_column.junction\n relations[field.name] = {\n type: 'junction',\n table: j.right_table,\n junction_table: j.table_name,\n left_column: j.left_column,\n right_column: j.right_column,\n order_column: j.order_column,\n }\n } else if (field.db_column.foreign_key) {\n relations[field.name] = {\n type: 'reference',\n table: field.db_column.foreign_key.table,\n fk_column: field.db_column.column_name,\n }\n }\n }\n }\n\n return relations\n}\n\nfunction groupBy<T extends Record<string, unknown>>(\n items: T[],\n key: string\n): Record<string, T[]> {\n const result: Record<string, T[]> = {}\n for (const item of items) {\n const k = String(item[key])\n if (!result[k]) result[k] = []\n result[k]!.push(item)\n }\n return result\n}\n\n// A reference/media pass resolves the FK IN PLACE: the resolved object replaces\n// the raw id under the same storage column. Nothing is deleted — a field whose\n// label differs from its storage column still keeps that column name; toLabels\n// applies the label downstream, after resolution.\n//\n// The same row object really does recur: reference/junction targets are cached by\n// `table:id`, so two parents can receive ONE shared object, and the GraphQL\n// dataloaders deliberately do not memoize by parent identity (\"rows are mutated and\n// may recur across nesting levels\", dataloaders.ts). So a row that already holds a\n// resolved object under the column is skipped, which makes the pass idempotent —\n// see needsFkResolution below.\nfunction needsFkResolution(row: Record<string, unknown>, fkColumn: string): boolean {\n // Resolved IN PLACE: the object sits under the column where the raw id was,\n // so an object (never a bare id) means this row is already done. A null\n // stays null on a second pass, which costs nothing — an empty FK list issues\n // no query.\n const current = row[fkColumn]\n return !(typeof current === 'object' && current !== null)\n}\n\n// Fully resolves a relation field on a batch of rows: paragraph → ordered rows,\n// reference → the target row, junction → target rows, media → the media row.\n// The cache (keyed table:id) dedupes target lookups within a request.\n//\n// `publishedOnly` (default false, preserving prior behavior for admin callers)\n// adds `AND published = true` to the reference/junction target-row SELECTs.\n// Reference and junction targets are always content or taxonomy tables, which\n// always carry a `published` boolean system field — see CONTENT_SYSTEM_FIELDS /\n// TAXONOMY_SYSTEM_FIELDS in packages/core/src/parser/parseSchema.ts. Paragraph\n// and media targets are deliberately excluded: paragraph rows are owned by\n// their parent (no independent publish state) and media rows have no\n// `published` column at all.\nexport async function resolveRelationField(\n db: DrizzlePostgresInstance,\n rows: Record<string, unknown>[],\n fieldName: string,\n rel: RelationDef,\n cache: Map<string, unknown>,\n publishedOnly = false\n): Promise<void> {\n if (rows.length === 0) return\n\n // Deduped by object identity: one batch can hold the same row object twice\n // (see needsFkResolution above). Resolving it twice would be harmless —\n // resolution is idempotent — but redundant, so this dedupe just avoids that\n // repeat work. The caller's array is left untouched — the GraphQL dataloader\n // maps over it to build its results.\n const batch = [...new Set(rows)]\n\n if (rel.type === 'paragraph') {\n const parentIds = batch.map((r) => r['id'] as string)\n const inList = sql.join(parentIds.map((id) => sql`${id}`), sql`, `)\n // Scoped by parent_field as well as parent_id: two paragraph fields of the\n // same paragraph type share one table, so filtering on the parent alone\n // hands each field the other's rows.\n const result = await db.execute(\n sql`SELECT * FROM ${sql.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) AND parent_field = ${fieldName} ORDER BY \"order\" ASC`\n )\n const byParent = groupBy(result.rows as Record<string, unknown>[], 'parent_id')\n for (const row of batch) {\n row[fieldName] = oneOrMany(rel.cardinality, byParent[row['id'] as string] ?? [])\n }\n } else if (rel.type === 'reference') {\n // Resolved IN PLACE, under the storage column. The column is a field's\n // internal identity, and toLabels applies whichever label the response\n // speaks. Writing under the field's name instead moved the value out of the\n // column, so any version labelling that column differently lost it.\n const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column))\n if (pending.length === 0) return\n\n const fkValues = pending.map((r) => r[rel.fk_column] as string).filter(Boolean)\n if (fkValues.length === 0) {\n for (const row of pending) row[rel.fk_column] = null\n return\n }\n const unique = [...new Set(fkValues)]\n const uncached = unique.filter((id) => !cache.has(`${rel.table}:${id}`))\n if (uncached.length > 0) {\n const inList = sql.join(uncached.map((id) => sql`${id}`), sql`, `)\n const publishedCond = publishedOnly ? sql` AND published = true` : sql``\n const result = await db.execute(\n sql`SELECT * FROM ${sql.raw(quoteIdent(rel.table))} WHERE id IN (${inList})${publishedCond}`\n )\n for (const item of result.rows as Record<string, unknown>[]) {\n cache.set(`${rel.table}:${item['id']}`, item)\n }\n }\n for (const row of pending) {\n const fkVal = row[rel.fk_column] as string\n row[rel.fk_column] = fkVal ? (cache.get(`${rel.table}:${fkVal}`) ?? null) : null\n }\n } else if (rel.type === 'junction') {\n const parentIds = batch.map((r) => r['id'] as string)\n const inList = sql.join(parentIds.map((id) => sql`${id}`), sql`, `)\n const orderBy = rel.order_column ? sql` ORDER BY \"order\" ASC` : sql``\n const junctionResult = await db.execute(\n sql`SELECT * FROM ${sql.raw(quoteIdent(rel.junction_table))} WHERE ${sql.raw(quoteIdent(rel.left_column))} IN (${inList})${orderBy}`\n )\n const jRows = junctionResult.rows as Record<string, unknown>[]\n const rightIds = [...new Set(jRows.map((r) => r[rel.right_column] as string))]\n const uncached = rightIds.filter((id) => !cache.has(`${rel.table}:${id}`))\n if (uncached.length > 0) {\n const rightInList = sql.join(uncached.map((id) => sql`${id}`), sql`, `)\n const publishedCond = publishedOnly ? sql` AND published = true` : sql``\n const entitiesResult = await db.execute(\n sql`SELECT * FROM ${sql.raw(quoteIdent(rel.table))} WHERE id IN (${rightInList})${publishedCond}`\n )\n for (const item of entitiesResult.rows as Record<string, unknown>[]) {\n cache.set(`${rel.table}:${item['id']}`, item)\n }\n }\n const byLeft = groupBy(jRows, rel.left_column)\n for (const row of batch) {\n const jEntries = byLeft[row['id'] as string] ?? []\n row[fieldName] = jEntries\n .map((jr) => cache.get(`${rel.table}:${jr[rel.right_column] as string}`))\n .filter(Boolean)\n }\n } else if (rel.type === 'media') {\n // Resolved IN PLACE, under the storage column — see the reference branch's\n // comment above.\n const pending = batch.filter((r) => needsFkResolution(r, rel.fk_column))\n if (pending.length === 0) return\n\n const fkValues = pending.map((r) => r[rel.fk_column] as string).filter(Boolean)\n if (fkValues.length === 0) {\n for (const row of pending) row[rel.fk_column] = null\n return\n }\n const unique = [...new Set(fkValues)]\n const uncached = unique.filter((id) => !cache.has(`media:${id}`))\n if (uncached.length > 0) {\n const inList = sql.join(uncached.map((id) => sql`${id}`), sql`, `)\n const result = await db.execute(sql`SELECT * FROM \"media\" WHERE id IN (${inList})`)\n for (const item of result.rows as Record<string, unknown>[]) {\n cache.set(`media:${item['id']}`, item)\n }\n }\n for (const row of pending) {\n const fkVal = row[rel.fk_column] as string\n row[rel.fk_column] = fkVal ? (cache.get(`media:${fkVal}`) ?? null) : null\n }\n }\n}\n\n// Populates paragraph/junction fields with bare ID arrays when they are not\n// explicitly included — they have no column on the owning table, so without this\n// they'd be silently absent. Reference fields already carry their bare ID as a\n// real column from `SELECT *`, so they need no extra query.\nexport async function resolveRelationBareIds(\n db: DrizzlePostgresInstance,\n rows: Record<string, unknown>[],\n fieldName: string,\n rel: RelationDef\n): Promise<void> {\n if (rows.length === 0) return\n\n if (rel.type === 'paragraph') {\n const parentIds = rows.map((r) => r['id'] as string)\n const inList = sql.join(parentIds.map((id) => sql`${id}`), sql`, `)\n const result = await db.execute(\n sql`SELECT id, parent_id FROM ${sql.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) AND parent_field = ${fieldName} ORDER BY \"order\" ASC`\n )\n const byParent = groupBy(result.rows as Record<string, unknown>[], 'parent_id')\n for (const row of rows) {\n row[fieldName] = oneOrMany(rel.cardinality, (byParent[row['id'] as string] ?? []).map((r) => r['id']))\n }\n } else if (rel.type === 'junction') {\n const parentIds = rows.map((r) => r['id'] as string)\n const inList = sql.join(parentIds.map((id) => sql`${id}`), sql`, `)\n const result = await db.execute(\n sql`SELECT ${sql.raw(quoteIdent(rel.left_column))} AS left_id, ${sql.raw(quoteIdent(rel.right_column))} AS right_id FROM ${sql.raw(quoteIdent(rel.junction_table))} WHERE ${sql.raw(quoteIdent(rel.left_column))} IN (${inList})`\n )\n const byLeft = groupBy(result.rows as Record<string, unknown>[], 'left_id')\n for (const row of rows) {\n row[fieldName] = (byLeft[row['id'] as string] ?? []).map((r) => r['right_id'])\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -2,7 +2,7 @@ import {
2
2
  buildRelationsMap,
3
3
  isColumnBacked,
4
4
  resolveRelationField
5
- } from "./chunk-JVP33FI2.mjs";
5
+ } from "./chunk-IHEIX6P4.mjs";
6
6
 
7
7
  // src/graphql/handler.ts
8
8
  import { createYoga, maskError } from "graphql-yoga";
@@ -19,6 +19,7 @@ import {
19
19
  GraphQLInt as GraphQLInt3,
20
20
  GraphQLBoolean as GraphQLBoolean3
21
21
  } from "graphql";
22
+ import { relationCardinality } from "@bobbykim/manguito-cms-core";
22
23
 
23
24
  // src/graphql/scalars.ts
24
25
  import { GraphQLScalarType, Kind, valueFromASTUntyped } from "graphql";
@@ -252,29 +253,22 @@ function scalarFieldResolver(field, fallback) {
252
253
  function relationFieldResolver(typeName, schemaFieldName) {
253
254
  return (parent, _args, ctx) => ctx.loaders.load(typeName, schemaFieldName, parent);
254
255
  }
255
- function programmaticFieldResolver(typeName, schemaFieldName, mediaFields = [], fieldKeys) {
256
+ function programmaticFieldResolver(typeName, schemaFieldName, mediaFieldNames = [], fieldKeys) {
256
257
  return async (parent, _args, ctx) => {
257
258
  let p = ctx.programmaticMemo.get(parent);
258
259
  if (!p) {
259
- p = resolveProgrammaticRow(typeName, parent, ctx, mediaFields, fieldKeys);
260
+ p = resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames, fieldKeys);
260
261
  ctx.programmaticMemo.set(parent, p);
261
262
  }
262
263
  return (await p)[schemaFieldName];
263
264
  };
264
265
  }
265
- async function resolveProgrammaticRow(typeName, parent, ctx, mediaFields, fieldKeys) {
266
+ async function resolveProgrammaticRow(typeName, parent, ctx, mediaFieldNames, fieldKeys) {
266
267
  const toLabels = (row) => fieldKeys ? fieldKeys.toLabels(row) : row;
267
- if (mediaFields.length === 0) return ctx.resolver.resolveItem(typeName, toLabels(parent));
268
+ if (mediaFieldNames.length === 0) return ctx.resolver.resolveItem(typeName, toLabels(parent));
268
269
  const enriched = { ...parent };
269
- await Promise.all(mediaFields.map((m) => ctx.loaders.load(typeName, m.name, enriched)));
270
- return ctx.resolver.resolveItem(typeName, relabelMedia(toLabels(enriched), enriched, mediaFields));
271
- }
272
- function relabelMedia(record, enriched, mediaFields) {
273
- for (const { name, exposedAs } of mediaFields) {
274
- if (!(name in enriched)) continue;
275
- record[exposedAs] = enriched[name];
276
- }
277
- return record;
270
+ await Promise.all(mediaFieldNames.map((name) => ctx.loaders.load(typeName, name, enriched)));
271
+ return ctx.resolver.resolveItem(typeName, toLabels(enriched));
278
272
  }
279
273
  function collectionResolver(typeName, nameMap, fieldKeys) {
280
274
  return async (_root, args, ctx) => {
@@ -348,7 +342,7 @@ var MEDIA = new GraphQLObjectType({
348
342
  duration: { type: GraphQLInt3, resolve: (m) => m.duration }
349
343
  }
350
344
  });
351
- function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
345
+ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view, programmaticKeyMaps = fieldKeyMaps) {
352
346
  const objectTypes = /* @__PURE__ */ new Map();
353
347
  const enumTypes = /* @__PURE__ */ new Map();
354
348
  for (const [name, enumType] of Object.entries(registry.enum_types)) {
@@ -388,7 +382,8 @@ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
388
382
  `
389
383
  );
390
384
  }
391
- return new GraphQLList2(new GraphQLNonNull2(target ?? MEDIA));
385
+ const t = target ?? MEDIA;
386
+ return relationCardinality(field) === "one" ? t : new GraphQLList2(new GraphQLNonNull2(t));
392
387
  }
393
388
  if (field.field_type === "reference") {
394
389
  const ref = field.ui_component.component === "typeahead-select" ? field.ui_component.ref : void 0;
@@ -397,8 +392,7 @@ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
397
392
  process.stderr.write(`\u26A0 reference field '${field.name}' targets unknown type '${ref}'; exposing as Media
398
393
  `);
399
394
  }
400
- const rel = field.ui_component.component === "typeahead-select" ? field.ui_component.rel : void 0;
401
- const isMany = rel === "many-to-many" || rel === "one-to-many";
395
+ const isMany = relationCardinality(field) === "many";
402
396
  const t = target ?? MEDIA;
403
397
  return isMany ? new GraphQLList2(new GraphQLNonNull2(t)) : t;
404
398
  }
@@ -412,9 +406,9 @@ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
412
406
  }
413
407
  function buildObjectType(machineName, type) {
414
408
  const visible = viewFieldsFor(machineName, type);
415
- const mediaFields = visible.filter(
416
- (v) => v.field.field_type === "image" || v.field.field_type === "video" || v.field.field_type === "file"
417
- ).map((v) => ({ name: v.field.name, exposedAs: v.exposedAs }));
409
+ const mediaFieldNames = type.fields.filter(
410
+ (f) => f.removed !== true && (f.field_type === "image" || f.field_type === "video" || f.field_type === "file")
411
+ ).map((f) => f.name);
418
412
  return new GraphQLObjectType({
419
413
  name: graphqlTypeName(machineName),
420
414
  fields: () => {
@@ -434,7 +428,7 @@ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
434
428
  const outType = outputTypeForField(field, vf.required);
435
429
  let resolve;
436
430
  if (field.field_type === "programmatic") {
437
- resolve = programmaticFieldResolver(machineName, field.name, mediaFields, fieldKeyMaps[machineName]);
431
+ resolve = programmaticFieldResolver(machineName, field.name, mediaFieldNames, programmaticKeyMaps[machineName]);
438
432
  } else if (field.field_type === "reference" || field.field_type === "paragraph" || field.field_type === "image" || field.field_type === "video" || field.field_type === "file") {
439
433
  resolve = relationFieldResolver(machineName, field.name);
440
434
  } else {
@@ -479,7 +473,10 @@ function buildGraphQLSchema(registry, fieldKeyMaps = {}, view) {
479
473
  for (const [name, ct] of Object.entries(registry.content_types)) {
480
474
  const objType = objectTypes.get(name);
481
475
  const visible = viewFieldsFor(name, ct);
482
- const nameMap = buildFieldNameMap(visible.map((v) => v.exposedAs));
476
+ const nameMap = buildFieldNameMap([
477
+ ...visible.map((v) => v.exposedAs),
478
+ ...ct.system_fields.map((s) => s.name)
479
+ ]);
483
480
  if (ct.only_one) {
484
481
  queryFields[singleQueryName(name)] = { type: objType, resolve: singletonResolver(name) };
485
482
  continue;
@@ -625,7 +622,8 @@ function createRelationLoaders(db, registry) {
625
622
  async (parents) => {
626
623
  const rows = parents;
627
624
  await resolveRelationField(db, rows, fieldName, rel, cache, true);
628
- return rows.map((r) => r[fieldName]);
625
+ const resultKey = rel.type === "reference" || rel.type === "media" ? rel.fk_column : fieldName;
626
+ return rows.map((r) => r[resultKey]);
629
627
  },
630
628
  // Batch within a tick; do not memoize by parent identity (rows are mutated
631
629
  // and may recur across nesting levels).
@@ -692,7 +690,7 @@ function createGraphQLHandler(registry, repos, fieldKeyMaps, resolver, db, optio
692
690
  currentVersion: versioning.currentVersion
693
691
  });
694
692
  }
695
- const schema = buildGraphQLSchema(registry, fieldKeyMaps, view);
693
+ const schema = buildGraphQLSchema(registry, fieldKeyMaps, view, versioning.currentFieldKeyMaps ?? fieldKeyMaps);
696
694
  const { plugins } = buildArmorPlugin({
697
695
  maxDepth: options.maxDepth,
698
696
  maxComplexity: options.maxComplexity
@@ -725,4 +723,4 @@ function createGraphQLHandler(registry, repos, fieldKeyMaps, resolver, db, optio
725
723
  export {
726
724
  createGraphQLHandler
727
725
  };
728
- //# sourceMappingURL=chunk-XNXM4W4K.mjs.map
726
+ //# sourceMappingURL=chunk-Y5SWT7PO.mjs.map