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

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
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
71
71
  var connection_exports = /* @__PURE__ */ __exportAll({
72
72
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
73
73
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
74
- createReadReplicaConnection: () => createReadReplicaConnection
74
+ createReadReplicaConnection: () => createReadReplicaConnection,
75
+ guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
75
76
  });
76
77
  var DEFAULT_POOL = {
77
78
  max: 20,
78
79
  idleTimeoutMillis: 3e4,
79
80
  connectionTimeoutMillis: 1e4,
80
- queryTimeout: 3e4,
81
+ queryTimeout: 6e4,
81
82
  statementTimeout: 3e4,
82
83
  keepAlive: true
83
84
  };
85
+ /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
86
+ var TX_IDLE = "I";
87
+ /**
88
+ * Destroy pool clients that are released while still inside a transaction.
89
+ *
90
+ * pg-pool returns a client to the idle list whenever `release()` is called
91
+ * without an error — even if the connection is still mid-transaction (status
92
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
93
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
94
+ * (e.g. it was queued behind a statement that hit the client-side
95
+ * query_timeout), the client goes back dirty. The next checkout then runs
96
+ * its statements inside the zombie transaction — with the previous request's
97
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
98
+ * RLS-scoped ones (observed in production as registration failing with
99
+ * SQLSTATE 42501 under a leaked anonymous context).
100
+ *
101
+ * pg-pool emits `release` before it consults its private `_expired` set, so
102
+ * marking the client expired here makes `_release()` destroy it instead of
103
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
104
+ * private APIs — feature-detect and fall back to loud logging so an upstream
105
+ * change degrades to observability, never to silent corruption.
106
+ */
107
+ function guardPoolAgainstDirtyRelease(pool, label) {
108
+ pool.on("release", (err, client) => {
109
+ if (err) return;
110
+ const txStatus = client?._txStatus;
111
+ if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
112
+ const expired = pool._expired;
113
+ if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
114
+ expired.add(client);
115
+ logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
116
+ } else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
117
+ });
118
+ }
84
119
  /**
85
120
  * Create a Drizzle-backed Postgres connection with a production-grade
86
121
  * connection pool.
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
112
147
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
113
148
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
114
149
  });
150
+ guardPoolAgainstDirtyRelease(pool, "pg-pool");
115
151
  return {
116
152
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
117
153
  pool,
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
144
180
  pool.on("error", (err) => {
145
181
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
146
182
  });
183
+ guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
147
184
  return {
148
185
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
149
186
  pool,
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
173
210
  pool.on("error", (err) => {
174
211
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
175
212
  });
213
+ guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
176
214
  return {
177
215
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
178
216
  pool,
@@ -1808,12 +1846,17 @@ function parseIdValues(idValue, primaryKeys) {
1808
1846
  /**
1809
1847
  * The primary keys of a collection, as declared by its properties.
1810
1848
  *
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.
1849
+ * This is the only tier both sides can read, because it is the only one written
1850
+ * in the config: the postgres driver can also infer keys from the Drizzle
1851
+ * schema, which the browser never sees and is never sent the admin compiles
1852
+ * the collection files into its own bundle rather than being served them. A key
1853
+ * that lives only in the Drizzle schema is therefore invisible here, and the
1854
+ * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1855
+ * to add.
1856
+ *
1857
+ * Returns an empty array when a collection declares none, which callers must
1858
+ * treat as "not addressable" rather than defaulting to `id`: guessing a key
1859
+ * that is not the real one produces confidently wrong addresses.
1817
1860
  */
