@rebasepro/server-postgres 0.9.1-canary.d198c11 → 0.9.1-canary.d906fb7

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.es.js CHANGED
@@ -1808,12 +1808,17 @@ function parseIdValues(idValue, primaryKeys) {
1808
1808
  /**
1809
1809
  * The primary keys of a collection, as declared by its properties.
1810
1810
  *
1811
- * The driver can also infer keys from the Drizzle schema, which the browser
1812
- * cannot see so the server normalizes what it resolved onto the config it
1813
- * serves (see `stampPrimaryKeys`), and this reads that back. Returns an empty
1814
- * array when a collection declares none, which callers must treat as "not
1815
- * addressable" rather than defaulting to `id`: guessing a key that is not the
1816
- * real one produces confidently wrong addresses.
1811
+ * This is the only tier both sides can read, because it is the only one written
1812
+ * in the config: the postgres driver can also infer keys from the Drizzle
1813
+ * schema, which the browser never sees and is never sent the admin compiles
1814
+ * the collection files into its own bundle rather than being served them. A key
1815
+ * that lives only in the Drizzle schema is therefore invisible here, and the
1816
+ * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1817
+ * to add.
1818
+ *
1819
+ * Returns an empty array when a collection declares none, which callers must
1820
+ * treat as "not addressable" rather than defaulting to `id`: guessing a key
1821
+ * that is not the real one produces confidently wrong addresses.
1817
1822
  */
1818
1823
  function getDeclaredPrimaryKeys(collection) {
1819
1824
  const properties = collection.properties;
@@ -1838,9 +1843,15 @@ function getDeclaredPrimaryKeys(collection) {
1838
1843
  * The postgres driver tries, in order: properties marked `isId`; the primary
1839
1844
  * keys of the Drizzle schema; and finally a column literally named `id`. Only
1840
1845
  * the first and last are visible in a `CollectionConfig`, which is what both
1841
- * sides share — so a collection whose key is *only* known to Drizzle, and is
1842
- * not named `id`, resolves to nothing here. That is reported rather than
1843
- * guessed: inventing a key produces addresses that look right and route wrong.
1846
+ * sides share.
1847
+ *
1848
+ * So the two agree except on a collection that declares no `isId` and whose key
1849
+ * is known only to Drizzle. There, the driver reads the real key, and this
1850
+ * either resolves nothing (reported to the console by the caller) or — if the
1851
+ * table happens to have an unrelated `id` property — resolves `id`, which is
1852
+ * the wrong key and cannot be detected from here: the addresses look right and
1853
+ * route wrong. Only the config can settle it, so the server names both cases
1854
+ * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1844
1855
  */
1845
1856
  function resolvePrimaryKeys(collection) {
1846
1857
  const declared = getDeclaredPrimaryKeys(collection);
@@ -4095,7 +4106,7 @@ function createPrimaryKeyResolver(options) {
4095
4106
  }
4096
4107
  if (!warned.has(slug)) {
4097
4108
  warned.add(slug);
4098
- console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config.`);
4109
+ console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
4099
4110
  }
4100
4111
  return keys;
4101
4112
  };
@@ -4538,8 +4549,26 @@ function getTableForCollection(collection, registry) {
4538
4549
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
4539
4550
  return table;
4540
4551
  }
4552
+ /**
4553
+ * The key columns a collection's rows are addressed by.
4554
+ *
4555
+ * Three tiers, in order: properties marked `isId`, the primary keys of the
4556
+ * drizzle schema, and finally a column literally named `id`. Only the first is
4557
+ * visible to the browser, which is why a key known only to drizzle is reported
4558
+ * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
4559
+ *
4560
+ * Returns `[]` when nothing resolves, rather than throwing. It used to open by
4561
+ * resolving the table, which throws when there is none — so the `isId` tier,
4562
+ * which needs no table at all, was unreachable for exactly the collections
4563
+ * most likely to have no table registered. Every caller that wanted "no keys"
4564
+ * to mean "no keys" had to spell that out in a try/catch.
4565
+ *
4566
+ * Callers that cannot proceed without a key must say so themselves, naming the
4567
+ * collection: an empty array here means "this collection has no address", which
4568
+ * is a different answer in a notification (broadcast a wildcard) than in a save
4569
+ * (fail).
4570
+ */
4541
4571
  function getPrimaryKeys(collection, registry) {
4542
- const table = getTableForCollection(collection, registry);
4543
4572
  if (collection.properties) {
4544
4573
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
4545
4574
  fieldName: key,
@@ -4548,6 +4577,8 @@ function getPrimaryKeys(collection, registry) {
4548
4577
  }));
4549
4578
  if (idProps.length > 0) return idProps;
4550
4579
  }
4580
+ const table = registry.getTable(getTableName$1(collection));
4581
+ if (!table) return [];
4551
4582
  const keys = [];
4552
4583
  for (const [key, colRaw] of Object.entries(table)) {
4553
4584
  const col = colRaw;
@@ -4575,6 +4606,90 @@ function getPrimaryKeys(collection, registry) {
4575
4606
  }
4576
4607
  return keys;
4577
4608
  }
4609
+ /**
4610
+ * The key columns, for callers that cannot do their job without one.
4611
+ *
4612
+ * {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
4613
+ * collection with no address. Most of this driver, though, is building a WHERE
4614
+ * clause and has no meaning without a key — for those, an empty array is not an
4615
+ * answer, and indexing `[0]` into it produces `Cannot read properties of
4616
+ * undefined` three frames from where the real problem is. This says what is
4617
+ * wrong and which collection it is wrong about.
4618
+ */
4619
+ function requirePrimaryKeys(collection, registry) {
4620
+ const keys = getPrimaryKeys(collection, registry);
4621
+ if (keys.length === 0) throw new Error(`Collection '${collection.slug}' has no primary key, so its rows cannot be addressed. Mark the key property with \`isId\` in its config, or register a table whose schema declares one.`);
4622
+ return keys;
4623
+ }
4624
+ /**
4625
+ * Collections whose key the *browser* cannot resolve, and what it will do
4626
+ * instead.
4627
+ *
4628
+ * The two sides resolve keys from different evidence. This driver reads, in
4629
+ * order: properties marked `isId`, the primary keys of the Drizzle schema, then
4630
+ * a column literally named `id`. The admin shares the `CollectionConfig` — it
4631
+ * compiles the same collection files into its bundle — but never the Drizzle
4632
+ * schema, so the middle tier is invisible to it.
4633
+ *
4634
+ * Nothing can normalize this at runtime: the server does not serve the admin
4635
+ * its collections, so a key resolved here cannot be handed over there. The
4636
+ * config files are the only thing both sides read, so the fix is an edit to
4637
+ * them, and the most this can do is say exactly which edit.
4638
+ *
4639
+ * Two shapes, and the second is the dangerous one:
4640
+ *
4641
+ * - No `isId`, no `id` property → the admin resolves no address, warns in the
4642
+ * console, and rows cannot be opened or linked.
4643
+ * - No `isId`, but an `id` property that is *not* the key → the admin addresses
4644
+ * rows by `id` while this driver reads the address as the real key. Nothing
4645
+ * errors: the addresses look right and route wrong.
4646
+ */
4647
+ function findUnresolvableKeyCollections(collections, registry) {
4648
+ const findings = [];
4649
+ for (const collection of collections) {
4650
+ if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4651
+ const keys = getPrimaryKeys(collection, registry);
4652
+ if (keys.length === 0) continue;
4653
+ if (keys.length === 1 && keys[0].fieldName === "id") continue;
4654
+ findings.push({
4655
+ collection,
4656
+ keys,
4657
+ shadowedByIdProperty: Boolean(collection.properties?.id)
4658
+ });
4659
+ }
4660
+ return findings;
4661
+ }
4662
+ /**
4663
+ * Report the collections from {@link findUnresolvableKeyCollections} at boot,
4664
+ * with the edit that fixes each one.
4665
+ *
4666
+ * Grouped by failure, not by collection: the shadowed case is a routing bug and
4667
+ * the silent case is a missing feature, and they deserve different urgency.
4668
+ */
4669
+ function warnOnKeysTheAdminCannotResolve(collections, registry) {
4670
+ const findings = findUnresolvableKeyCollections(collections, registry);
4671
+ if (findings.length === 0) return;
4672
+ const edit = (f) => `${f.collection.slug}: mark ${f.keys.map((k) => `\`${k.fieldName}\``).join(" and ")} with \`isId: ${f.keys[0].isUUID ? "\"uuid\"" : f.keys[0].type === "number" ? "\"increment\"" : "true"}\``;
4673
+ const shadowed = findings.filter((f) => f.shadowedByIdProperty);
4674
+ const silent = findings.filter((f) => !f.shadowedByIdProperty);
4675
+ if (shadowed.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema — but they do have a property called `id`. The admin has no way to know `id` is not the key, so it will address rows by it while this server reads the address as the real key: the links look right and route wrong. Nothing will error.\n\n" + shadowed.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4676
+ if (silent.length > 0) logger.warn("⚠️ These collections declare no `isId`, and their key is only in the drizzle schema, which the admin never sees. It will resolve no address for their rows, so detail links, caching and relations will not work for them:\n\n" + silent.map((f) => ` • ${edit(f)}`).join("\n") + "\n");
4677
+ }
4678
+ /**
4679
+ * The address of a row: derived from the collection's primary keys, because a
4680
+ * row does not carry one — it is exactly its columns.
4681
+ *
4682
+ * Falls back to a literal `id` column, for a row that reached us from somewhere
4683
+ * other than this driver. Returns `""` when there is no key and no `id` —
4684
+ * callers decide what that means, since "unaddressable" is a different answer
4685
+ * in a notification (broadcast a wildcard) than in a save (fail).
4686
+ */
4687
+ function deriveRowAddress(row, collection, registry) {
4688
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4689
+ if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4690
+ if (row.id !== void 0 && row.id !== null) return String(row.id);
4691
+ return "";
4692
+ }
4578
4693
  //#endregion
4579
4694
  //#region src/utils/drizzle-conditions.ts
4580
4695
  /**
@@ -5113,6 +5228,7 @@ var DrizzleConditionBuilder = class {
5113
5228
  static buildVectorSearchConditions(table, vectorSearch) {
5114
5229
  const column = table[vectorSearch.property];
5115
5230
  if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
5231
+ if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
5116
5232
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5117
5233
  const distanceFn = vectorSearch.distance || "cosine";
5118
5234
  let operator;
@@ -5196,12 +5312,10 @@ function serializeDataToServer(row, properties, collection, registry) {
5196
5312
  continue;
5197
5313
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5198
5314
  const serializedValue = serializePropertyToServer(effectiveValue, property);
5199
- const pks = getPrimaryKeys(collection, registry);
5200
5315
  inverseRelationUpdates.push({
5201
5316
  relationKey: key,
5202
5317
  relation,
5203
- newValue: serializedValue,
5204
- currentId: row.id || buildCompositeId(row, pks)
5318
+ newValue: serializedValue
5205
5319
  });
5206
5320
  continue;
5207
5321
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -5211,15 +5325,11 @@ function serializeDataToServer(row, properties, collection, registry) {
5211
5325
  relation,
5212
5326
  newTargetId: serializedValue
5213
5327
  });
5214
- else {
5215
- const pks = getPrimaryKeys(collection, registry);
5216
- inverseRelationUpdates.push({
5217
- relationKey: key,
5218
- relation,
5219
- newValue: serializedValue,
5220
- currentId: row.id || buildCompositeId(row, pks)
5221
- });
5222
- }
5328
+ else inverseRelationUpdates.push({
5329
+ relationKey: key,
5330
+ relation,
5331
+ newValue: serializedValue
5332
+ });
5223
5333
  continue;
5224
5334
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
5225
5335
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5619,6 +5729,63 @@ var RelationService = class {
5619
5729
  this.registry = registry;
5620
5730
  }
5621
5731
  /**
5732
+ * One target row, as the {@link RelatedRow} everything here returns.
5733
+ *
5734
+ * Eight sites built this by hand, which is how the address came to be the
5735
+ * target's first key column in all eight — one edit, eight places to miss.
5736
+ *
5737
+ * `resolveNested` is the one thing they did not agree on, and the
5738
+ * disagreement was invisible: the single-parent fetches pass `db` and
5739
+ * `registry` to `parseDataFromServer`, so the target's *own* relations get
5740
+ * resolved too, while the batch paths deliberately do not — a query per
5741
+ * target row is the N+1 the batching exists to avoid. Naming the parameter
5742
+ * makes that a decision rather than a difference between two call sites
5743
+ * nobody was comparing.
5744
+ */
5745
+ async toRelatedRow(targetRow, targetCollection, targetPks, options) {
5746
+ const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
5747
+ return {
5748
+ id: buildCompositeId(targetRow, targetPks),
5749
+ path: targetCollection.slug,
5750
+ values
5751
+ };
5752
+ }
5753
+ /**
5754
+ * A WHERE matching any of `parentIds`, by the whole key.
5755
+ *
5756
+ * A single key is an `IN (…)`. A composite one cannot be: matching
5757
+ * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
5758
+ * share their first column each receive the other's relations. It becomes
5759
+ * an OR of ANDs — one exact address per parent — which Postgres indexes the
5760
+ * same way it would a multi-column key lookup.
5761
+ */
5762
+ parentKeyCondition(parentTable, parentPks, parentIds) {
5763
+ const columnFor = (fieldName) => {
5764
+ const col = parentTable[fieldName];
5765
+ if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
5766
+ return col;
5767
+ };
5768
+ if (parentPks.length === 1) {
5769
+ const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
5770
+ return inArray(columnFor(parentPks[0].fieldName), values);
5771
+ }
5772
+ return or(...parentIds.map((id) => {
5773
+ const values = parseIdValues(id, parentPks);
5774
+ return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
5775
+ }));
5776
+ }
5777
+ /**
5778
+ * Reject a relation that cannot express a composite-keyed parent.
5779
+ *
5780
+ * `localKey` and `foreignKeyOnTarget` are single column names: one column
5781
+ * cannot reference a two-column key, so such a relation has no correct
5782
+ * reading. Left alone it would silently match on the first key column and
5783
+ * hand a tenant's rows to its neighbour — say so instead.
5784
+ */
5785
+ assertSingleKeyAddressable(parentCollection, parentPks, via) {
5786
+ if (parentPks.length > 1) throw new Error(`Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but '${parentCollection.slug}' is keyed on ${parentPks.map((k) => `'${k.fieldName}'`).join(" + ")}. One column cannot reference a composite key — express this relation with \`joinPath\`, whose \`on.from\`/\`on.to\` take every key column.`);
5787
+ }
5788
+ /**
5622
5789
  * Fetch rows related to a parent row through a specific relation
5623
5790
  */
5624
5791
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5637,9 +5804,9 @@ var RelationService = class {
5637
5804
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5638
5805
  const targetCollection = relation.target();
5639
5806
  const targetTable = getTableForCollection(targetCollection, this.registry);
5640
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
5807
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5641
5808
  const idField = targetTable[idInfo[0].fieldName];
5642
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5809
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5643
5810
  const parentIdInfo = parentPks[0];
5644
5811
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5645
5812
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5663,7 +5830,7 @@ var RelationService = class {
5663
5830
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5664
5831
  currentTable = joinTable;
5665
5832
  }
5666
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5833
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5667
5834
  query = query.where(eq(parentIdField, parsedParentId));
5668
5835
  if (options.limit) query = query.limit(options.limit);
5669
5836
  const results = await query;
@@ -5671,13 +5838,7 @@ var RelationService = class {
5671
5838
  const rows = [];
5672
5839
  for (const row of results) {
5673
5840
  const targetRow = row[targetTableName] || row;
5674
- const id = targetRow[idInfo[0].fieldName];
5675
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5676
- rows.push({
5677
- id: id?.toString() || "",
5678
- path: targetCollection.slug,
5679
- values: parsedValues
5680
- });
5841
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5681
5842
  }
5682
5843
  return rows;
5683
5844
  }
@@ -5696,13 +5857,7 @@ var RelationService = class {
5696
5857
  const rows = [];
5697
5858
  for (const row of results) {
5698
5859
  const targetRow = row[getTableName$1(targetCollection)] || row;
5699
- const id = targetRow[idInfo[0].fieldName];
5700
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5701
- rows.push({
5702
- id: id?.toString() || "",
5703
- path: targetCollection.slug,
5704
- values: parsedValues
5705
- });
5860
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5706
5861
  }
5707
5862
  return rows;
5708
5863
  }
@@ -5719,8 +5874,8 @@ var RelationService = class {
5719
5874
  }
5720
5875
  const targetCollection = relation.target();
5721
5876
  const targetTable = getTableForCollection(targetCollection, this.registry);
5722
- const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5723
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5877
+ const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5878
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5724
5879
  const parentIdInfo = parentPks[0];
5725
5880
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5726
5881
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5739,9 +5894,10 @@ var RelationService = class {
5739
5894
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5740
5895
  const targetCollection = relation.target();
5741
5896
  const targetTable = getTableForCollection(targetCollection, this.registry);
5742
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5897
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5898
+ const targetIdInfo = targetPks[0];
5743
5899
  const targetIdField = targetTable[targetIdInfo.fieldName];
5744
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5900
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5745
5901
  const parentIdInfo = parentPks[0];
5746
5902
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5747
5903
  if (!parentTable) throw new Error("Parent table not found");
@@ -5765,25 +5921,19 @@ var RelationService = class {
5765
5921
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5766
5922
  currentTable = joinTable;
5767
5923
  }
5768
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5769
- query = query.where(inArray(parentIdField, parsedParentIds));
5924
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5770
5925
  const results = await query;
5771
5926
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5772
5927
  const resultMap = /* @__PURE__ */ new Map();
5773
5928
  for (const row of results) {
5774
5929
  const parentRow = row[getTableName$1(parentCollection)] || row;
5775
5930
  const targetRow = row[targetTableName] || row;
5776
- const parentId = parentRow[parentIdInfo.fieldName];
5777
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5778
- resultMap.set(String(parentId), {
5779
- id: String(targetRow[targetIdInfo.fieldName]),
5780
- path: targetCollection.slug,
5781
- values: parsedValues
5782
- });
5931
+ resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5783
5932
  }
5784
5933
  return resultMap;
5785
5934
  }
5786
5935
  if (relation.direction === "owning" && relation.localKey) {
5936
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5787
5937
  const localKeyCol = parentTable[relation.localKey];
5788
5938
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5789
5939
  const fkRows = await this.db.select({
@@ -5812,17 +5962,11 @@ var RelationService = class {
5812
5962
  const resultMap = /* @__PURE__ */ new Map();
5813
5963
  for (const [parentIdStr, fkValue] of parentToFk) {
5814
5964
  const targetRow = targetById.get(String(fkValue));
5815
- if (targetRow) {
5816
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5817
- resultMap.set(parentIdStr, {
5818
- id: String(targetRow[targetIdInfo.fieldName]),
5819
- path: targetCollection.slug,
5820
- values: parsedValues
5821
- });
5822
- }
5965
+ if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5823
5966
  }
5824
5967
  return resultMap;
5825
5968
  }
5969
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5826
5970
  let query = this.db.select().from(targetTable).$dynamic();
5827
5971
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5828
5972
  const results = await query;
@@ -5833,14 +5977,7 @@ var RelationService = class {
5833
5977
  let parentId;
5834
5978
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5835
5979
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5836
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5837
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5838
- resultMap.set(String(parentId), {
5839
- id: String(targetRow[targetIdInfo.fieldName]),
5840
- path: targetCollection.slug,
5841
- values: parsedValues
5842
- });
5843
- }
5980
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5844
5981
  }
5845
5982
  return resultMap;
5846
5983
  }
@@ -5854,9 +5991,9 @@ var RelationService = class {
5854
5991
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5855
5992
  const targetCollection = relation.target();
5856
5993
  const targetTable = getTableForCollection(targetCollection, this.registry);
5857
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5858
- const targetIdField = targetTable[targetIdInfo.fieldName];
5859
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5994
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5995
+ const targetIdField = targetTable[targetPks[0].fieldName];
5996
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5860
5997
  const parentIdInfo = parentPks[0];
5861
5998
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5862
5999
  if (!parentTable) throw new Error("Parent table not found");
@@ -5878,27 +6015,22 @@ var RelationService = class {
5878
6015
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5879
6016
  currentTable = joinTable;
5880
6017
  }
5881
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5882
- query = query.where(inArray(parentIdField, parsedParentIds));
6018
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5883
6019
  const results = await query;
5884
6020
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5885
6021
  const resultMap = /* @__PURE__ */ new Map();
5886
6022
  for (const row of results) {
5887
6023
  const parentRow = row[getTableName$1(parentCollection)] || row;
5888
6024
  const targetRow = row[targetTableName] || row;
5889
- const parentId = String(parentRow[parentIdInfo.fieldName]);
5890
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6025
+ const parentId = buildCompositeId(parentRow, parentPks);
5891
6026
  const arr = resultMap.get(parentId) || [];
5892
- arr.push({
5893
- id: String(targetRow[targetIdInfo.fieldName]),
5894
- path: targetCollection.slug,
5895
- values: parsedValues
5896
- });
6027
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5897
6028
  resultMap.set(parentId, arr);
5898
6029
  }
5899
6030
  return resultMap;
5900
6031
  }
5901
6032
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6033
+ this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
5902
6034
  const junctionTable = this.registry.getTable(relation.through.table);
5903
6035
  if (!junctionTable) {
5904
6036
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -5917,17 +6049,13 @@ var RelationService = class {
5917
6049
  const junctionData = row[relation.through.table] || row;
5918
6050
  const targetData = row[targetTableName] || row;
5919
6051
  const parentId = String(junctionData[relation.through.sourceColumn]);
5920
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
5921
6052
  const arr = resultMap.get(parentId) || [];
5922
- arr.push({
5923
- id: String(targetData[targetIdInfo.fieldName]),
5924
- path: targetCollection.slug,
5925
- values: parsedValues
5926
- });
6053
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5927
6054
  resultMap.set(parentId, arr);
5928
6055
  }
5929
6056
  return resultMap;
5930
6057
  }
6058
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5931
6059
  let query = this.db.select().from(targetTable).$dynamic();
5932
6060
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5933
6061
  const results = await query;
@@ -5940,14 +6068,9 @@ var RelationService = class {
5940
6068
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5941
6069
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5942
6070
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5943
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5944
6071
  const key = String(parentId);
5945
6072
  const arr = resultMap.get(key) || [];
5946
- arr.push({
5947
- id: String(targetRow[targetIdInfo.fieldName]),
5948
- path: targetCollection.slug,
5949
- values: parsedValues
5950
- });
6073
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5951
6074
  resultMap.set(key, arr);
5952
6075
  }
5953
6076
  }
@@ -5995,12 +6118,12 @@ var RelationService = class {
5995
6118
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
5996
6119
  continue;
5997
6120
  }
5998
- const parentPks = getPrimaryKeys(collection, this.registry);
6121
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5999
6122
  const parentIdInfo = parentPks[0];
6000
6123
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6001
6124
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6002
6125
  if (targetEntityIds.length > 0) {
6003
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6126
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6004
6127
  const targetIdInfo = targetPks[0];
6005
6128
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6006
6129
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6020,12 +6143,12 @@ var RelationService = class {
6020
6143
  logger.warn(`Junction columns not found for relation '${key}'`);
6021
6144
  continue;
6022
6145
  }
6023
- const parentPks = getPrimaryKeys(collection, this.registry);
6146
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6024
6147
  const parentIdInfo = parentPks[0];
6025
6148
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6026
6149
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6027
6150
  if (targetEntityIds.length > 0) {
6028
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6151
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6029
6152
  const targetIdInfo = targetPks[0];
6030
6153
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6031
6154
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6036,7 +6159,7 @@ var RelationService = class {
6036
6159
  } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") logger.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
6037
6160
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
6038
6161
  const targetTable = getTableForCollection(targetCollection, this.registry);
6039
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6162
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6040
6163
  const targetIdInfo = targetPks[0];
6041
6164
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6042
6165
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -6044,7 +6167,7 @@ var RelationService = class {
6044
6167
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
6045
6168
  continue;
6046
6169
  }
6047
- const parentPks = getPrimaryKeys(collection, this.registry);
6170
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6048
6171
  const parentIdInfo = parentPks[0];
6049
6172
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6050
6173
  if (targetEntityIds.length > 0) {
@@ -6064,9 +6187,9 @@ var RelationService = class {
6064
6187
  try {
6065
6188
  const targetCollection = relation.target();
6066
6189
  const targetTable = getTableForCollection(targetCollection, this.registry);
6067
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6190
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6068
6191
  const targetIdInfo = targetPks[0];
6069
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6192
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6070
6193
  const sourceIdInfo = sourcePks[0];
6071
6194
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
6072
6195
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -6147,12 +6270,12 @@ var RelationService = class {
6147
6270
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
6148
6271
  return;
6149
6272
  }
6150
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6273
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6151
6274
  const sourceIdInfo = sourcePks[0];
6152
6275
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6153
6276
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6154
6277
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6155
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6278
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6156
6279
  const targetIdInfo = targetPks[0];
6157
6280
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6158
6281
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6160,7 +6283,7 @@ var RelationService = class {
6160
6283
  }));
6161
6284
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
6162
6285
  } else if (newValue && !Array.isArray(newValue)) {
6163
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6286
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6164
6287
  const targetIdInfo = targetPks[0];
6165
6288
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
6166
6289
  const newLink = {
@@ -6191,12 +6314,12 @@ var RelationService = class {
6191
6314
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
6192
6315
  return;
6193
6316
  }
6194
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6317
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6195
6318
  const sourceIdInfo = sourcePks[0];
6196
6319
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6197
6320
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6198
6321
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6199
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6322
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6200
6323
  const targetIdInfo = targetPks[0];
6201
6324
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6202
6325
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6217,12 +6340,12 @@ var RelationService = class {
6217
6340
  const { relation, newTargetId } = upd;
6218
6341
  const targetCollection = relation.target();
6219
6342
  const targetTable = getTableForCollection(targetCollection, this.registry);
6220
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6343
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6221
6344
  const targetIdInfo = targetPks[0];
6222
6345
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6223
6346
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
6224
6347
  const parentTable = getTableForCollection(parentCollection, this.registry);
6225
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6348
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
6226
6349
  const parentIdInfo = parentPks[0];
6227
6350
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
6228
6351
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -6293,7 +6416,7 @@ var RelationService = class {
6293
6416
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
6294
6417
  return;
6295
6418
  }
6296
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6419
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6297
6420
  const targetIdInfo = targetPks[0];
6298
6421
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
6299
6422
  const junctionData = {
@@ -6309,6 +6432,131 @@ var RelationService = class {
6309
6432
  }
6310
6433
  };
6311
6434
  //#endregion
6435
+ //#region src/services/row-pipeline.ts
6436
+ /**
6437
+ * Whether a many-relation reaches its target through a junction table.
6438
+ *
6439
+ * Also used to build the drizzle `with` config, which is why it is exported:
6440
+ * the query has to nest one level deeper for a junction, and the row walk has
6441
+ * to unwrap that same level back out.
6442
+ */
6443
+ function isJunctionRelation(relation) {
6444
+ if (relation.through) return true;
6445
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
6446
+ return false;
6447
+ }
6448
+ /**
6449
+ * Reach the target row inside a junction row.
6450
+ *
6451
+ * A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
6452
+ * the foreign keys, and the target nested under one of them. The target is the
6453
+ * only object among them, so that is how it is found. A junction row that has
6454
+ * not been nested (no `with` on the join) has no object and is returned as-is.
6455
+ */
6456
+ function unwrapJunctionRow(item) {
6457
+ const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
6458
+ return nestedKey ? item[nestedKey] : item;
6459
+ }
6460
+ /**
6461
+ * Give back the number a `number` property was declared to be.
6462
+ *
6463
+ * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
6464
+ * numeric can hold more precision than a double. But the collection declared
6465
+ * this property `number` and the OpenAPI spec this server publishes says
6466
+ * `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
6467
+ * asymmetrically, because a create answers with the number and the read that
6468
+ * follows answers with the string. Any client that multiplies a price works
6469
+ * until the first refresh.
6470
+ *
6471
+ * Declaring a property `number` already accepts double precision — that is what
6472
+ * the admin has always parsed it to. Columns REST serves that no property
6473
+ * declares are left exactly as the database returned them.
6474
+ */
6475
+ function coerceDeclaredNumber(value, property) {
6476
+ if (property?.type !== "number" || typeof value !== "string") return value;
6477
+ const parsed = parseFloat(value);
6478
+ return isNaN(parsed) ? null : parsed;
6479
+ }
6480
+ /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
6481
+ function coerceDeclaredNumbers(row, collection) {
6482
+ const properties = collection.properties;
6483
+ if (!properties) return row;
6484
+ const out = {};
6485
+ for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
6486
+ return out;
6487
+ }
6488
+ /** Render one target row in the requested style. */
6489
+ function renderTarget(targetRow, targetCollection, style, registry) {
6490
+ if (style === "inline") return coerceDeclaredNumbers({ ...targetRow }, targetCollection);
6491
+ const address = relationTargetAddress(targetRow, targetCollection, registry);
6492
+ const path = targetCollection.slug;
6493
+ return createRelationRefWithData(address, path, {
6494
+ id: address,
6495
+ path,
6496
+ values: normalizeDbValues(targetRow, targetCollection)
6497
+ });
6498
+ }
6499
+ /**
6500
+ * The address a relation ref points at.
6501
+ *
6502
+ * The whole key, not its first column: a composite-keyed target addressed by
6503
+ * `tenant_id` alone points at every row that shares it. A target whose key
6504
+ * cannot be resolved at all used to throw here — reading `[0]` of an empty
6505
+ * array — taking down the parent's fetch over a relation it may not even have
6506
+ * asked for. The first column is a guess, but a ref that resolves to nothing
6507
+ * beats no rows at all.
6508
+ */
6509
+ function relationTargetAddress(targetRow, targetCollection, registry) {
6510
+ const address = deriveRowAddress(targetRow, targetCollection, registry);
6511
+ if (address) return address;
6512
+ return String(targetRow[Object.keys(targetRow)[0]] ?? "");
6513
+ }
6514
+ /**
6515
+ * The row the admin renders: every column, with relations as references.
6516
+ *
6517
+ * Values are normalized (dates, numbers, NaN) because the admin's view-model
6518
+ * expects real types. The row's own address is *not* among the columns — it is
6519
+ * derived by the consumer from the collection's primary keys.
6520
+ */
6521
+ function toCmsRow(row, collection, registry) {
6522
+ const resolvedRelations = resolveCollectionRelations(collection);
6523
+ const normalized = normalizeDbValues(row, collection);
6524
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
6525
+ const relData = row[relation.relationName || key];
6526
+ if (relData === void 0 || relData === null) continue;
6527
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
6528
+ const targetCollection = relation.target();
6529
+ normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6530
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6531
+ }
6532
+ return normalized;
6533
+ }
6534
+ /**
6535
+ * The row REST serves: every column under its own name, with the value Postgres
6536
+ * returned, and relations inlined as the target's columns.
6537
+ *
6538
+ * Values are the ones the database returned, except where that contradicts the
6539
+ * declared type: a `number` property is served as a number (see
6540
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6541
+ * JSON has its own opinions about dates that the admin's view-model does not
6542
+ * share.
6543
+ *
6544
+ * Keyed by the row rather than by the relation list — a REST fetch only loads
6545
+ * the relations `include` asked for, so the row is the authority on which are
6546
+ * actually there.
6547
+ */
6548
+ function toRestRow(row, collection, registry) {
6549
+ const resolvedRelations = resolveCollectionRelations(collection);
6550
+ const flat = {};
6551
+ for (const [key, value] of Object.entries(row)) {
6552
+ const relation = findRelation(resolvedRelations, key);
6553
+ if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6554
+ else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6555
+ else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6556
+ }
6557
+ return flat;
6558
+ }
6559
+ //#endregion
6312
6560
  //#region src/services/FetchService.ts
6313
6561
  /**
6314
6562
  * Service for handling all row read operations.
@@ -6367,7 +6615,7 @@ var FetchService = class {
6367
6615
  if (!shouldInclude(key)) continue;
6368
6616
  const drizzleRelName = relation.relationName || key;
6369
6617
  if (relation.joinPath && relation.joinPath.length > 0) continue;
6370
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6618
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
6371
6619
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
6372
6620
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
6373
6621
  else withConfig[drizzleRelName] = true;
@@ -6376,14 +6624,6 @@ var FetchService = class {
6376
6624
  return withConfig;
6377
6625
  }
6378
6626
  /**
6379
- * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
6380
- */
6381
- isJunctionRelation(relation, _collection) {
6382
- if (relation.through) return true;
6383
- if (relation.joinPath && relation.joinPath.length > 1) return true;
6384
- return false;
6385
- }
6386
- /**
6387
6627
  * Get the Drizzle relation name on the junction table that points to the actual target row.
6388
6628
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
6389
6629
  */
@@ -6392,54 +6632,6 @@ var FetchService = class {
6392
6632
  return null;
6393
6633
  }
6394
6634
  /**
6395
- * Convert a db.query result row (with nested relation objects) to a flat row.
6396
- * Handles:
6397
- * - Type normalization (dates, numbers, NaN) via normalizeDbValues
6398
- * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
6399
- * - Flattening junction-table many-to-many results
6400
- *
6401
- * The row's own address is not among them: it is derived by the consumer
6402
- * from the collection's primary keys.
6403
- */
6404
- drizzleResultToRow(row, collection) {
6405
- const resolvedRelations = resolveCollectionRelations(collection);
6406
- const normalizedValues = normalizeDbValues(row, collection);
6407
- for (const [key, relation] of Object.entries(resolvedRelations)) {
6408
- const relData = row[relation.relationName || key];
6409
- if (relData === void 0 || relData === null) continue;
6410
- if (relation.cardinality === "many" && Array.isArray(relData)) {
6411
- const targetCollection = relation.target();
6412
- const targetPath = targetCollection.slug;
6413
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6414
- normalizedValues[key] = relData.map((item) => {
6415
- let targetRow = item;
6416
- if (this.isJunctionRelation(relation, collection)) {
6417
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6418
- if (nestedKey) targetRow = item[nestedKey];
6419
- }
6420
- const relId = String(targetRow[targetIdField] ?? targetRow.id ?? targetRow[Object.keys(targetRow)[0]]);
6421
- return createRelationRefWithData(relId, targetPath, {
6422
- id: relId,
6423
- path: targetPath,
6424
- values: normalizeDbValues(targetRow, targetCollection)
6425
- });
6426
- });
6427
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
6428
- const targetCollection = relation.target();
6429
- const targetPath = targetCollection.slug;
6430
- const targetIdField = getPrimaryKeys(targetCollection, this.registry)[0].fieldName;
6431
- const relObj = relData;
6432
- const relId = String(relObj[targetIdField] ?? relObj.id ?? relObj[Object.keys(relObj)[0]]);
6433
- normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
6434
- id: relId,
6435
- path: targetPath,
6436
- values: normalizeDbValues(relObj, targetCollection)
6437
- });
6438
- }
6439
- }
6440
- return normalizedValues;
6441
- }
6442
- /**
6443
6635
  * Post-fetch joinPath relations for a single flat row.
6444
6636
  * joinPath relations cannot be expressed via Drizzle's `with` config,
6445
6637
  * so they must be loaded separately after the primary query.
@@ -6460,31 +6652,6 @@ var FetchService = class {
6460
6652
  await Promise.all(promises);
6461
6653
  }
6462
6654
  /**
6463
- * Post-fetch joinPath relations for a batch of flat rows.
6464
- * Uses batch fetching to avoid N+1 queries for list views.
6465
- */
6466
- async resolveJoinPathRelationsBatch(rows, collection, collectionPath, idInfo, _databaseId) {
6467
- if (rows.length === 0) return;
6468
- const resolvedRelations = resolveCollectionRelations(collection);
6469
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0);
6470
- if (joinPathRelations.length === 0) return;
6471
- for (const [key, relation] of joinPathRelations) try {
6472
- const rowIds = rows.map((r) => {
6473
- return parseIdValues(String(r.id), [idInfo])[idInfo.fieldName];
6474
- });
6475
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6476
- for (const row of rows) {
6477
- const id = parseIdValues(String(row.id), [idInfo])[idInfo.fieldName];
6478
- const relatedRow = resultMap.get(String(id));
6479
- if (relatedRow) {
6480
- if (relation.cardinality === "one") row[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
6481
- }
6482
- }
6483
- } catch (e) {
6484
- logger.warn(`Could not batch resolve joinPath relation '${key}'`, { error: e });
6485
- }
6486
- }
6487
- /**
6488
6655
  * Resolves joinPath relations for raw REST rows and directly injects them.
6489
6656
  * Uses RelationService to query the database and maps results back to the flattened objects.
6490
6657
  */
@@ -6495,63 +6662,29 @@ var FetchService = class {
6495
6662
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
6496
6663
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6497
6664
  if (joinPathRelations.length === 0) return;
6498
- const idInfo = idInfoArray[0];
6665
+ const parentIdOf = (row) => {
6666
+ const address = buildCompositeId(row, idInfoArray);
6667
+ return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6668
+ };
6499
6669
  for (const [key, relation] of joinPathRelations) try {
6500
- const rowIds = rows.map((r) => {
6501
- return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
6502
- });
6670
+ const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6671
+ if (addressable.length === 0) continue;
6672
+ const rowIds = addressable.map((r) => parentIdOf(r));
6503
6673
  if (relation.cardinality === "one") {
6504
6674
  const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
6505
- for (const row of rows) {
6506
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
6507
- const relatedRow = resultMap.get(String(id));
6508
- if (relatedRow) row[key] = {
6509
- ...relatedRow.values,
6510
- id: relatedRow.id
6511
- };
6512
- else row[key] = null;
6675
+ for (const row of addressable) {
6676
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
6677
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
6513
6678
  }
6514
6679
  } else if (relation.cardinality === "many") {
6515
6680
  const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
6516
- for (const row of rows) {
6517
- const id = parseIdValues(String(row.id), idInfoArray)[idInfo.fieldName];
6518
- row[key] = (resultMap.get(String(id)) || []).map((e) => ({
6519
- ...e.values,
6520
- id: e.id
6521
- }));
6522
- }
6681
+ for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
6523
6682
  }
6524
6683
  } catch (e) {
6525
6684
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
6526
6685
  }
6527
6686
  }
6528
6687
  /**
6529
- * Convert a db.query result row to a flat REST-style row with populated relations.
6530
- *
6531
- * Every column is copied through under its own name, with the value Postgres
6532
- * returned. This used to open with a synthesized `id` and then skip the key
6533
- * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
6534
- * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
6535
- * need an address derive it from the collection's primary keys.
6536
- */
6537
- drizzleResultToRestRow(row, collection) {
6538
- const flat = {};
6539
- const resolvedRelations = resolveCollectionRelations(collection);
6540
- for (const [k, v] of Object.entries(row)) {
6541
- const relation = findRelation(resolvedRelations, k);
6542
- if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6543
- if (this.isJunctionRelation(relation, collection)) {
6544
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6545
- if (nestedKey) return { ...item[nestedKey] };
6546
- }
6547
- return { ...item };
6548
- });
6549
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6550
- else flat[k] = v;
6551
- }
6552
- return flat;
6553
- }
6554
- /**
6555
6688
  * Build db.query-compatible options from standard fetch options.
6556
6689
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6557
6690
  */
@@ -6621,7 +6754,7 @@ var FetchService = class {
6621
6754
  async fetchOne(collectionPath, id, databaseId) {
6622
6755
  const collection = getCollectionByPath(collectionPath, this.registry);
6623
6756
  const table = getTableForCollection(collection, this.registry);
6624
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6757
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6625
6758
  const idInfo = idInfoArray[0];
6626
6759
  const idField = table[idInfo.fieldName];
6627
6760
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6635,7 +6768,7 @@ var FetchService = class {
6635
6768
  with: withConfig
6636
6769
  });
6637
6770
  if (!row) return void 0;
6638
- const flatRow = this.drizzleResultToRow(row, collection);
6771
+ const flatRow = toCmsRow(row, collection, this.registry);
6639
6772
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6640
6773
  return flatRow;
6641
6774
  } catch (e) {
@@ -6677,7 +6810,7 @@ var FetchService = class {
6677
6810
  async fetchRowsWithConditions(collectionPath, options = {}) {
6678
6811
  const collection = getCollectionByPath(collectionPath, this.registry);
6679
6812
  const table = getTableForCollection(collection, this.registry);
6680
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6813
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6681
6814
  const idInfo = idInfoArray[0];
6682
6815
  const idField = table[idInfo.fieldName];
6683
6816
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6687,7 +6820,7 @@ var FetchService = class {
6687
6820
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6688
6821
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6689
6822
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6690
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6823
+ return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6691
6824
  } catch (e) {
6692
6825
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6693
6826
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6748,8 +6881,10 @@ var FetchService = class {
6748
6881
  }
6749
6882
  /**
6750
6883
  * Fallback path used when db.query is unavailable.
6751
- * The primary path uses drizzleResultToRow which handles relation
6752
- * mapping without N+1 queries.
6884
+ *
6885
+ * The primary path runs the results through `toCmsRow`, which maps
6886
+ * relations from what drizzle already nested — no query per row. This one
6887
+ * has no nesting to read, so it resolves relations itself, in batches.
6753
6888
  *
6754
6889
  * Process raw database results into flat rows with relations.
6755
6890
  */
@@ -6829,10 +6964,7 @@ var FetchService = class {
6829
6964
  const relationKey = pathSegments[i];
6830
6965
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6831
6966
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
6832
- if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({
6833
- ...row.values,
6834
- id: row.id
6835
- }));
6967
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6836
6968
  if (i + 1 < pathSegments.length) {
6837
6969
  const nextEntityId = pathSegments[i + 1];
6838
6970
  currentCollection = relation.target();
@@ -6894,7 +7026,7 @@ var FetchService = class {
6894
7026
  if (value === void 0 || value === null) return true;
6895
7027
  const collection = getCollectionByPath(collectionPath, this.registry);
6896
7028
  const table = getTableForCollection(collection, this.registry);
6897
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7029
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6898
7030
  const idInfo = idInfoArray[0];
6899
7031
  const idField = table[idInfo.fieldName];
6900
7032
  const field = table[fieldName];
@@ -6921,7 +7053,7 @@ var FetchService = class {
6921
7053
  async fetchCollectionForRest(collectionPath, options = {}, include) {
6922
7054
  const collection = getCollectionByPath(collectionPath, this.registry);
6923
7055
  const table = getTableForCollection(collection, this.registry);
6924
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7056
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6925
7057
  const idInfo = idInfoArray[0];
6926
7058
  const idField = table[idInfo.fieldName];
6927
7059
  const tableName = getTableName(table);
@@ -6929,7 +7061,7 @@ var FetchService = class {
6929
7061
  if (qb && !options.searchString && !options.vectorSearch) try {
6930
7062
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6931
7063
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6932
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
7064
+ const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6933
7065
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6934
7066
  return restRows;
6935
7067
  } catch (e) {
@@ -6978,7 +7110,7 @@ var FetchService = class {
6978
7110
  async fetchOneForRest(collectionPath, id, include, databaseId) {
6979
7111
  const collection = getCollectionByPath(collectionPath, this.registry);
6980
7112
  const table = getTableForCollection(collection, this.registry);
6981
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7113
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6982
7114
  const idInfo = idInfoArray[0];
6983
7115
  const idField = table[idInfo.fieldName];
6984
7116
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -6991,7 +7123,7 @@ var FetchService = class {
6991
7123
  ...withConfig ? { with: withConfig } : {}
6992
7124
  });
6993
7125
  if (!row) return null;
6994
- const restRow = this.drizzleResultToRestRow(row, collection);
7126
+ const restRow = toRestRow(row, collection, this.registry);
6995
7127
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6996
7128
  return restRow;
6997
7129
  } catch (e) {
@@ -7036,7 +7168,7 @@ var FetchService = class {
7036
7168
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
7037
7169
  const collection = getCollectionByPath(collectionPath, this.registry);
7038
7170
  const table = getTableForCollection(collection, this.registry);
7039
- const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7171
+ const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
7040
7172
  let vectorMeta;
7041
7173
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
7042
7174
  let query = vectorMeta ? this.db.select({
@@ -7525,7 +7657,7 @@ var PersistService = class {
7525
7657
  } catch (error) {
7526
7658
  throw this.toUserFriendlyError(error, collection.slug);
7527
7659
  }
7528
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7660
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7529
7661
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7530
7662
  return finalEntity;
7531
7663
  }
@@ -8444,12 +8576,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8444
8576
  let updatedValues = values;
8445
8577
  const contextForCallback = this.buildCallContext();
8446
8578
  let previousValuesForHistory;
8447
- if (status === "existing" && id) {
8448
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8579
+ if (status === "existing" && id) try {
8580
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8449
8581
  if (existing) {
8450
8582
  const { id: _existingId, ...existingValues } = existing;
8451
8583
  previousValuesForHistory = existingValues;
8452
8584
  }
8585
+ } catch (err) {
8586
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8453
8587
  }
8454
8588
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8455
8589
  if (globalCallbacks?.beforeSave) {
@@ -8517,8 +8651,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8517
8651
  context: contextForCallback
8518
8652
  });
8519
8653
  }
8520
- const savedId = savedRow.id;
8521
- const { id: _savedId, ...savedValues } = savedRow;
8654
+ const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8655
+ const savedValues = savedRow;
8522
8656
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
8523
8657
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
8524
8658
  collection: resolvedCollection,
@@ -8550,7 +8684,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8550
8684
  }
8551
8685
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8552
8686
  tableName: path,
8553
- id: savedId.toString(),
8687
+ id: savedId,
8554
8688
  action: status === "new" ? "create" : "update",
8555
8689
  values: savedValues,
8556
8690
  previousValues: previousValuesForHistory,
@@ -8558,11 +8692,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8558
8692
  });
8559
8693
  if (this._deferNotifications) this._pendingNotifications.push({
8560
8694
  path,
8561
- id: savedId.toString(),
8695
+ id: savedId,
8562
8696
  row: savedRow,
8563
8697
  databaseId: resolvedCollection?.databaseId
8564
8698
  });
8565
- else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8699
+ else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8566
8700
  return savedRow;
8567
8701
  } catch (error) {
8568
8702
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8642,10 +8776,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8642
8776
  }
8643
8777
  async delete({ row, collection }) {
8644
8778
  const targetPath = row.path;
8645
- const targetRow = {
8646
- id: row.id,
8647
- ...row.values ?? {}
8648
- };
8779
+ const targetRow = { ...row.values ?? {} };
8649
8780
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8650
8781
  const contextForCallback = this.buildCallContext();
8651
8782
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -10328,7 +10459,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10328
10459
  startAfter: request.startAfter,
10329
10460
  searchString: request.searchString
10330
10461
  }, authContext);
10331
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
10462
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
10332
10463
  } catch (error) {
10333
10464
  const sanitized = sanitizeErrorForClient(error, request.path);
10334
10465
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10429,7 +10560,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10429
10560
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
10430
10561
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
10431
10562
  else if (subscription.type === "collection" && subscription.collectionRequest) {
10432
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10563
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
10433
10564
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
10434
10565
  }
10435
10566
  } catch (error) {
@@ -10459,7 +10590,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10459
10590
  if (!this._subscriptions.has(subscriptionId)) return;
10460
10591
  try {
10461
10592
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
10462
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10593
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
10463
10594
  } catch (error) {
10464
10595
  const sanitized = sanitizeErrorForClient(error, notifyPath);
10465
10596
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10673,11 +10804,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10673
10804
  }
10674
10805
  return await this.dataService.fetchOne(notifyPath, id);
10675
10806
  }
10676
- sendCollectionUpdate(clientId, subscriptionId, rows) {
10807
+ sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10677
10808
  const message = {
10678
10809
  type: "collection_update",
10679
10810
  subscriptionId,
10680
- rows
10811
+ rows,
10812
+ pks: this.primaryKeysForPath(path)
10681
10813
  };
10682
10814
  this.sendMessage(clientId, message);
10683
10815
  }
@@ -10692,16 +10824,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10692
10824
  /**
10693
10825
  * Send a lightweight row-level patch to a collection subscriber.
10694
10826
  * The client can merge this into its cached data for instant feedback.
10827
+ *
10828
+ * The key columns ride along: the patch names a row by address, and the
10829
+ * client has to find that row among the ones it cached — which carry
10830
+ * columns and no address. The SDK holds no collection config to derive one
10831
+ * from, so this is the only place the mapping can come from.
10695
10832
  */
10696
- sendCollectionPatch(clientId, subscriptionId, id, row) {
10833
+ sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10697
10834
  const message = {
10698
10835
  type: "collection_patch",
10699
10836
  subscriptionId,
10700
10837
  id,
10701
- row
10838
+ row,
10839
+ pks: this.primaryKeysForPath(notifyPath)
10702
10840
  };
10703
10841
  this.sendMessage(clientId, message);
10704
10842
  }
10843
+ /** The key columns of the collection at `path`, if they can be resolved. */
10844
+ primaryKeysForPath(path) {
10845
+ try {
10846
+ const collection = this.registry.getCollectionByPath(path);
10847
+ if (!collection) return void 0;
10848
+ const keys = getPrimaryKeys(collection, this.registry);
10849
+ return keys.length > 0 ? keys : void 0;
10850
+ } catch {
10851
+ return;
10852
+ }
10853
+ }
10705
10854
  sendError(clientId, error, subscriptionId, code) {
10706
10855
  const message = {
10707
10856
  type: "error",
@@ -10949,12 +11098,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10949
11098
  }
10950
11099
  /** Compute the canonical (possibly composite) id string from a captured row. */
10951
11100
  extractIdFromCdcRow(collection, row) {
10952
- try {
10953
- const composite = buildCompositeId(row, getPrimaryKeys(collection, this.registry));
10954
- if (composite && composite !== ":::") return composite;
10955
- } catch {}
10956
- if (row.id !== void 0 && row.id !== null) return String(row.id);
10957
- return "*";
11101
+ return deriveRowAddress(row, collection, this.registry) || "*";
10958
11102
  }
10959
11103
  dedupKey(path, id, databaseId) {
10960
11104
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -20414,6 +20558,33 @@ function formatBytes(bytes) {
20414
20558
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
20415
20559
  }
20416
20560
  //#endregion
20561
+ //#region src/collections/buildRegistry.ts
20562
+ /**
20563
+ * Build the collection registry for a driver.
20564
+ *
20565
+ * The order matters and is the reason this is one function rather than a run of
20566
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
20567
+ * anything that inspects them has to run *after* the tables are registered —
20568
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
20569
+ * collection whose table it cannot look up is one it has nothing to say about.
20570
+ * Warned too early, it would skip every collection and report nothing, which
20571
+ * reads exactly like having nothing to report.
20572
+ */
20573
+ function buildCollectionRegistry(schema) {
20574
+ const registry = new PostgresCollectionRegistry();
20575
+ if (schema.collections) {
20576
+ registry.registerMultiple(schema.collections);
20577
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
20578
+ }
20579
+ if (schema.tables) Object.values(schema.tables).forEach((table) => {
20580
+ if (isTable(table)) registry.registerTable(table, getTableName(table));
20581
+ });
20582
+ if (schema.enums) registry.registerEnums(schema.enums);
20583
+ if (schema.relations) registry.registerRelations(schema.relations);
20584
+ warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
20585
+ return registry;
20586
+ }
20587
+ //#endregion
20417
20588
  //#region src/auth/ensure-tables.ts
20418
20589
  /**
20419
20590
  * Auto-create auth tables if they don't exist.
@@ -22396,21 +22567,14 @@ function createPostgresBootstrapper(pgConfig) {
22396
22567
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
22397
22568
  }
22398
22569
  const activeCollections = introspectedCollections ?? collections;
22399
- const registry = new PostgresCollectionRegistry();
22400
- if (activeCollections) {
22401
- registry.registerMultiple(activeCollections);
22402
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
22403
- }
22404
22570
  const schemaTables = introspectedTables ?? pgConfig.schema?.tables;
22405
- if (schemaTables) Object.values(schemaTables).forEach((table) => {
22406
- if (isTable(table)) {
22407
- const tableName = getTableName(table);
22408
- registry.registerTable(table, tableName);
22409
- }
22410
- });
22411
- if (pgConfig.schema?.enums) registry.registerEnums(pgConfig.schema.enums);
22412
22571
  const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22413
- if (schemaRelations) registry.registerRelations(schemaRelations);
22572
+ const registry = buildCollectionRegistry({
22573
+ collections: activeCollections,
22574
+ tables: schemaTables,
22575
+ enums: pgConfig.schema?.enums,
22576
+ relations: schemaRelations
22577
+ });
22414
22578
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
22415
22579
  const mergedSchema = {
22416
22580
  ...schemaTables,
@@ -22489,6 +22653,7 @@ function createPostgresBootstrapper(pgConfig) {
22489
22653
  const wantsCdc = cdcMode !== "off";
22490
22654
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22491
22655
  let cdcEnabled = false;
22656
+ let provisionCdcForTables;
22492
22657
  if (wantsCdc && !directUrl) {
22493
22658
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22494
22659
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22505,6 +22670,9 @@ function createPostgresBootstrapper(pgConfig) {
22505
22670
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22506
22671
  await realtimeService.enableCdc(directUrl);
22507
22672
  cdcEnabled = true;
22673
+ provisionCdcForTables = async (tables) => {
22674
+ await provisionTriggerCdc(cdcRunSql, tables);
22675
+ };
22508
22676
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22509
22677
  } catch (err) {
22510
22678
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22529,13 +22697,14 @@ function createPostgresBootstrapper(pgConfig) {
22529
22697
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22530
22698
  const missing = [];
22531
22699
  for (const col of registeredCollections) {
22700
+ if (col.auth?.enabled) continue;
22532
22701
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22533
22702
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22534
22703
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22535
22704
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22536
22705
  if (!dbTables.has(fullCheckName)) missing.push({
22537
22706
  slug: col.slug,
22538
- table: checkName
22707
+ table: fullCheckName
22539
22708
  });
22540
22709
  }
22541
22710
  if (missing.length > 0) {
@@ -22569,7 +22738,8 @@ function createPostgresBootstrapper(pgConfig) {
22569
22738
  registry,
22570
22739
  realtimeService,
22571
22740
  driver,
22572
- poolManager
22741
+ poolManager,
22742
+ provisionCdcForTables
22573
22743
  }
22574
22744
  };
22575
22745
  },
@@ -22581,6 +22751,18 @@ function createPostgresBootstrapper(pgConfig) {
22581
22751
  const registry = internals.registry;
22582
22752
  const authCollection = authConfig.collection;
22583
22753
  await ensureAuthTablesExist(db, authCollection);
22754
+ if (authCollection && internals.provisionCdcForTables) {
22755
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22756
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22757
+ if (authTable) try {
22758
+ await internals.provisionCdcForTables([{
22759
+ schema: authSchema,
22760
+ table: authTable
22761
+ }]);
22762
+ } catch (err) {
22763
+ logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
22764
+ }
22765
+ }
22584
22766
  let emailService;
22585
22767
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22586
22768
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;