1818
1861
  function getDeclaredPrimaryKeys(collection) {
1819
1862
  const properties = collection.properties;
@@ -1838,9 +1881,15 @@ function getDeclaredPrimaryKeys(collection) {
1838
1881
  * The postgres driver tries, in order: properties marked `isId`; the primary
1839
1882
  * keys of the Drizzle schema; and finally a column literally named `id`. Only
1840
1883
  * 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.
1884
+ * sides share.
1885
+ *
1886
+ * So the two agree except on a collection that declares no `isId` and whose key
1887
+ * is known only to Drizzle. There, the driver reads the real key, and this
1888
+ * either resolves nothing (reported to the console by the caller) or — if the
1889
+ * table happens to have an unrelated `id` property — resolves `id`, which is
1890
+ * the wrong key and cannot be detected from here: the addresses look right and
1891
+ * route wrong. Only the config can settle it, so the server names both cases
1892
+ * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1844
1893
  */
1845
1894
  function resolvePrimaryKeys(collection) {
1846
1895
  const declared = getDeclaredPrimaryKeys(collection);
@@ -4095,7 +4144,7 @@ function createPrimaryKeyResolver(options) {
4095
4144
  }
4096
4145
  if (!warned.has(slug)) {
4097
4146
  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.`);
4147
+ 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
4148
  }
4100
4149
  return keys;
4101
4150
  };
@@ -4538,8 +4587,26 @@ function getTableForCollection(collection, registry) {
4538
4587
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
4539
4588
  return table;
4540
4589
  }
4590
+ /**
4591
+ * The key columns a collection's rows are addressed by.
4592
+ *
4593
+ * Three tiers, in order: properties marked `isId`, the primary keys of the
4594
+ * drizzle schema, and finally a column literally named `id`. Only the first is
4595
+ * visible to the browser, which is why a key known only to drizzle is reported
4596
+ * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
4597
+ *
4598
+ * Returns `[]` when nothing resolves, rather than throwing. It used to open by
4599
+ * resolving the table, which throws when there is none — so the `isId` tier,
4600
+ * which needs no table at all, was unreachable for exactly the collections
4601
+ * most likely to have no table registered. Every caller that wanted "no keys"
4602
+ * to mean "no keys" had to spell that out in a try/catch.
4603
+ *
4604
+ * Callers that cannot proceed without a key must say so themselves, naming the
4605
+ * collection: an empty array here means "this collection has no address", which
4606
+ * is a different answer in a notification (broadcast a wildcard) than in a save
4607
+ * (fail).
4608
+ */
4541
4609
  function getPrimaryKeys(collection, registry) {
4542
- const table = getTableForCollection(collection, registry);
4543
4610
  if (collection.properties) {
4544
4611
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
4545
4612
  fieldName: key,
@@ -4548,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
4548
4615
  }));
4549
4616
  if (idProps.length > 0) return idProps;
4550
4617
  }
4618
+ const table = registry.getTable(getTableName$1(collection));
4619
+ if (!table) return [];
4551
4620
  const keys = [];
4552
4621
  for (const [key, colRaw] of Object.entries(table)) {
4553
4622
  const col = colRaw;
@@ -4575,6 +4644,90 @@ function getPrimaryKeys(collection, registry) {
4575
4644
  }
4576
4645
  return keys;
4577
4646
  }
4647
+ /**
4648
+ * The key columns, for callers that cannot do their job without one.
4649
+ *
4650
+ * {@link getPrimaryKeys} answers "what keys, if any" and returns `[]` for a
4651
+ * collection with no address. Most of this driver, though, is building a WHERE
4652
+ * clause and has no meaning without a key — for those, an empty array is not an
4653
+ * answer, and indexing `[0]` into it produces `Cannot read properties of
4654
+ * undefined` three frames from where the real problem is. This says what is
4655
+ * wrong and which collection it is wrong about.
4656
+ */
4657
+ function requirePrimaryKeys(collection, registry) {
4658
+ const keys = getPrimaryKeys(collection, registry);
4659
+ 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.`);
4660
+ return keys;
4661
+ }
4662
+ /**
4663
+ * Collections whose key the *browser* cannot resolve, and what it will do
4664
+ * instead.
4665
+ *
4666
+ * The two sides resolve keys from different evidence. This driver reads, in
4667
+ * order: properties marked `isId`, the primary keys of the Drizzle schema, then
4668
+ * a column literally named `id`. The admin shares the `CollectionConfig` — it
4669
+ * compiles the same collection files into its bundle — but never the Drizzle
4670
+ * schema, so the middle tier is invisible to it.
4671
+ *
4672
+ * Nothing can normalize this at runtime: the server does not serve the admin
4673
+ * its collections, so a key resolved here cannot be handed over there. The
4674
+ * config files are the only thing both sides read, so the fix is an edit to
4675
+ * them, and the most this can do is say exactly which edit.
4676
+ *
4677
+ * Two shapes, and the second is the dangerous one:
4678
+ *
4679
+ * - No `isId`, no `id` property → the admin resolves no address, warns in the
4680
+ * console, and rows cannot be opened or linked.
4681
+ * - No `isId`, but an `id` property that is *not* the key → the admin addresses
4682
+ * rows by `id` while this driver reads the address as the real key. Nothing
4683
+ * errors: the addresses look right and route wrong.
4684
+ */
4685
+ function findUnresolvableKeyCollections(collections, registry) {
4686
+ const findings = [];
4687
+ for (const collection of collections) {
4688
+ if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4689
+ const keys = getPrimaryKeys(collection, registry);
4690
+ if (keys.length === 0) continue;
4691
+ if (keys.length === 1 && keys[0].fieldName === "id") continue;
4692
+ findings.push({
4693
+ collection,
4694
+ keys,
4695
+ shadowedByIdProperty: Boolean(collection.properties?.id)
4696
+ });
4697
+ }
4698
+ return findings;
4699
+ }
4700
+ /**
4701
+ * Report the collections from {@link findUnresolvableKeyCollections} at boot,
4702
+ * with the edit that fixes each one.
4703
+ *
4704
+ * Grouped by failure, not by collection: the shadowed case is a routing bug and
4705
+ * the silent case is a missing feature, and they deserve different urgency.
4706
+ */
4707
+ function warnOnKeysTheAdminCannotResolve(collections, registry) {
4708
+ const findings = findUnresolvableKeyCollections(collections, registry);
4709
+ if (findings.length === 0) return;
4710
+ 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"}\``;
4711
+ const shadowed = findings.filter((f) => f.shadowedByIdProperty);
4712
+ const silent = findings.filter((f) => !f.shadowedByIdProperty);
4713
+ 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");
4714
+ 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");
4715
+ }
4716
+ /**
4717
+ * The address of a row: derived from the collection's primary keys, because a
4718
+ * row does not carry one — it is exactly its columns.
4719
+ *
4720
+ * Falls back to a literal `id` column, for a row that reached us from somewhere
4721
+ * other than this driver. Returns `""` when there is no key and no `id` —
4722
+ * callers decide what that means, since "unaddressable" is a different answer
4723
+ * in a notification (broadcast a wildcard) than in a save (fail).
4724
+ */
4725
+ function deriveRowAddress(row, collection, registry) {
4726
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4727
+ if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4728
+ if (row.id !== void 0 && row.id !== null) return String(row.id);
4729
+ return "";
4730
+ }
4578
4731
  //#endregion
4579
4732
  //#region src/utils/drizzle-conditions.ts
4580
4733
  /**
@@ -5113,6 +5266,7 @@ var DrizzleConditionBuilder = class {
5113
5266
  static buildVectorSearchConditions(table, vectorSearch) {
5114
5267
  const column = table[vectorSearch.property];
5115
5268
  if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
5269
+ 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
5270
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5117
5271
  const distanceFn = vectorSearch.distance || "cosine";
5118
5272
  let operator;
@@ -5196,12 +5350,10 @@ function serializeDataToServer(row, properties, collection, registry) {
5196
5350
  continue;
5197
5351
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
5198
5352
  const serializedValue = serializePropertyToServer(effectiveValue, property);
5199
- const pks = getPrimaryKeys(collection, registry);
5200
5353
  inverseRelationUpdates.push({
5201
5354
  relationKey: key,
5202
5355
  relation,
5203
- newValue: serializedValue,
5204
- currentId: row.id || buildCompositeId(row, pks)
5356
+ newValue: serializedValue
5205
5357
  });
5206
5358
  continue;
5207
5359
  } else if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
@@ -5211,15 +5363,11 @@ function serializeDataToServer(row, properties, collection, registry) {
5211
5363
  relation,
5212
5364
  newTargetId: serializedValue
5213
5365
  });
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
- }
5366
+ else inverseRelationUpdates.push({
5367
+ relationKey: key,
5368
+ relation,
5369
+ newValue: serializedValue
5370
+ });
5223
5371
  continue;
5224
5372
  } else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
5225
5373
  const serializedValue = serializePropertyToServer(effectiveValue, property);
@@ -5619,6 +5767,63 @@ var RelationService = class {
5619
5767
  this.registry = registry;
5620
5768
  }
5621
5769
  /**
5770
+ * One target row, as the {@link RelatedRow} everything here returns.
5771
+ *
5772
+ * Eight sites built this by hand, which is how the address came to be the
5773
+ * target's first key column in all eight — one edit, eight places to miss.
5774
+ *
5775
+ * `resolveNested` is the one thing they did not agree on, and the
5776
+ * disagreement was invisible: the single-parent fetches pass `db` and
5777
+ * `registry` to `parseDataFromServer`, so the target's *own* relations get
5778
+ * resolved too, while the batch paths deliberately do not — a query per
5779
+ * target row is the N+1 the batching exists to avoid. Naming the parameter
5780
+ * makes that a decision rather than a difference between two call sites
5781
+ * nobody was comparing.
5782
+ */
5783
+ async toRelatedRow(targetRow, targetCollection, targetPks, options) {
5784
+ const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
5785
+ return {
5786
+ id: buildCompositeId(targetRow, targetPks),
5787
+ path: targetCollection.slug,
5788
+ values
5789
+ };
5790
+ }
5791
+ /**
5792
+ * A WHERE matching any of `parentIds`, by the whole key.
5793
+ *
5794
+ * A single key is an `IN (…)`. A composite one cannot be: matching
5795
+ * `tenant_id IN (1, 1)` collects every row of tenant 1, so two parents that
5796
+ * share their first column each receive the other's relations. It becomes
5797
+ * an OR of ANDs — one exact address per parent — which Postgres indexes the
5798
+ * same way it would a multi-column key lookup.
5799
+ */
5800
+ parentKeyCondition(parentTable, parentPks, parentIds) {
5801
+ const columnFor = (fieldName) => {
5802
+ const col = parentTable[fieldName];
5803
+ if (!col) throw new Error(`Key column '${fieldName}' not found in parent table`);
5804
+ return col;
5805
+ };
5806
+ if (parentPks.length === 1) {
5807
+ const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
5808
+ return inArray(columnFor(parentPks[0].fieldName), values);
5809
+ }
5810
+ return or(...parentIds.map((id) => {
5811
+ const values = parseIdValues(id, parentPks);
5812
+ return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
5813
+ }));
5814
+ }
5815
+ /**
5816
+ * Reject a relation that cannot express a composite-keyed parent.
5817
+ *
5818
+ * `localKey` and `foreignKeyOnTarget` are single column names: one column
5819
+ * cannot reference a two-column key, so such a relation has no correct
5820
+ * reading. Left alone it would silently match on the first key column and
5821
+ * hand a tenant's rows to its neighbour — say so instead.
5822
+ */
5823
+ assertSingleKeyAddressable(parentCollection, parentPks, via) {
5824
+ 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.`);
5825
+ }
5826
+ /**
5622
5827
  * Fetch rows related to a parent row through a specific relation
5623
5828
  */
5624
5829
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5637,9 +5842,9 @@ var RelationService = class {
5637
5842
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5638
5843
  const targetCollection = relation.target();
5639
5844
  const targetTable = getTableForCollection(targetCollection, this.registry);
5640
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
5845
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5641
5846
  const idField = targetTable[idInfo[0].fieldName];
5642
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5847
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5643
5848
  const parentIdInfo = parentPks[0];
5644
5849
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5645
5850
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5663,7 +5868,7 @@ var RelationService = class {
5663
5868
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5664
5869
  currentTable = joinTable;
5665
5870
  }
5666
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5871
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5667
5872
  query = query.where(eq(parentIdField, parsedParentId));
5668
5873
  if (options.limit) query = query.limit(options.limit);
5669
5874
  const results = await query;
@@ -5671,13 +5876,7 @@ var RelationService = class {
5671
5876
  const rows = [];
5672
5877
  for (const row of results) {
5673
5878
  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
- });
5879
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5681
5880
  }
5682
5881
  return rows;
5683
5882
  }
@@ -5696,13 +5895,7 @@ var RelationService = class {
5696
5895
  const rows = [];
5697
5896
  for (const row of results) {
5698
5897
  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
- });
5898
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5706
5899
  }
5707
5900
  return rows;
5708
5901
  }
@@ -5719,8 +5912,8 @@ var RelationService = class {
5719
5912
  }
5720
5913
  const targetCollection = relation.target();
5721
5914
  const targetTable = getTableForCollection(targetCollection, this.registry);
5722
- const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5723
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5915
+ const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5724
5917
  const parentIdInfo = parentPks[0];
5725
5918
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5726
5919
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5739,9 +5932,10 @@ var RelationService = class {
5739
5932
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5740
5933
  const targetCollection = relation.target();
5741
5934
  const targetTable = getTableForCollection(targetCollection, this.registry);
5742
- const targetIdInfo = getPrimaryKeys(targetCollection, this.registry)[0];
5935
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5936
+ const targetIdInfo = targetPks[0];
5743
5937
  const targetIdField = targetTable[targetIdInfo.fieldName];
5744
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5938
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5745
5939
  const parentIdInfo = parentPks[0];
5746
5940
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5747
5941
  if (!parentTable) throw new Error("Parent table not found");
@@ -5765,25 +5959,19 @@ var RelationService = class {
5765
5959
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5766
5960
  currentTable = joinTable;
5767
5961
  }
5768
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5769
- query = query.where(inArray(parentIdField, parsedParentIds));
5962
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5770
5963
  const results = await query;
5771
5964
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5772
5965
  const resultMap = /* @__PURE__ */ new Map();
5773
5966
  for (const row of results) {
5774
5967
  const parentRow = row[getTableName$1(parentCollection)] || row;
5775
5968
  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
- });
5969
+ resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5783
5970
  }
5784
5971
  return resultMap;
5785
5972
  }
5786
5973
  if (relation.direction === "owning" && relation.localKey) {
5974
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5787
5975
  const localKeyCol = parentTable[relation.localKey];
5788
5976
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5789
5977
  const fkRows = await this.db.select({
@@ -5812,17 +6000,11 @@ var RelationService = class {
5812
6000
  const resultMap = /* @__PURE__ */ new Map();
5813
6001
  for (const [parentIdStr, fkValue] of parentToFk) {
5814
6002
  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
- }
6003
+ if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5823
6004
  }
5824
6005
  return resultMap;
5825
6006
  }
6007
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5826
6008
  let query = this.db.select().from(targetTable).$dynamic();
5827
6009
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5828
6010
  const results = await query;
@@ -5833,14 +6015,7 @@ var RelationService = class {
5833
6015
  let parentId;
5834
6016
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5835
6017
  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
- }
6018
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5844
6019
  }
5845
6020
  return resultMap;
5846
6021
  }
@@ -5854,9 +6029,9 @@ var RelationService = class {
5854
6029
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5855
6030
  const targetCollection = relation.target();
5856
6031
  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);
6032
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6033
+ const targetIdField = targetTable[targetPks[0].fieldName];
6034
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5860
6035
  const parentIdInfo = parentPks[0];
5861
6036
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5862
6037
  if (!parentTable) throw new Error("Parent table not found");
@@ -5878,27 +6053,22 @@ var RelationService = class {
5878
6053
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5879
6054
  currentTable = joinTable;
5880
6055
  }
5881
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5882
- query = query.where(inArray(parentIdField, parsedParentIds));
6056
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5883
6057
  const results = await query;
5884
6058
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5885
6059
  const resultMap = /* @__PURE__ */ new Map();
5886
6060
  for (const row of results) {
5887
6061
  const parentRow = row[getTableName$1(parentCollection)] || row;
5888
6062
  const targetRow = row[targetTableName] || row;
5889
- const parentId = String(parentRow[parentIdInfo.fieldName]);
5890
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6063
+ const parentId = buildCompositeId(parentRow, parentPks);
5891
6064
  const arr = resultMap.get(parentId) || [];
5892
- arr.push({
5893
- id: String(targetRow[targetIdInfo.fieldName]),
5894
- path: targetCollection.slug,
5895
- values: parsedValues
5896
- });
6065
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5897
6066
  resultMap.set(parentId, arr);
5898
6067
  }
5899
6068
  return resultMap;
5900
6069
  }
5901
6070
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
+ this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
5902
6072
  const junctionTable = this.registry.getTable(relation.through.table);
5903
6073
  if (!junctionTable) {
5904
6074
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -5917,17 +6087,13 @@ var RelationService = class {
5917
6087
  const junctionData = row[relation.through.table] || row;
5918
6088
  const targetData = row[targetTableName] || row;
5919
6089
  const parentId = String(junctionData[relation.through.sourceColumn]);
5920
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
5921
6090
  const arr = resultMap.get(parentId) || [];
5922
- arr.push({
5923
- id: String(targetData[targetIdInfo.fieldName]),
5924
- path: targetCollection.slug,
5925
- values: parsedValues
5926
- });
6091
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
5927
6092
  resultMap.set(parentId, arr);
5928
6093
  }
5929
6094
  return resultMap;
5930
6095
  }
6096
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5931
6097
  let query = this.db.select().from(targetTable).$dynamic();
5932
6098
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5933
6099
  const results = await query;
@@ -5940,14 +6106,9 @@ var RelationService = class {
5940
6106
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5941
6107
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5942
6108
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5943
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5944
6109
  const key = String(parentId);
5945
6110
  const arr = resultMap.get(key) || [];
5946
- arr.push({
5947
- id: String(targetRow[targetIdInfo.fieldName]),
5948
- path: targetCollection.slug,
5949
- values: parsedValues
5950
- });
6111
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5951
6112
  resultMap.set(key, arr);
5952
6113
  }
5953
6114
  }
@@ -5995,12 +6156,12 @@ var RelationService = class {
5995
6156
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
5996
6157
  continue;
5997
6158
  }
5998
- const parentPks = getPrimaryKeys(collection, this.registry);
6159
+ const parentPks = requirePrimaryKeys(collection, this.registry);
5999
6160
  const parentIdInfo = parentPks[0];
6000
6161
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6001
6162
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6002
6163
  if (targetEntityIds.length > 0) {
6003
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6164
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6004
6165
  const targetIdInfo = targetPks[0];
6005
6166
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6006
6167
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6020,12 +6181,12 @@ var RelationService = class {
6020
6181
  logger.warn(`Junction columns not found for relation '${key}'`);
6021
6182
  continue;
6022
6183
  }
6023
- const parentPks = getPrimaryKeys(collection, this.registry);
6184
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6024
6185
  const parentIdInfo = parentPks[0];
6025
6186
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6026
6187
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6027
6188
  if (targetEntityIds.length > 0) {
6028
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6189
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6029
6190
  const targetIdInfo = targetPks[0];
6030
6191
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6031
6192
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6036,7 +6197,7 @@ var RelationService = class {
6036
6197
  } 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
6198
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
6038
6199
  const targetTable = getTableForCollection(targetCollection, this.registry);
6039
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6200
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6040
6201
  const targetIdInfo = targetPks[0];
6041
6202
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6042
6203
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -6044,7 +6205,7 @@ var RelationService = class {
6044
6205
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
6045
6206
  continue;
6046
6207
  }
6047
- const parentPks = getPrimaryKeys(collection, this.registry);
6208
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6048
6209
  const parentIdInfo = parentPks[0];
6049
6210
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6050
6211
  if (targetEntityIds.length > 0) {
@@ -6064,9 +6225,9 @@ var RelationService = class {
6064
6225
  try {
6065
6226
  const targetCollection = relation.target();
6066
6227
  const targetTable = getTableForCollection(targetCollection, this.registry);
6067
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6228
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6068
6229
  const targetIdInfo = targetPks[0];
6069
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6230
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6070
6231
  const sourceIdInfo = sourcePks[0];
6071
6232
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
6072
6233
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -6147,12 +6308,12 @@ var RelationService = class {
6147
6308
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
6148
6309
  return;
6149
6310
  }
6150
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6311
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6151
6312
  const sourceIdInfo = sourcePks[0];
6152
6313
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6153
6314
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6154
6315
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6155
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6316
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6156
6317
  const targetIdInfo = targetPks[0];
6157
6318
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6158
6319
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6160,7 +6321,7 @@ var RelationService = class {
6160
6321
  }));
6161
6322
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
6162
6323
  } else if (newValue && !Array.isArray(newValue)) {
6163
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6324
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6164
6325
  const targetIdInfo = targetPks[0];
6165
6326
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
6166
6327
  const newLink = {
@@ -6191,12 +6352,12 @@ var RelationService = class {
6191
6352
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
6192
6353
  return;
6193
6354
  }
6194
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6355
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6195
6356
  const sourceIdInfo = sourcePks[0];
6196
6357
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6197
6358
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6198
6359
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6199
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6360
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6200
6361
  const targetIdInfo = targetPks[0];
6201
6362
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6202
6363
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6217,12 +6378,12 @@ var RelationService = class {
6217
6378
  const { relation, newTargetId } = upd;
6218
6379
  const targetCollection = relation.target();
6219
6380
  const targetTable = getTableForCollection(targetCollection, this.registry);
6220
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6381
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6221
6382
  const targetIdInfo = targetPks[0];
6222
6383
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6223
6384
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
6224
6385
  const parentTable = getTableForCollection(parentCollection, this.registry);
6225
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6386
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
6226
6387
  const parentIdInfo = parentPks[0];
6227
6388
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
6228
6389
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -6293,7 +6454,7 @@ var RelationService = class {
6293
6454
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
6294
6455
  return;
6295
6456
  }
6296
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6457
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6297
6458
  const targetIdInfo = targetPks[0];
6298
6459
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
6299
6460
  const junctionData = {
@@ -6309,6 +6470,131 @@ var RelationService = class {
6309
6470
  }
6310
6471
  };
6311
6472
  //#endregion
6473
+ //#region src/services/row-pipeline.ts
6474
+ /**
6475
+ * Whether a many-relation reaches its target through a junction table.
6476
+ *
6477
+ * Also used to build the drizzle `with` config, which is why it is exported:
6478
+ * the query has to nest one level deeper for a junction, and the row walk has
6479
+ * to unwrap that same level back out.
6480
+ */
6481
+ function isJunctionRelation(relation) {
6482
+ if (relation.through) return true;
6483
+ if (relation.joinPath && relation.joinPath.length > 1) return true;
6484
+ return false;
6485
+ }
6486
+ /**
6487
+ * Reach the target row inside a junction row.
6488
+ *
6489
+ * A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
6490
+ * the foreign keys, and the target nested under one of them. The target is the
6491
+ * only object among them, so that is how it is found. A junction row that has
6492
+ * not been nested (no `with` on the join) has no object and is returned as-is.
6493
+ */
6494
+ function unwrapJunctionRow(item) {
6495
+ const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
6496
+ return nestedKey ? item[nestedKey] : item;
6497
+ }
6498
+ /**
6499
+ * Give back the number a `number` property was declared to be.
6500
+ *
6501
+ * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
6502
+ * numeric can hold more precision than a double. But the collection declared
6503
+ * this property `number` and the OpenAPI spec this server publishes says
6504
+ * `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
6505
+ * asymmetrically, because a create answers with the number and the read that
6506
+ * follows answers with the string. Any client that multiplies a price works
6507
+ * until the first refresh.
6508
+ *
6509
+ * Declaring a property `number` already accepts double precision — that is what
6510
+ * the admin has always parsed it to. Columns REST serves that no property
6511
+ * declares are left exactly as the database returned them.
6512
+ */
6513
+ function coerceDeclaredNumber(value, property) {
6514
+ if (property?.type !== "number" || typeof value !== "string") return value;
6515
+ const parsed = parseFloat(value);
6516
+ return isNaN(parsed) ? null : parsed;
6517
+ }
6518
+ /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
6519
+ function coerceDeclaredNumbers(row, collection) {
6520
+ const properties = collection.properties;
6521
+ if (!properties) return row;
6522
+ const out = {};
6523
+ for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
6524
+ return out;
6525
+ }
6526
+ /** Render one target row in the requested style. */
6527
+ function renderTarget(targetRow, targetCollection, style, registry) {
6528
+ if (style === "inline") return coerceDeclaredNumbers({ ...targetRow }, targetCollection);
6529
+ const address = relationTargetAddress(targetRow, targetCollection, registry);
6530
+ const path = targetCollection.slug;
6531
+ return createRelationRefWithData(address, path, {
6532
+ id: address,
6533
+ path,
6534
+ values: normalizeDbValues(targetRow, targetCollection)
6535
+ });
6536
+ }
6537
+ /**
6538
+ * The address a relation ref points at.
6539
+ *
6540
+ * The whole key, not its first column: a composite-keyed target addressed by
6541
+ * `tenant_id` alone points at every row that shares it. A target whose key
6542
+ * cannot be resolved at all used to throw here — reading `[0]` of an empty
6543
+ * array — taking down the parent's fetch over a relation it may not even have
6544
+ * asked for. The first column is a guess, but a ref that resolves to nothing
6545
+ * beats no rows at all.
6546
+ */
6547
+ function relationTargetAddress(targetRow, targetCollection, registry) {
6548
+ const address = deriveRowAddress(targetRow, targetCollection, registry);
6549
+ if (address) return address;
6550
+ return String(targetRow[Object.keys(targetRow)[0]] ?? "");
6551
+ }
6552
+ /**
6553
+ * The row the admin renders: every column, with relations as references.
6554
+ *
6555
+ * Values are normalized (dates, numbers, NaN) because the admin's view-model
6556
+ * expects real types. The row's own address is *not* among the columns — it is
6557
+ * derived by the consumer from the collection's primary keys.
6558
+ */
6559
+ function toCmsRow(row, collection, registry) {
6560
+ const resolvedRelations = resolveCollectionRelations(collection);
6561
+ const normalized = normalizeDbValues(row, collection);
6562
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
6563
+ const relData = row[relation.relationName || key];
6564
+ if (relData === void 0 || relData === null) continue;
6565
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
6566
+ const targetCollection = relation.target();
6567
+ normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6568
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6569
+ }
6570
+ return normalized;
6571
+ }
6572
+ /**
6573
+ * The row REST serves: every column under its own name, with the value Postgres
6574
+ * returned, and relations inlined as the target's columns.
6575
+ *
6576
+ * Values are the ones the database returned, except where that contradicts the
6577
+ * declared type: a `number` property is served as a number (see
6578
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6579
+ * JSON has its own opinions about dates that the admin's view-model does not
6580
+ * share.
6581
+ *
6582
+ * Keyed by the row rather than by the relation list — a REST fetch only loads
6583
+ * the relations `include` asked for, so the row is the authority on which are
6584
+ * actually there.
6585
+ */
6586
+ function toRestRow(row, collection, registry) {
6587
+ const resolvedRelations = resolveCollectionRelations(collection);
6588
+ const flat = {};
6589
+ for (const [key, value] of Object.entries(row)) {
6590
+ const relation = findRelation(resolvedRelations, key);
6591
+ if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6592
+ else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6593
+ else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6594
+ }
6595
+ return flat;
6596
+ }
6597
+ //#endregion
6312
6598
  //#region src/services/FetchService.ts
6313
6599
  /**
6314
6600
  * Service for handling all row read operations.
@@ -6367,7 +6653,7 @@ var FetchService = class {
6367
6653
  if (!shouldInclude(key)) continue;
6368
6654
  const drizzleRelName = relation.relationName || key;
6369
6655
  if (relation.joinPath && relation.joinPath.length > 0) continue;
6370
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6656
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
6371
6657
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
6372
6658
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
6373
6659
  else withConfig[drizzleRelName] = true;
@@ -6376,14 +6662,6 @@ var FetchService = class {
6376
6662
  return withConfig;
6377
6663
  }
6378
6664
  /**
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
6665
  * Get the Drizzle relation name on the junction table that points to the actual target row.
6388
6666
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
6389
6667
  */
@@ -6392,54 +6670,6 @@ var FetchService = class {
6392
6670
  return null;
6393
6671
  }
6394
6672
  /**
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
6673
  * Post-fetch joinPath relations for a single flat row.
6444
6674
  * joinPath relations cannot be expressed via Drizzle's `with` config,
6445
6675
  * so they must be loaded separately after the primary query.
@@ -6460,31 +6690,6 @@ var FetchService = class {
6460
6690
  await Promise.all(promises);
6461
6691
  }
6462
6692
  /**
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
6693
  * Resolves joinPath relations for raw REST rows and directly injects them.
6489
6694
  * Uses RelationService to query the database and maps results back to the flattened objects.
6490
6695
  */
@@ -6495,63 +6700,29 @@ var FetchService = class {
6495
6700
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
6496
6701
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6497
6702
  if (joinPathRelations.length === 0) return;
6498
- const idInfo = idInfoArray[0];
6703
+ const parentIdOf = (row) => {
6704
+ const address = buildCompositeId(row, idInfoArray);
6705
+ return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6706
+ };
6499
6707
  for (const [key, relation] of joinPathRelations) try {
6500
- const rowIds = rows.map((r) => {
6501
- return parseIdValues(String(r.id), idInfoArray)[idInfo.fieldName];
6502
- });
6708
+ const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6709
+ if (addressable.length === 0) continue;
6710
+ const rowIds = addressable.map((r) => parentIdOf(r));
6503
6711
  if (relation.cardinality === "one") {
6504
6712
  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;
6713
+ for (const row of addressable) {
6714
+ const relatedRow = resultMap.get(String(parentIdOf(row)));
6715
+ row[key] = relatedRow ? { ...relatedRow.values } : null;
6513
6716
  }
6514
6717
  } else if (relation.cardinality === "many") {
6515
6718
  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
- }
6719
+ for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
6523
6720
  }
6524
6721
  } catch (e) {
6525
6722
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
6526
6723
  }
6527
6724
  }
6528
6725
  /**
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
6726
  * Build db.query-compatible options from standard fetch options.
6556
6727
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6557
6728
  */
@@ -6621,7 +6792,7 @@ var FetchService = class {
6621
6792
  async fetchOne(collectionPath, id, databaseId) {
6622
6793
  const collection = getCollectionByPath(collectionPath, this.registry);
6623
6794
  const table = getTableForCollection(collection, this.registry);
6624
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6795
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6625
6796
  const idInfo = idInfoArray[0];
6626
6797
  const idField = table[idInfo.fieldName];
6627
6798
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6635,7 +6806,7 @@ var FetchService = class {
6635
6806
  with: withConfig
6636
6807
  });
6637
6808
  if (!row) return void 0;
6638
- const flatRow = this.drizzleResultToRow(row, collection);
6809
+ const flatRow = toCmsRow(row, collection, this.registry);
6639
6810
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6640
6811
  return flatRow;
6641
6812
  } catch (e) {
@@ -6677,7 +6848,7 @@ var FetchService = class {
6677
6848
  async fetchRowsWithConditions(collectionPath, options = {}) {
6678
6849
  const collection = getCollectionByPath(collectionPath, this.registry);
6679
6850
  const table = getTableForCollection(collection, this.registry);
6680
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6851
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6681
6852
  const idInfo = idInfoArray[0];
6682
6853
  const idField = table[idInfo.fieldName];
6683
6854
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6687,7 +6858,7 @@ var FetchService = class {
6687
6858
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6688
6859
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6689
6860
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6690
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6861
+ return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6691
6862
  } catch (e) {
6692
6863
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6693
6864
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6748,8 +6919,10 @@ var FetchService = class {
6748
6919
  }
6749
6920
  /**
6750
6921
  * Fallback path used when db.query is unavailable.
6751
- * The primary path uses drizzleResultToRow which handles relation
6752
- * mapping without N+1 queries.
6922
+ *
6923
+ * The primary path runs the results through `toCmsRow`, which maps
6924
+ * relations from what drizzle already nested — no query per row. This one
6925
+ * has no nesting to read, so it resolves relations itself, in batches.
6753
6926
  *
6754
6927
  * Process raw database results into flat rows with relations.
6755
6928
  */
@@ -6829,10 +7002,7 @@ var FetchService = class {
6829
7002
  const relationKey = pathSegments[i];
6830
7003
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
6831
7004
  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
- }));
7005
+ if (i === pathSegments.length - 1) return (await this.relationService.fetchRelatedEntities(currentCollection.slug, currentId, relationKey, options)).map((row) => ({ ...row.values }));
6836
7006
  if (i + 1 < pathSegments.length) {
6837
7007
  const nextEntityId = pathSegments[i + 1];
6838
7008
  currentCollection = relation.target();
@@ -6894,7 +7064,7 @@ var FetchService = class {
6894
7064
  if (value === void 0 || value === null) return true;
6895
7065
  const collection = getCollectionByPath(collectionPath, this.registry);
6896
7066
  const table = getTableForCollection(collection, this.registry);
6897
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7067
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6898
7068
  const idInfo = idInfoArray[0];
6899
7069
  const idField = table[idInfo.fieldName];
6900
7070
  const field = table[fieldName];
@@ -6921,7 +7091,7 @@ var FetchService = class {
6921
7091
  async fetchCollectionForRest(collectionPath, options = {}, include) {
6922
7092
  const collection = getCollectionByPath(collectionPath, this.registry);
6923
7093
  const table = getTableForCollection(collection, this.registry);
6924
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7094
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6925
7095
  const idInfo = idInfoArray[0];
6926
7096
  const idField = table[idInfo.fieldName];
6927
7097
  const tableName = getTableName(table);
@@ -6929,7 +7099,7 @@ var FetchService = class {
6929
7099
  if (qb && !options.searchString && !options.vectorSearch) try {
6930
7100
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6931
7101
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6932
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
7102
+ const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6933
7103
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6934
7104
  return restRows;
6935
7105
  } catch (e) {
@@ -6978,7 +7148,7 @@ var FetchService = class {
6978
7148
  async fetchOneForRest(collectionPath, id, include, databaseId) {
6979
7149
  const collection = getCollectionByPath(collectionPath, this.registry);
6980
7150
  const table = getTableForCollection(collection, this.registry);
6981
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7151
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6982
7152
  const idInfo = idInfoArray[0];
6983
7153
  const idField = table[idInfo.fieldName];
6984
7154
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -6991,7 +7161,7 @@ var FetchService = class {
6991
7161
  ...withConfig ? { with: withConfig } : {}
6992
7162
  });
6993
7163
  if (!row) return null;
6994
- const restRow = this.drizzleResultToRestRow(row, collection);
7164
+ const restRow = toRestRow(row, collection, this.registry);
6995
7165
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
6996
7166
  return restRow;
6997
7167
  } catch (e) {
@@ -7036,7 +7206,7 @@ var FetchService = class {
7036
7206
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
7037
7207
  const collection = getCollectionByPath(collectionPath, this.registry);
7038
7208
  const table = getTableForCollection(collection, this.registry);
7039
- const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7209
+ const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
7040
7210
  let vectorMeta;
7041
7211
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
7042
7212
  let query = vectorMeta ? this.db.select({
@@ -7525,7 +7695,7 @@ var PersistService = class {
7525
7695
  } catch (error) {
7526
7696
  throw this.toUserFriendlyError(error, collection.slug);
7527
7697
  }
7528
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7698
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7529
7699
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7530
7700
  return finalEntity;
7531
7701
  }
@@ -8444,12 +8614,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8444
8614
  let updatedValues = values;
8445
8615
  const contextForCallback = this.buildCallContext();
8446
8616
  let previousValuesForHistory;
8447
- if (status === "existing" && id) {
8448
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8617
+ if (status === "existing" && id) try {
8618
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8449
8619
  if (existing) {
8450
8620
  const { id: _existingId, ...existingValues } = existing;
8451
8621
  previousValuesForHistory = existingValues;
8452
8622
  }
8623
+ } catch (err) {
8624
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8453
8625
  }
8454
8626
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8455
8627
  if (globalCallbacks?.beforeSave) {
@@ -8517,8 +8689,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8517
8689
  context: contextForCallback
8518
8690
  });
8519
8691
  }
8520
- const savedId = savedRow.id;
8521
- const { id: _savedId, ...savedValues } = savedRow;
8692
+ const savedId = deriveRowAddress(savedRow, resolvedCollection ?? collection, this.registry);
8693
+ const savedValues = savedRow;
8522
8694
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
8523
8695
  if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
8524
8696
  collection: resolvedCollection,
@@ -8550,7 +8722,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8550
8722
  }
8551
8723
  if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
8552
8724
  tableName: path,
8553
- id: savedId.toString(),
8725
+ id: savedId,
8554
8726
  action: status === "new" ? "create" : "update",
8555
8727
  values: savedValues,
8556
8728
  previousValues: previousValuesForHistory,
@@ -8558,11 +8730,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8558
8730
  });
8559
8731
  if (this._deferNotifications) this._pendingNotifications.push({
8560
8732
  path,
8561
- id: savedId.toString(),
8733
+ id: savedId,
8562
8734
  row: savedRow,
8563
8735
  databaseId: resolvedCollection?.databaseId
8564
8736
  });
8565
- else await this.realtimeService.notifyUpdate(path, savedId.toString(), savedRow, resolvedCollection?.databaseId);
8737
+ else await this.realtimeService.notifyUpdate(path, savedId, savedRow, resolvedCollection?.databaseId);
8566
8738
  return savedRow;
8567
8739
  } catch (error) {
8568
8740
  if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
@@ -8642,10 +8814,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8642
8814
  }
8643
8815
  async delete({ row, collection }) {
8644
8816
  const targetPath = row.path;
8645
- const targetRow = {
8646
- id: row.id,
8647
- ...row.values ?? {}
8648
- };
8817
+ const targetRow = { ...row.values ?? {} };
8649
8818
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
8650
8819
  const contextForCallback = this.buildCallContext();
8651
8820
  if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
@@ -9065,6 +9234,7 @@ var DatabasePoolManager = class {
9065
9234
  pool.on("error", (err) => {
9066
9235
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9067
9236
  });
9237
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9068
9238
  this.pools.set(databaseName, pool);
9069
9239
  return pool;
9070
9240
  }
@@ -10328,7 +10498,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10328
10498
  startAfter: request.startAfter,
10329
10499
  searchString: request.searchString
10330
10500
  }, authContext);
10331
- this.sendCollectionUpdate(clientId, subscriptionId, rows);
10501
+ this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
10332
10502
  } catch (error) {
10333
10503
  const sanitized = sanitizeErrorForClient(error, request.path);
10334
10504
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10429,7 +10599,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10429
10599
  if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
10430
10600
  else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
10431
10601
  else if (subscription.type === "collection" && subscription.collectionRequest) {
10432
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row);
10602
+ if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
10433
10603
  this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
10434
10604
  }
10435
10605
  } catch (error) {
@@ -10459,7 +10629,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10459
10629
  if (!this._subscriptions.has(subscriptionId)) return;
10460
10630
  try {
10461
10631
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
10462
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows);
10632
+ this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
10463
10633
  } catch (error) {
10464
10634
  const sanitized = sanitizeErrorForClient(error, notifyPath);
10465
10635
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -10673,11 +10843,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10673
10843
  }
10674
10844
  return await this.dataService.fetchOne(notifyPath, id);
10675
10845
  }
10676
- sendCollectionUpdate(clientId, subscriptionId, rows) {
10846
+ sendCollectionUpdate(clientId, subscriptionId, rows, path) {
10677
10847
  const message = {
10678
10848
  type: "collection_update",
10679
10849
  subscriptionId,
10680
- rows
10850
+ rows,
10851
+ pks: this.primaryKeysForPath(path)
10681
10852
  };
10682
10853
  this.sendMessage(clientId, message);
10683
10854
  }
@@ -10692,16 +10863,33 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10692
10863
  /**
10693
10864
  * Send a lightweight row-level patch to a collection subscriber.
10694
10865
  * The client can merge this into its cached data for instant feedback.
10866
+ *
10867
+ * The key columns ride along: the patch names a row by address, and the
10868
+ * client has to find that row among the ones it cached — which carry
10869
+ * columns and no address. The SDK holds no collection config to derive one
10870
+ * from, so this is the only place the mapping can come from.
10695
10871
  */
10696
- sendCollectionPatch(clientId, subscriptionId, id, row) {
10872
+ sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
10697
10873
  const message = {
10698
10874
  type: "collection_patch",
10699
10875
  subscriptionId,
10700
10876
  id,
10701
- row
10877
+ row,
10878
+ pks: this.primaryKeysForPath(notifyPath)
10702
10879
  };
10703
10880
  this.sendMessage(clientId, message);
10704
10881
  }
10882
+ /** The key columns of the collection at `path`, if they can be resolved. */
10883
+ primaryKeysForPath(path) {
10884
+ try {
10885
+ const collection = this.registry.getCollectionByPath(path);
10886
+ if (!collection) return void 0;
10887
+ const keys = getPrimaryKeys(collection, this.registry);
10888
+ return keys.length > 0 ? keys : void 0;
10889
+ } catch {
10890
+ return;
10891
+ }
10892
+ }
10705
10893
  sendError(clientId, error, subscriptionId, code) {
10706
10894
  const message = {
10707
10895
  type: "error",
@@ -10949,12 +11137,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10949
11137
  }
10950
11138
  /** Compute the canonical (possibly composite) id string from a captured row. */
10951
11139
  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 "*";
11140
+ return deriveRowAddress(row, collection, this.registry) || "*";
10958
11141
  }
10959
11142
  dedupKey(path, id, databaseId) {
10960
11143
  return `${databaseId ?? ""}::${path}::${id}`;
@@ -20414,6 +20597,33 @@ function formatBytes(bytes) {
20414
20597
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
20415
20598
  }
20416
20599
  //#endregion
20600
+ //#region src/collections/buildRegistry.ts
20601
+ /**
20602
+ * Build the collection registry for a driver.
20603
+ *
20604
+ * The order matters and is the reason this is one function rather than a run of
20605
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
20606
+ * anything that inspects them has to run *after* the tables are registered —
20607
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
20608
+ * collection whose table it cannot look up is one it has nothing to say about.
20609
+ * Warned too early, it would skip every collection and report nothing, which
20610
+ * reads exactly like having nothing to report.
20611
+ */
20612
+ function buildCollectionRegistry(schema) {
20613
+ const registry = new PostgresCollectionRegistry();
20614
+ if (schema.collections) {
20615
+ registry.registerMultiple(schema.collections);
20616
+ logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
20617
+ }
20618
+ if (schema.tables) Object.values(schema.tables).forEach((table) => {
20619
+ if (isTable(table)) registry.registerTable(table, getTableName(table));
20620
+ });
20621
+ if (schema.enums) registry.registerEnums(schema.enums);
20622
+ if (schema.relations) registry.registerRelations(schema.relations);
20623
+ warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
20624
+ return registry;
20625
+ }
20626
+ //#endregion
20417
20627
  //#region src/auth/ensure-tables.ts
20418
20628
  /**
20419
20629
  * Auto-create auth tables if they don't exist.
@@ -20585,14 +20795,22 @@ async function ensureAuthTablesExist(db, collection) {
20585
20795
  $$ LANGUAGE sql STABLE
20586
20796
  `);
20587
20797
  });
20588
- await db.execute(sql`
20589
- ALTER TABLE ${sql.raw(usersTableName)}
20590
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20591
- `);
20592
- await db.execute(sql`
20593
- ALTER TABLE ${sql.raw(usersTableName)}
20594
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20595
- `);
20798
+ for (const columnDef of [
20799
+ "display_name VARCHAR(255)",
20800
+ "photo_url VARCHAR(500)",
20801
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
20802
+ "password_hash VARCHAR(255)",
20803
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20804
+ "email_verification_token VARCHAR(255)",
20805
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20806
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20807
+ "metadata JSONB DEFAULT '{}' NOT NULL",
20808
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20809
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20810
+ ]) await db.execute(sql`
20811
+ ALTER TABLE ${sql.raw(usersTableName)}
20812
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20813
+ `);
20596
20814
  try {
20597
20815
  if ((await db.execute(sql`
20598
20816
  SELECT EXISTS (
@@ -20663,6 +20881,34 @@ async function ensureAuthTablesExist(db, collection) {
20663
20881
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20664
20882
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20665
20883
  `);
20884
+ try {
20885
+ const authTablePairs = [
20886
+ [usersSchema, resolvedTable],
20887
+ [authSchema, "user_identities"],
20888
+ [authSchema, "refresh_tokens"],
20889
+ [authSchema, "password_reset_tokens"],
20890
+ [authSchema, "app_config"],
20891
+ [authSchema, "mfa_factors"],
20892
+ [authSchema, "mfa_challenges"],
20893
+ [authSchema, "recovery_codes"]
20894
+ ];
20895
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20896
+ SELECT 1
20897
+ FROM pg_class c
20898
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20899
+ WHERE n.nspname = ${schemaName}
20900
+ AND c.relname = ${tableName}
20901
+ AND c.relforcerowsecurity
20902
+ `)).rows.length > 0) {
20903
+ await db.execute(sql`
20904
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20905
+ NO FORCE ROW LEVEL SECURITY
20906
+ `);
20907
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20908
+ }
20909
+ } catch (rlsReconcileError) {
20910
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20911
+ }
20666
20912
  logger.info("✅ Auth tables ready");
20667
20913
  } catch (error) {
20668
20914
  logger.error("❌ Failed to create auth tables", { error });
@@ -20710,6 +20956,31 @@ var UserService = class {
20710
20956
  const name = getTableName(this.usersTable);
20711
20957
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20712
20958
  }
20959
+ /**
20960
+ * Run a privileged auth write with an explicitly cleared RLS context.
20961
+ *
20962
+ * The auth services run on the base/owner connection, which by design
20963
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20964
+ * in the default policies applies. That NULL is normally guaranteed by
20965
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
20966
+ * GUC that survives on a pooled connection (or a connection role that
20967
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20968
+ * turns the trusted write into an RLS-scoped one and denies it with
20969
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20970
+ * single chokepoint, makes the server context deterministic instead of
20971
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20972
+ * NULL via NULLIF, so '' is the server context.
20973
+ */
20974
+ async withServerContext(fn) {
20975
+ return await this.db.transaction(async (tx) => {
20976
+ await tx.execute(sql`
20977
+ SELECT set_config('app.user_id', '', true),
20978
+ set_config('app.user_roles', '', true),
20979
+ set_config('app.jwt', '', true)
20980
+ `);
20981
+ return await fn(tx);
20982
+ });
20983
+ }
20713
20984
  mapRowToUser(row) {
20714
20985
  if (!row) return row;
20715
20986
  const id = row.id ?? row.uid;
@@ -20807,7 +21078,7 @@ var UserService = class {
20807
21078
  }
20808
21079
  async createUser(data) {
20809
21080
  const payload = this.mapPayload(data);
20810
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21081
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20811
21082
  return this.mapRowToUser(row);
20812
21083
  }
20813
21084
  async getUserById(id) {
@@ -20846,12 +21117,12 @@ var UserService = class {
20846
21117
  }));
20847
21118
  }
20848
21119
  async linkUserIdentity(userId, provider, providerId, profileData) {
20849
- await this.db.insert(this.userIdentitiesTable).values({
21120
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20850
21121
  userId,
20851
21122
  provider,
20852
21123
  providerId,
20853
21124
  profileData: profileData || null
20854
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21125
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20855
21126
  }
20856
21127
  async updateUser(id, data) {
20857
21128
  const idCol = getColumn(this.usersTable, "id");
@@ -20859,13 +21130,13 @@ var UserService = class {
20859
21130
  const payload = this.mapPayload(data);
20860
21131
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20861
21132
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20862
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21133
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20863
21134
  return row ? this.mapRowToUser(row) : null;
20864
21135
  }
20865
21136
  async deleteUser(id) {
20866
21137
  const idCol = getColumn(this.usersTable, "id");
20867
21138
  if (!idCol) return;
20868
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21139
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20869
21140
  }
20870
21141
  async listUsers() {
20871
21142
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -20919,10 +21190,10 @@ var UserService = class {
20919
21190
  if (!idCol) return;
20920
21191
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
20921
21192
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20922
- await this.db.update(this.usersTable).set({
21193
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20923
21194
  [passwordHashColKey]: passwordHash,
20924
21195
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20925
- }).where(eq(idCol, id));
21196
+ }).where(eq(idCol, id)));
20926
21197
  }
20927
21198
  /**
20928
21199
  * Set email verification status
@@ -20933,11 +21204,11 @@ var UserService = class {
20933
21204
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
20934
21205
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20935
21206
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20936
- await this.db.update(this.usersTable).set({
21207
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20937
21208
  [emailVerifiedColKey]: verified,
20938
21209
  [emailVerificationTokenColKey]: null,
20939
21210
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20940
- }).where(eq(idCol, id));
21211
+ }).where(eq(idCol, id)));
20941
21212
  }
20942
21213
  /**
20943
21214
  * Set email verification token
@@ -20948,11 +21219,11 @@ var UserService = class {
20948
21219
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
20949
21220
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
20950
21221
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20951
- await this.db.update(this.usersTable).set({
21222
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
20952
21223
  [emailVerificationTokenColKey]: token,
20953
21224
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
20954
21225
  [updatedAtColKey]: /* @__PURE__ */ new Date()
20955
- }).where(eq(idCol, id));
21226
+ }).where(eq(idCol, id)));
20956
21227
  }
20957
21228
  /**
20958
21229
  * Find user by email verification token
@@ -20997,22 +21268,22 @@ var UserService = class {
20997
21268
  async setUserRoles(userId, roleIds) {
20998
21269
  const usersTableName = this.getQualifiedUsersTableName();
20999
21270
  const rolesArray = `{${roleIds.join(",")}}`;
21000
- await this.db.execute(sql`
21271
+ await this.withServerContext(async (db) => db.execute(sql`
21001
21272
  UPDATE ${sql.raw(usersTableName)}
21002
21273
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21003
21274
  WHERE id = ${userId}
21004
- `);
21275
+ `));
21005
21276
  }
21006
21277
  /**
21007
21278
  * Assign a specific role to new user (appends if not present)
21008
21279
  */
21009
21280
  async assignDefaultRole(userId, roleId) {
21010
21281
  const usersTableName = this.getQualifiedUsersTableName();
21011
- await this.db.execute(sql`
21282
+ await this.withServerContext(async (db) => db.execute(sql`
21012
21283
  UPDATE ${sql.raw(usersTableName)}
21013
21284
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21014
21285
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21015
- `);
21286
+ `));
21016
21287
  }
21017
21288
  /**
21018
21289
  * Get user with their roles
@@ -22396,21 +22667,14 @@ function createPostgresBootstrapper(pgConfig) {
22396
22667
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
22397
22668
  }
22398
22669
  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
22670
  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
22671
  const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations;
22413
- if (schemaRelations) registry.registerRelations(schemaRelations);
22672
+ const registry = buildCollectionRegistry({
22673
+ collections: activeCollections,
22674
+ tables: schemaTables,
22675
+ enums: pgConfig.schema?.enums,
22676
+ relations: schemaRelations
22677
+ });
22414
22678
  if (schemaTables) patchPgArrayNullSafety(schemaTables);
22415
22679
  const mergedSchema = {
22416
22680
  ...schemaTables,
@@ -22489,6 +22753,7 @@ function createPostgresBootstrapper(pgConfig) {
22489
22753
  const wantsCdc = cdcMode !== "off";
22490
22754
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22491
22755
  let cdcEnabled = false;
22756
+ let provisionCdcForTables;
22492
22757
  if (wantsCdc && !directUrl) {
22493
22758
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22494
22759
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22505,6 +22770,9 @@ function createPostgresBootstrapper(pgConfig) {
22505
22770
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22506
22771
  await realtimeService.enableCdc(directUrl);
22507
22772
  cdcEnabled = true;
22773
+ provisionCdcForTables = async (tables) => {
22774
+ await provisionTriggerCdc(cdcRunSql, tables);
22775
+ };
22508
22776
  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
22777
  } catch (err) {
22510
22778
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22529,13 +22797,14 @@ function createPostgresBootstrapper(pgConfig) {
22529
22797
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22530
22798
  const missing = [];
22531
22799
  for (const col of registeredCollections) {
22800
+ if (col.auth?.enabled) continue;
22532
22801
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22533
22802
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22534
22803
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22535
22804
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22536
22805
  if (!dbTables.has(fullCheckName)) missing.push({
22537
22806
  slug: col.slug,
22538
- table: checkName
22807
+ table: fullCheckName
22539
22808
  });
22540
22809
  }
22541
22810
  if (missing.length > 0) {
@@ -22569,7 +22838,8 @@ function createPostgresBootstrapper(pgConfig) {
22569
22838
  registry,
22570
22839
  realtimeService,
22571
22840
  driver,
22572
- poolManager
22841
+ poolManager,
22842
+ provisionCdcForTables
22573
22843
  }
22574
22844
  };
22575
22845
  },
@@ -22581,6 +22851,18 @@ function createPostgresBootstrapper(pgConfig) {
22581
22851
  const registry = internals.registry;
22582
22852
  const authCollection = authConfig.collection;
22583
22853
  await ensureAuthTablesExist(db, authCollection);
22854
+ if (authCollection && internals.provisionCdcForTables) {
22855
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22856
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22857
+ if (authTable) try {
22858
+ await internals.provisionCdcForTables([{
22859
+ schema: authSchema,
22860
+ table: authTable
22861
+ }]);
22862
+ } catch (err) {
22863
+ 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) });
22864
+ }
22865
+ }
22584
22866
  let emailService;
22585
22867
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22586
22868
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22651,6 +22933,6 @@ function createPostgresAdapter(pgConfig) {
22651
22933
  };
22652
22934
  }
22653
22935
  //#endregion
22654
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22936
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22655
22937
 
22656
22938
  //# sourceMappingURL=index.es.js.map