@rebasepro/server-postgres 0.9.1-canary.7dddf96 → 0.9.1-canary.97e305f

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,
@@ -4549,8 +4587,26 @@ function getTableForCollection(collection, registry) {
4549
4587
  if (!table) throw new Error(`Table not found for collection '${collection.slug}' (table: ${tableName})`);
4550
4588
  return table;
4551
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
+ */
4552
4609
  function getPrimaryKeys(collection, registry) {
4553
- const table = getTableForCollection(collection, registry);
4554
4610
  if (collection.properties) {
4555
4611
  const idProps = Object.entries(collection.properties).filter(([_, prop]) => "isId" in prop && Boolean(prop.isId)).map(([key, prop]) => ({
4556
4612
  fieldName: key,
@@ -4559,6 +4615,8 @@ function getPrimaryKeys(collection, registry) {
4559
4615
  }));
4560
4616
  if (idProps.length > 0) return idProps;
4561
4617
  }
4618
+ const table = registry.getTable(getTableName$1(collection));
4619
+ if (!table) return [];
4562
4620
  const keys = [];
4563
4621
  for (const [key, colRaw] of Object.entries(table)) {
4564
4622
  const col = colRaw;
@@ -4587,6 +4645,21 @@ function getPrimaryKeys(collection, registry) {
4587
4645
  return keys;
4588
4646
  }
4589
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
+ /**
4590
4663
  * Collections whose key the *browser* cannot resolve, and what it will do
4591
4664
  * instead.
4592
4665
  *
@@ -4613,12 +4686,7 @@ function findUnresolvableKeyCollections(collections, registry) {
4613
4686
  const findings = [];
4614
4687
  for (const collection of collections) {
4615
4688
  if (getDeclaredPrimaryKeys(collection).length > 0) continue;
4616
- let keys;
4617
- try {
4618
- keys = getPrimaryKeys(collection, registry);
4619
- } catch {
4620
- continue;
4621
- }
4689
+ const keys = getPrimaryKeys(collection, registry);
4622
4690
  if (keys.length === 0) continue;
4623
4691
  if (keys.length === 1 && keys[0].fieldName === "id") continue;
4624
4692
  findings.push({
@@ -4649,19 +4717,14 @@ function warnOnKeysTheAdminCannotResolve(collections, registry) {
4649
4717
  * The address of a row: derived from the collection's primary keys, because a
4650
4718
  * row does not carry one — it is exactly its columns.
4651
4719
  *
4652
- * Falls back to a literal `id` column, which covers the two cases where the
4653
- * keys cannot be resolved: a row that reached us from somewhere other than the
4654
- * postgres driver, and a collection whose table the registry cannot look up
4655
- * (`getPrimaryKeys` throws for an unregistered table rather than returning
4656
- * nothing). Returns `""` when there is no key and no `id` — callers decide what
4657
- * that means, since "unaddressable" is a different answer in a notification
4658
- * (broadcast a wildcard) than in a save (fail).
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).
4659
4724
  */
4660
4725
  function deriveRowAddress(row, collection, registry) {
4661
- try {
4662
- const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4663
- if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4664
- } catch {}
4726
+ const composite = buildCompositeId(row, getPrimaryKeys(collection, registry));
4727
+ if (composite && composite.split(":::").some((part) => part !== "")) return composite;
4665
4728
  if (row.id !== void 0 && row.id !== null) return String(row.id);
4666
4729
  return "";
4667
4730
  }
@@ -5203,6 +5266,7 @@ var DrizzleConditionBuilder = class {
5203
5266
  static buildVectorSearchConditions(table, vectorSearch) {
5204
5267
  const column = table[vectorSearch.property];
5205
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");
5206
5270
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5207
5271
  const distanceFn = vectorSearch.distance || "cosine";
5208
5272
  let operator;
@@ -5703,6 +5767,63 @@ var RelationService = class {
5703
5767
  this.registry = registry;
5704
5768
  }
5705
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
+ /**
5706
5827
  * Fetch rows related to a parent row through a specific relation
5707
5828
  */
5708
5829
  async fetchRelatedEntities(parentCollectionPath, parentId, relationKey, options = {}) {
@@ -5721,9 +5842,9 @@ var RelationService = class {
5721
5842
  async fetchEntitiesUsingJoins(parentCollection, parentId, relation, options = {}) {
5722
5843
  const targetCollection = relation.target();
5723
5844
  const targetTable = getTableForCollection(targetCollection, this.registry);
5724
- const idInfo = getPrimaryKeys(targetCollection, this.registry);
5845
+ const idInfo = requirePrimaryKeys(targetCollection, this.registry);
5725
5846
  const idField = targetTable[idInfo[0].fieldName];
5726
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5847
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5727
5848
  const parentIdInfo = parentPks[0];
5728
5849
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5729
5850
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5747,7 +5868,7 @@ var RelationService = class {
5747
5868
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5748
5869
  currentTable = joinTable;
5749
5870
  }
5750
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5871
+ const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
5751
5872
  query = query.where(eq(parentIdField, parsedParentId));
5752
5873
  if (options.limit) query = query.limit(options.limit);
5753
5874
  const results = await query;
@@ -5755,12 +5876,7 @@ var RelationService = class {
5755
5876
  const rows = [];
5756
5877
  for (const row of results) {
5757
5878
  const targetRow = row[targetTableName] || row;
5758
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5759
- rows.push({
5760
- id: buildCompositeId(targetRow, idInfo),
5761
- path: targetCollection.slug,
5762
- values: parsedValues
5763
- });
5879
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5764
5880
  }
5765
5881
  return rows;
5766
5882
  }
@@ -5779,12 +5895,7 @@ var RelationService = class {
5779
5895
  const rows = [];
5780
5896
  for (const row of results) {
5781
5897
  const targetRow = row[getTableName$1(targetCollection)] || row;
5782
- const parsedValues = await parseDataFromServer(targetRow, targetCollection, this.db, this.registry);
5783
- rows.push({
5784
- id: buildCompositeId(targetRow, idInfo),
5785
- path: targetCollection.slug,
5786
- values: parsedValues
5787
- });
5898
+ rows.push(await this.toRelatedRow(targetRow, targetCollection, idInfo, { resolveNested: true }));
5788
5899
  }
5789
5900
  return rows;
5790
5901
  }
@@ -5801,8 +5912,8 @@ var RelationService = class {
5801
5912
  }
5802
5913
  const targetCollection = relation.target();
5803
5914
  const targetTable = getTableForCollection(targetCollection, this.registry);
5804
- const targetIdField = targetTable[getPrimaryKeys(targetCollection, this.registry)[0].fieldName];
5805
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5915
+ const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
5916
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5806
5917
  const parentIdInfo = parentPks[0];
5807
5918
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
5808
5919
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
@@ -5821,10 +5932,10 @@ var RelationService = class {
5821
5932
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5822
5933
  const targetCollection = relation.target();
5823
5934
  const targetTable = getTableForCollection(targetCollection, this.registry);
5824
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
5935
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5825
5936
  const targetIdInfo = targetPks[0];
5826
5937
  const targetIdField = targetTable[targetIdInfo.fieldName];
5827
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
5938
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5828
5939
  const parentIdInfo = parentPks[0];
5829
5940
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5830
5941
  if (!parentTable) throw new Error("Parent table not found");
@@ -5848,25 +5959,19 @@ var RelationService = class {
5848
5959
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5849
5960
  currentTable = joinTable;
5850
5961
  }
5851
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5852
- query = query.where(inArray(parentIdField, parsedParentIds));
5962
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5853
5963
  const results = await query;
5854
5964
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5855
5965
  const resultMap = /* @__PURE__ */ new Map();
5856
5966
  for (const row of results) {
5857
5967
  const parentRow = row[getTableName$1(parentCollection)] || row;
5858
5968
  const targetRow = row[targetTableName] || row;
5859
- const parentId = parentRow[parentIdInfo.fieldName];
5860
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5861
- resultMap.set(String(parentId), {
5862
- id: buildCompositeId(targetRow, targetPks),
5863
- path: targetCollection.slug,
5864
- values: parsedValues
5865
- });
5969
+ resultMap.set(buildCompositeId(parentRow, parentPks), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5866
5970
  }
5867
5971
  return resultMap;
5868
5972
  }
5869
5973
  if (relation.direction === "owning" && relation.localKey) {
5974
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
5870
5975
  const localKeyCol = parentTable[relation.localKey];
5871
5976
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
5872
5977
  const fkRows = await this.db.select({
@@ -5895,17 +6000,11 @@ var RelationService = class {
5895
6000
  const resultMap = /* @__PURE__ */ new Map();
5896
6001
  for (const [parentIdStr, fkValue] of parentToFk) {
5897
6002
  const targetRow = targetById.get(String(fkValue));
5898
- if (targetRow) {
5899
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5900
- resultMap.set(parentIdStr, {
5901
- id: buildCompositeId(targetRow, targetPks),
5902
- path: targetCollection.slug,
5903
- values: parsedValues
5904
- });
5905
- }
6003
+ if (targetRow) resultMap.set(parentIdStr, await this.toRelatedRow(targetRow, targetCollection, targetPks));
5906
6004
  }
5907
6005
  return resultMap;
5908
6006
  }
6007
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
5909
6008
  let query = this.db.select().from(targetTable).$dynamic();
5910
6009
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
5911
6010
  const results = await query;
@@ -5916,14 +6015,7 @@ var RelationService = class {
5916
6015
  let parentId;
5917
6016
  if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
5918
6017
  else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
5919
- if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
5920
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
5921
- resultMap.set(String(parentId), {
5922
- id: buildCompositeId(targetRow, targetPks),
5923
- path: targetCollection.slug,
5924
- values: parsedValues
5925
- });
5926
- }
6018
+ if (parentId !== void 0 && parentIdSet.has(String(parentId))) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
5927
6019
  }
5928
6020
  return resultMap;
5929
6021
  }
@@ -5937,9 +6029,9 @@ var RelationService = class {
5937
6029
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
5938
6030
  const targetCollection = relation.target();
5939
6031
  const targetTable = getTableForCollection(targetCollection, this.registry);
5940
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6032
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
5941
6033
  const targetIdField = targetTable[targetPks[0].fieldName];
5942
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6034
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
5943
6035
  const parentIdInfo = parentPks[0];
5944
6036
  const parentTable = this.registry.getTable(getTableName$1(parentCollection));
5945
6037
  if (!parentTable) throw new Error("Parent table not found");
@@ -5961,27 +6053,22 @@ var RelationService = class {
5961
6053
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
5962
6054
  currentTable = joinTable;
5963
6055
  }
5964
- const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName];
5965
- query = query.where(inArray(parentIdField, parsedParentIds));
6056
+ query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
5966
6057
  const results = await query;
5967
6058
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
5968
6059
  const resultMap = /* @__PURE__ */ new Map();
5969
6060
  for (const row of results) {
5970
6061
  const parentRow = row[getTableName$1(parentCollection)] || row;
5971
6062
  const targetRow = row[targetTableName] || row;
5972
- const parentId = String(parentRow[parentIdInfo.fieldName]);
5973
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6063
+ const parentId = buildCompositeId(parentRow, parentPks);
5974
6064
  const arr = resultMap.get(parentId) || [];
5975
- arr.push({
5976
- id: buildCompositeId(targetRow, targetPks),
5977
- path: targetCollection.slug,
5978
- values: parsedValues
5979
- });
6065
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
5980
6066
  resultMap.set(parentId, arr);
5981
6067
  }
5982
6068
  return resultMap;
5983
6069
  }
5984
6070
  if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
6071
+ this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
5985
6072
  const junctionTable = this.registry.getTable(relation.through.table);
5986
6073
  if (!junctionTable) {
5987
6074
  logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
@@ -6000,17 +6087,13 @@ var RelationService = class {
6000
6087
  const junctionData = row[relation.through.table] || row;
6001
6088
  const targetData = row[targetTableName] || row;
6002
6089
  const parentId = String(junctionData[relation.through.sourceColumn]);
6003
- const parsedValues = await parseDataFromServer(targetData, targetCollection);
6004
6090
  const arr = resultMap.get(parentId) || [];
6005
- arr.push({
6006
- id: buildCompositeId(targetData, targetPks),
6007
- path: targetCollection.slug,
6008
- values: parsedValues
6009
- });
6091
+ arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
6010
6092
  resultMap.set(parentId, arr);
6011
6093
  }
6012
6094
  return resultMap;
6013
6095
  }
6096
+ this.assertSingleKeyAddressable(parentCollection, parentPks, relation.foreignKeyOnTarget ?? `${relation.inverseRelationName}_id`);
6014
6097
  let query = this.db.select().from(targetTable).$dynamic();
6015
6098
  query = applyDynamicRelationQuery(query, query, relation, parsedParentIds, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
6016
6099
  const results = await query;
@@ -6023,14 +6106,9 @@ var RelationService = class {
6023
6106
  else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) parentId = targetRow[relation.foreignKeyOnTarget];
6024
6107
  else if (relation.direction === "inverse" && relation.inverseRelationName) parentId = targetRow[`${relation.inverseRelationName}_id`];
6025
6108
  if (parentId !== void 0 && parentIdSet.has(String(parentId))) {
6026
- const parsedValues = await parseDataFromServer(targetRow, targetCollection);
6027
6109
  const key = String(parentId);
6028
6110
  const arr = resultMap.get(key) || [];
6029
- arr.push({
6030
- id: buildCompositeId(targetRow, targetPks),
6031
- path: targetCollection.slug,
6032
- values: parsedValues
6033
- });
6111
+ arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
6034
6112
  resultMap.set(key, arr);
6035
6113
  }
6036
6114
  }
@@ -6078,12 +6156,12 @@ var RelationService = class {
6078
6156
  logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
6079
6157
  continue;
6080
6158
  }
6081
- const parentPks = getPrimaryKeys(collection, this.registry);
6159
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6082
6160
  const parentIdInfo = parentPks[0];
6083
6161
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6084
6162
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6085
6163
  if (targetEntityIds.length > 0) {
6086
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6164
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6087
6165
  const targetIdInfo = targetPks[0];
6088
6166
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6089
6167
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6103,12 +6181,12 @@ var RelationService = class {
6103
6181
  logger.warn(`Junction columns not found for relation '${key}'`);
6104
6182
  continue;
6105
6183
  }
6106
- const parentPks = getPrimaryKeys(collection, this.registry);
6184
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6107
6185
  const parentIdInfo = parentPks[0];
6108
6186
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6109
6187
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
6110
6188
  if (targetEntityIds.length > 0) {
6111
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6189
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6112
6190
  const targetIdInfo = targetPks[0];
6113
6191
  const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6114
6192
  [sourceJunctionColumn.name]: parsedParentId,
@@ -6119,7 +6197,7 @@ var RelationService = class {
6119
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.`);
6120
6198
  else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
6121
6199
  const targetTable = getTableForCollection(targetCollection, this.registry);
6122
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6200
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6123
6201
  const targetIdInfo = targetPks[0];
6124
6202
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6125
6203
  const fkCol = targetTable[relation.foreignKeyOnTarget];
@@ -6127,7 +6205,7 @@ var RelationService = class {
6127
6205
  logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
6128
6206
  continue;
6129
6207
  }
6130
- const parentPks = getPrimaryKeys(collection, this.registry);
6208
+ const parentPks = requirePrimaryKeys(collection, this.registry);
6131
6209
  const parentIdInfo = parentPks[0];
6132
6210
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
6133
6211
  if (targetEntityIds.length > 0) {
@@ -6147,9 +6225,9 @@ var RelationService = class {
6147
6225
  try {
6148
6226
  const targetCollection = relation.target();
6149
6227
  const targetTable = getTableForCollection(targetCollection, this.registry);
6150
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6228
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6151
6229
  const targetIdInfo = targetPks[0];
6152
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6230
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6153
6231
  const sourceIdInfo = sourcePks[0];
6154
6232
  if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
6155
6233
  await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
@@ -6230,12 +6308,12 @@ var RelationService = class {
6230
6308
  logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
6231
6309
  return;
6232
6310
  }
6233
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6311
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6234
6312
  const sourceIdInfo = sourcePks[0];
6235
6313
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6236
6314
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6237
6315
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6238
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6316
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6239
6317
  const targetIdInfo = targetPks[0];
6240
6318
  const newLinks = newValue.map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6241
6319
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6243,7 +6321,7 @@ var RelationService = class {
6243
6321
  }));
6244
6322
  if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
6245
6323
  } else if (newValue && !Array.isArray(newValue)) {
6246
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6324
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6247
6325
  const targetIdInfo = targetPks[0];
6248
6326
  const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
6249
6327
  const newLink = {
@@ -6274,12 +6352,12 @@ var RelationService = class {
6274
6352
  logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
6275
6353
  return;
6276
6354
  }
6277
- const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
6355
+ const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
6278
6356
  const sourceIdInfo = sourcePks[0];
6279
6357
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
6280
6358
  await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
6281
6359
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
6282
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6360
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6283
6361
  const targetIdInfo = targetPks[0];
6284
6362
  const newLinks = newValue.map((rel) => rel.id).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
6285
6363
  [sourceJunctionColumn.name]: parsedSourceId,
@@ -6300,12 +6378,12 @@ var RelationService = class {
6300
6378
  const { relation, newTargetId } = upd;
6301
6379
  const targetCollection = relation.target();
6302
6380
  const targetTable = getTableForCollection(targetCollection, this.registry);
6303
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6381
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6304
6382
  const targetIdInfo = targetPks[0];
6305
6383
  const targetIdCol = targetTable[targetIdInfo.fieldName];
6306
6384
  const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
6307
6385
  const parentTable = getTableForCollection(parentCollection, this.registry);
6308
- const parentPks = getPrimaryKeys(parentCollection, this.registry);
6386
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
6309
6387
  const parentIdInfo = parentPks[0];
6310
6388
  const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
6311
6389
  const parentIdCol = parentTable[parentIdInfo.fieldName];
@@ -6376,7 +6454,7 @@ var RelationService = class {
6376
6454
  logger.warn(`Junction columns not found for relation '${relationKey}'`);
6377
6455
  return;
6378
6456
  }
6379
- const targetPks = getPrimaryKeys(targetCollection, this.registry);
6457
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
6380
6458
  const targetIdInfo = targetPks[0];
6381
6459
  const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
6382
6460
  const junctionData = {
@@ -6392,6 +6470,150 @@ var RelationService = class {
6392
6470
  }
6393
6471
  };
6394
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
+ /**
6528
+ * Drop every column the collection marked `excludeFromApi`.
6529
+ *
6530
+ * Password hashes and verification tokens have to be readable server-side but
6531
+ * must never reach a client — and "never" has to mean every exit from this
6532
+ * pipeline, including relation targets, or a secret leaks through whichever
6533
+ * path was overlooked. Keyed by both the property name and its column name,
6534
+ * since a row can arrive keyed either way depending on the caller.
6535
+ */
6536
+ function stripExcluded(row, collection) {
6537
+ const properties = collection.properties;
6538
+ if (!properties) return row;
6539
+ for (const [key, property] of Object.entries(properties)) {
6540
+ if (!property?.excludeFromApi) continue;
6541
+ delete row[key];
6542
+ if (property.columnName) delete row[property.columnName];
6543
+ }
6544
+ return row;
6545
+ }
6546
+ function renderTarget(targetRow, targetCollection, style, registry) {
6547
+ if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
6548
+ const address = relationTargetAddress(targetRow, targetCollection, registry);
6549
+ const path = targetCollection.slug;
6550
+ return createRelationRefWithData(address, path, {
6551
+ id: address,
6552
+ path,
6553
+ values: normalizeDbValues(targetRow, targetCollection)
6554
+ });
6555
+ }
6556
+ /**
6557
+ * The address a relation ref points at.
6558
+ *
6559
+ * The whole key, not its first column: a composite-keyed target addressed by
6560
+ * `tenant_id` alone points at every row that shares it. A target whose key
6561
+ * cannot be resolved at all used to throw here — reading `[0]` of an empty
6562
+ * array — taking down the parent's fetch over a relation it may not even have
6563
+ * asked for. The first column is a guess, but a ref that resolves to nothing
6564
+ * beats no rows at all.
6565
+ */
6566
+ function relationTargetAddress(targetRow, targetCollection, registry) {
6567
+ const address = deriveRowAddress(targetRow, targetCollection, registry);
6568
+ if (address) return address;
6569
+ return String(targetRow[Object.keys(targetRow)[0]] ?? "");
6570
+ }
6571
+ /**
6572
+ * The row the admin renders: every column, with relations as references.
6573
+ *
6574
+ * Values are normalized (dates, numbers, NaN) because the admin's view-model
6575
+ * expects real types. The row's own address is *not* among the columns — it is
6576
+ * derived by the consumer from the collection's primary keys.
6577
+ */
6578
+ function toCmsRow(row, collection, registry) {
6579
+ const resolvedRelations = resolveCollectionRelations(collection);
6580
+ const normalized = normalizeDbValues(row, collection);
6581
+ for (const [key, relation] of Object.entries(resolvedRelations)) {
6582
+ const relData = row[relation.relationName || key];
6583
+ if (relData === void 0 || relData === null) continue;
6584
+ if (relation.cardinality === "many" && Array.isArray(relData)) {
6585
+ const targetCollection = relation.target();
6586
+ normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6587
+ } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6588
+ }
6589
+ return stripExcluded(normalized, collection);
6590
+ }
6591
+ /**
6592
+ * The row REST serves: every column under its own name, with the value Postgres
6593
+ * returned, and relations inlined as the target's columns.
6594
+ *
6595
+ * Values are the ones the database returned, except where that contradicts the
6596
+ * declared type: a `number` property is served as a number (see
6597
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6598
+ * JSON has its own opinions about dates that the admin's view-model does not
6599
+ * share.
6600
+ *
6601
+ * Keyed by the row rather than by the relation list — a REST fetch only loads
6602
+ * the relations `include` asked for, so the row is the authority on which are
6603
+ * actually there.
6604
+ */
6605
+ function toRestRow(row, collection, registry) {
6606
+ const resolvedRelations = resolveCollectionRelations(collection);
6607
+ const flat = {};
6608
+ for (const [key, value] of Object.entries(row)) {
6609
+ const relation = findRelation(resolvedRelations, key);
6610
+ if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6611
+ else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6612
+ else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6613
+ }
6614
+ return stripExcluded(flat, collection);
6615
+ }
6616
+ //#endregion
6395
6617
  //#region src/services/FetchService.ts
6396
6618
  /**
6397
6619
  * Service for handling all row read operations.
@@ -6450,7 +6672,7 @@ var FetchService = class {
6450
6672
  if (!shouldInclude(key)) continue;
6451
6673
  const drizzleRelName = relation.relationName || key;
6452
6674
  if (relation.joinPath && relation.joinPath.length > 0) continue;
6453
- if (relation.cardinality === "many" && this.isJunctionRelation(relation, collection)) {
6675
+ if (relation.cardinality === "many" && isJunctionRelation(relation)) {
6454
6676
  const targetFkName = this.getJunctionTargetRelationName(relation, collection);
6455
6677
  if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
6456
6678
  else withConfig[drizzleRelName] = true;
@@ -6459,14 +6681,6 @@ var FetchService = class {
6459
6681
  return withConfig;
6460
6682
  }
6461
6683
  /**
6462
- * Detect if a many-to-many relation uses a junction table in the Drizzle schema.
6463
- */
6464
- isJunctionRelation(relation, _collection) {
6465
- if (relation.through) return true;
6466
- if (relation.joinPath && relation.joinPath.length > 1) return true;
6467
- return false;
6468
- }
6469
- /**
6470
6684
  * Get the Drizzle relation name on the junction table that points to the actual target row.
6471
6685
  * For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
6472
6686
  */
@@ -6475,68 +6689,6 @@ var FetchService = class {
6475
6689
  return null;
6476
6690
  }
6477
6691
  /**
6478
- * The address a relation ref points at.
6479
- *
6480
- * The whole key, not its first column: a composite-keyed target addressed
6481
- * by `tenant_id` alone points at every row that shares it. And a target
6482
- * whose key cannot be resolved at all used to throw here — reading
6483
- * `targetPks[0]` of an empty array — taking down the parent's fetch over a
6484
- * relation it may not even have asked for; the first column is a guess, but
6485
- * a ref that resolves to nothing beats no rows at all.
6486
- */
6487
- relationTargetAddress(targetRow, targetCollection) {
6488
- const address = deriveRowAddress(targetRow, targetCollection, this.registry);
6489
- if (address) return address;
6490
- const firstColumn = targetRow[Object.keys(targetRow)[0]];
6491
- return String(firstColumn ?? "");
6492
- }
6493
- /**
6494
- * Convert a db.query result row (with nested relation objects) to a flat row.
6495
- * Handles:
6496
- * - Type normalization (dates, numbers, NaN) via normalizeDbValues
6497
- * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
6498
- * - Flattening junction-table many-to-many results
6499
- *
6500
- * The row's own address is not among them: it is derived by the consumer
6501
- * from the collection's primary keys.
6502
- */
6503
- drizzleResultToRow(row, collection) {
6504
- const resolvedRelations = resolveCollectionRelations(collection);
6505
- const normalizedValues = normalizeDbValues(row, collection);
6506
- for (const [key, relation] of Object.entries(resolvedRelations)) {
6507
- const relData = row[relation.relationName || key];
6508
- if (relData === void 0 || relData === null) continue;
6509
- if (relation.cardinality === "many" && Array.isArray(relData)) {
6510
- const targetCollection = relation.target();
6511
- const targetPath = targetCollection.slug;
6512
- normalizedValues[key] = relData.map((item) => {
6513
- let targetRow = item;
6514
- if (this.isJunctionRelation(relation, collection)) {
6515
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6516
- if (nestedKey) targetRow = item[nestedKey];
6517
- }
6518
- const relId = this.relationTargetAddress(targetRow, targetCollection);
6519
- return createRelationRefWithData(relId, targetPath, {
6520
- id: relId,
6521
- path: targetPath,
6522
- values: normalizeDbValues(targetRow, targetCollection)
6523
- });
6524
- });
6525
- } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
6526
- const targetCollection = relation.target();
6527
- const targetPath = targetCollection.slug;
6528
- const relObj = relData;
6529
- const relId = this.relationTargetAddress(relObj, targetCollection);
6530
- normalizedValues[key] = createRelationRefWithData(relId, targetPath, {
6531
- id: relId,
6532
- path: targetPath,
6533
- values: normalizeDbValues(relObj, targetCollection)
6534
- });
6535
- }
6536
- }
6537
- return normalizedValues;
6538
- }
6539
- /**
6540
6692
  * Post-fetch joinPath relations for a single flat row.
6541
6693
  * joinPath relations cannot be expressed via Drizzle's `with` config,
6542
6694
  * so they must be loaded separately after the primary query.
@@ -6567,8 +6719,10 @@ var FetchService = class {
6567
6719
  const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
6568
6720
  const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.joinPath && relation.joinPath.length > 0 && propertyKeys.has(key) && shouldInclude(key));
6569
6721
  if (joinPathRelations.length === 0) return;
6570
- const idInfo = idInfoArray[0];
6571
- const parentIdOf = (row) => row[idInfo.fieldName];
6722
+ const parentIdOf = (row) => {
6723
+ const address = buildCompositeId(row, idInfoArray);
6724
+ return address && address.split(":::").some((part) => part !== "") ? address : void 0;
6725
+ };
6572
6726
  for (const [key, relation] of joinPathRelations) try {
6573
6727
  const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
6574
6728
  if (addressable.length === 0) continue;
@@ -6588,32 +6742,6 @@ var FetchService = class {
6588
6742
  }
6589
6743
  }
6590
6744
  /**
6591
- * Convert a db.query result row to a flat REST-style row with populated relations.
6592
- *
6593
- * Every column is copied through under its own name, with the value Postgres
6594
- * returned. This used to open with a synthesized `id` and then skip the key
6595
- * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
6596
- * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
6597
- * need an address derive it from the collection's primary keys.
6598
- */
6599
- drizzleResultToRestRow(row, collection) {
6600
- const flat = {};
6601
- const resolvedRelations = resolveCollectionRelations(collection);
6602
- for (const [k, v] of Object.entries(row)) {
6603
- const relation = findRelation(resolvedRelations, k);
6604
- if (Array.isArray(v) && relation) flat[k] = v.map((item) => {
6605
- if (this.isJunctionRelation(relation, collection)) {
6606
- const nestedKey = Object.keys(item).find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
6607
- if (nestedKey) return { ...item[nestedKey] };
6608
- }
6609
- return { ...item };
6610
- });
6611
- else if (typeof v === "object" && v !== null && !Array.isArray(v) && relation) flat[k] = { ...v };
6612
- else flat[k] = v;
6613
- }
6614
- return flat;
6615
- }
6616
- /**
6617
6745
  * Build db.query-compatible options from standard fetch options.
6618
6746
  * Handles filter, search, orderBy, limit, and cursor-based pagination.
6619
6747
  */
@@ -6683,7 +6811,7 @@ var FetchService = class {
6683
6811
  async fetchOne(collectionPath, id, databaseId) {
6684
6812
  const collection = getCollectionByPath(collectionPath, this.registry);
6685
6813
  const table = getTableForCollection(collection, this.registry);
6686
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6814
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6687
6815
  const idInfo = idInfoArray[0];
6688
6816
  const idField = table[idInfo.fieldName];
6689
6817
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6697,7 +6825,7 @@ var FetchService = class {
6697
6825
  with: withConfig
6698
6826
  });
6699
6827
  if (!row) return void 0;
6700
- const flatRow = this.drizzleResultToRow(row, collection);
6828
+ const flatRow = toCmsRow(row, collection, this.registry);
6701
6829
  await this.resolveJoinPathRelations(flatRow, collection, collectionPath, parsedId, databaseId);
6702
6830
  return flatRow;
6703
6831
  } catch (e) {
@@ -6739,7 +6867,7 @@ var FetchService = class {
6739
6867
  async fetchRowsWithConditions(collectionPath, options = {}) {
6740
6868
  const collection = getCollectionByPath(collectionPath, this.registry);
6741
6869
  const table = getTableForCollection(collection, this.registry);
6742
- const idInfoArray = getPrimaryKeys(collection, this.registry);
6870
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6743
6871
  const idInfo = idInfoArray[0];
6744
6872
  const idField = table[idInfo.fieldName];
6745
6873
  if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
@@ -6749,7 +6877,7 @@ var FetchService = class {
6749
6877
  const hasRelations = withConfig && Object.keys(withConfig).length > 0;
6750
6878
  if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
6751
6879
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0);
6752
- return (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRow(row, collection));
6880
+ return (await qb.findMany(queryOpts)).map((row) => toCmsRow(row, collection, this.registry));
6753
6881
  } catch (e) {
6754
6882
  if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
6755
6883
  logger.error(`[FetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
@@ -6810,8 +6938,10 @@ var FetchService = class {
6810
6938
  }
6811
6939
  /**
6812
6940
  * Fallback path used when db.query is unavailable.
6813
- * The primary path uses drizzleResultToRow which handles relation
6814
- * mapping without N+1 queries.
6941
+ *
6942
+ * The primary path runs the results through `toCmsRow`, which maps
6943
+ * relations from what drizzle already nested — no query per row. This one
6944
+ * has no nesting to read, so it resolves relations itself, in batches.
6815
6945
  *
6816
6946
  * Process raw database results into flat rows with relations.
6817
6947
  */
@@ -6953,7 +7083,7 @@ var FetchService = class {
6953
7083
  if (value === void 0 || value === null) return true;
6954
7084
  const collection = getCollectionByPath(collectionPath, this.registry);
6955
7085
  const table = getTableForCollection(collection, this.registry);
6956
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7086
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6957
7087
  const idInfo = idInfoArray[0];
6958
7088
  const idField = table[idInfo.fieldName];
6959
7089
  const field = table[fieldName];
@@ -6980,7 +7110,7 @@ var FetchService = class {
6980
7110
  async fetchCollectionForRest(collectionPath, options = {}, include) {
6981
7111
  const collection = getCollectionByPath(collectionPath, this.registry);
6982
7112
  const table = getTableForCollection(collection, this.registry);
6983
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7113
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
6984
7114
  const idInfo = idInfoArray[0];
6985
7115
  const idField = table[idInfo.fieldName];
6986
7116
  const tableName = getTableName(table);
@@ -6988,7 +7118,7 @@ var FetchService = class {
6988
7118
  if (qb && !options.searchString && !options.vectorSearch) try {
6989
7119
  const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
6990
7120
  const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig);
6991
- const restRows = (await qb.findMany(queryOpts)).map((row) => this.drizzleResultToRestRow(row, collection));
7121
+ const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
6992
7122
  await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
6993
7123
  return restRows;
6994
7124
  } catch (e) {
@@ -7037,7 +7167,7 @@ var FetchService = class {
7037
7167
  async fetchOneForRest(collectionPath, id, include, databaseId) {
7038
7168
  const collection = getCollectionByPath(collectionPath, this.registry);
7039
7169
  const table = getTableForCollection(collection, this.registry);
7040
- const idInfoArray = getPrimaryKeys(collection, this.registry);
7170
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
7041
7171
  const idInfo = idInfoArray[0];
7042
7172
  const idField = table[idInfo.fieldName];
7043
7173
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
@@ -7050,7 +7180,7 @@ var FetchService = class {
7050
7180
  ...withConfig ? { with: withConfig } : {}
7051
7181
  });
7052
7182
  if (!row) return null;
7053
- const restRow = this.drizzleResultToRestRow(row, collection);
7183
+ const restRow = toRestRow(row, collection, this.registry);
7054
7184
  await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
7055
7185
  return restRow;
7056
7186
  } catch (e) {
@@ -7095,7 +7225,7 @@ var FetchService = class {
7095
7225
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
7096
7226
  const collection = getCollectionByPath(collectionPath, this.registry);
7097
7227
  const table = getTableForCollection(collection, this.registry);
7098
- const idField = table[getPrimaryKeys(collection, this.registry)[0].fieldName];
7228
+ const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
7099
7229
  let vectorMeta;
7100
7230
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
7101
7231
  let query = vectorMeta ? this.db.select({
@@ -7584,7 +7714,7 @@ var PersistService = class {
7584
7714
  } catch (error) {
7585
7715
  throw this.toUserFriendlyError(error, collection.slug);
7586
7716
  }
7587
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7717
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7588
7718
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7589
7719
  return finalEntity;
7590
7720
  }
@@ -8503,12 +8633,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8503
8633
  let updatedValues = values;
8504
8634
  const contextForCallback = this.buildCallContext();
8505
8635
  let previousValuesForHistory;
8506
- if (status === "existing" && id) {
8507
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8636
+ if (status === "existing" && id) try {
8637
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8508
8638
  if (existing) {
8509
8639
  const { id: _existingId, ...existingValues } = existing;
8510
8640
  previousValuesForHistory = existingValues;
8511
8641
  }
8642
+ } catch (err) {
8643
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8512
8644
  }
8513
8645
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8514
8646
  if (globalCallbacks?.beforeSave) {
@@ -9121,6 +9253,7 @@ var DatabasePoolManager = class {
9121
9253
  pool.on("error", (err) => {
9122
9254
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9123
9255
  });
9256
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9124
9257
  this.pools.set(databaseName, pool);
9125
9258
  return pool;
9126
9259
  }
@@ -20681,14 +20814,22 @@ async function ensureAuthTablesExist(db, collection) {
20681
20814
  $$ LANGUAGE sql STABLE
20682
20815
  `);
20683
20816
  });
20684
- await db.execute(sql`
20685
- ALTER TABLE ${sql.raw(usersTableName)}
20686
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20687
- `);
20688
- await db.execute(sql`
20689
- ALTER TABLE ${sql.raw(usersTableName)}
20690
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20691
- `);
20817
+ for (const columnDef of [
20818
+ "display_name VARCHAR(255)",
20819
+ "photo_url VARCHAR(500)",
20820
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
20821
+ "password_hash VARCHAR(255)",
20822
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20823
+ "email_verification_token VARCHAR(255)",
20824
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20825
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20826
+ "metadata JSONB DEFAULT '{}' NOT NULL",
20827
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20828
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20829
+ ]) await db.execute(sql`
20830
+ ALTER TABLE ${sql.raw(usersTableName)}
20831
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20832
+ `);
20692
20833
  try {
20693
20834
  if ((await db.execute(sql`
20694
20835
  SELECT EXISTS (
@@ -20759,6 +20900,34 @@ async function ensureAuthTablesExist(db, collection) {
20759
20900
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20760
20901
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20761
20902
  `);
20903
+ try {
20904
+ const authTablePairs = [
20905
+ [usersSchema, resolvedTable],
20906
+ [authSchema, "user_identities"],
20907
+ [authSchema, "refresh_tokens"],
20908
+ [authSchema, "password_reset_tokens"],
20909
+ [authSchema, "app_config"],
20910
+ [authSchema, "mfa_factors"],
20911
+ [authSchema, "mfa_challenges"],
20912
+ [authSchema, "recovery_codes"]
20913
+ ];
20914
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20915
+ SELECT 1
20916
+ FROM pg_class c
20917
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20918
+ WHERE n.nspname = ${schemaName}
20919
+ AND c.relname = ${tableName}
20920
+ AND c.relforcerowsecurity
20921
+ `)).rows.length > 0) {
20922
+ await db.execute(sql`
20923
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20924
+ NO FORCE ROW LEVEL SECURITY
20925
+ `);
20926
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20927
+ }
20928
+ } catch (rlsReconcileError) {
20929
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20930
+ }
20762
20931
  logger.info("✅ Auth tables ready");
20763
20932
  } catch (error) {
20764
20933
  logger.error("❌ Failed to create auth tables", { error });
@@ -20806,6 +20975,31 @@ var UserService = class {
20806
20975
  const name = getTableName(this.usersTable);
20807
20976
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20808
20977
  }
20978
+ /**
20979
+ * Run a privileged auth write with an explicitly cleared RLS context.
20980
+ *
20981
+ * The auth services run on the base/owner connection, which by design
20982
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20983
+ * in the default policies applies. That NULL is normally guaranteed by
20984
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
20985
+ * GUC that survives on a pooled connection (or a connection role that
20986
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20987
+ * turns the trusted write into an RLS-scoped one and denies it with
20988
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20989
+ * single chokepoint, makes the server context deterministic instead of
20990
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20991
+ * NULL via NULLIF, so '' is the server context.
20992
+ */
20993
+ async withServerContext(fn) {
20994
+ return await this.db.transaction(async (tx) => {
20995
+ await tx.execute(sql`
20996
+ SELECT set_config('app.user_id', '', true),
20997
+ set_config('app.user_roles', '', true),
20998
+ set_config('app.jwt', '', true)
20999
+ `);
21000
+ return await fn(tx);
21001
+ });
21002
+ }
20809
21003
  mapRowToUser(row) {
20810
21004
  if (!row) return row;
20811
21005
  const id = row.id ?? row.uid;
@@ -20903,7 +21097,7 @@ var UserService = class {
20903
21097
  }
20904
21098
  async createUser(data) {
20905
21099
  const payload = this.mapPayload(data);
20906
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21100
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20907
21101
  return this.mapRowToUser(row);
20908
21102
  }
20909
21103
  async getUserById(id) {
@@ -20942,12 +21136,12 @@ var UserService = class {
20942
21136
  }));
20943
21137
  }
20944
21138
  async linkUserIdentity(userId, provider, providerId, profileData) {
20945
- await this.db.insert(this.userIdentitiesTable).values({
21139
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20946
21140
  userId,
20947
21141
  provider,
20948
21142
  providerId,
20949
21143
  profileData: profileData || null
20950
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21144
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20951
21145
  }
20952
21146
  async updateUser(id, data) {
20953
21147
  const idCol = getColumn(this.usersTable, "id");
@@ -20955,13 +21149,13 @@ var UserService = class {
20955
21149
  const payload = this.mapPayload(data);
20956
21150
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20957
21151
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20958
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21152
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
20959
21153
  return row ? this.mapRowToUser(row) : null;
20960
21154
  }
20961
21155
  async deleteUser(id) {
20962
21156
  const idCol = getColumn(this.usersTable, "id");
20963
21157
  if (!idCol) return;
20964
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21158
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
20965
21159
  }
20966
21160
  async listUsers() {
20967
21161
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21015,10 +21209,10 @@ var UserService = class {
21015
21209
  if (!idCol) return;
21016
21210
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21017
21211
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21018
- await this.db.update(this.usersTable).set({
21212
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21019
21213
  [passwordHashColKey]: passwordHash,
21020
21214
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21021
- }).where(eq(idCol, id));
21215
+ }).where(eq(idCol, id)));
21022
21216
  }
21023
21217
  /**
21024
21218
  * Set email verification status
@@ -21029,11 +21223,11 @@ var UserService = class {
21029
21223
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21030
21224
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21031
21225
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21032
- await this.db.update(this.usersTable).set({
21226
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21033
21227
  [emailVerifiedColKey]: verified,
21034
21228
  [emailVerificationTokenColKey]: null,
21035
21229
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21036
- }).where(eq(idCol, id));
21230
+ }).where(eq(idCol, id)));
21037
21231
  }
21038
21232
  /**
21039
21233
  * Set email verification token
@@ -21044,11 +21238,11 @@ var UserService = class {
21044
21238
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21045
21239
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21046
21240
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21047
- await this.db.update(this.usersTable).set({
21241
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21048
21242
  [emailVerificationTokenColKey]: token,
21049
21243
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21050
21244
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21051
- }).where(eq(idCol, id));
21245
+ }).where(eq(idCol, id)));
21052
21246
  }
21053
21247
  /**
21054
21248
  * Find user by email verification token
@@ -21093,22 +21287,22 @@ var UserService = class {
21093
21287
  async setUserRoles(userId, roleIds) {
21094
21288
  const usersTableName = this.getQualifiedUsersTableName();
21095
21289
  const rolesArray = `{${roleIds.join(",")}}`;
21096
- await this.db.execute(sql`
21290
+ await this.withServerContext(async (db) => db.execute(sql`
21097
21291
  UPDATE ${sql.raw(usersTableName)}
21098
21292
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21099
21293
  WHERE id = ${userId}
21100
- `);
21294
+ `));
21101
21295
  }
21102
21296
  /**
21103
21297
  * Assign a specific role to new user (appends if not present)
21104
21298
  */
21105
21299
  async assignDefaultRole(userId, roleId) {
21106
21300
  const usersTableName = this.getQualifiedUsersTableName();
21107
- await this.db.execute(sql`
21301
+ await this.withServerContext(async (db) => db.execute(sql`
21108
21302
  UPDATE ${sql.raw(usersTableName)}
21109
21303
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21110
21304
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21111
- `);
21305
+ `));
21112
21306
  }
21113
21307
  /**
21114
21308
  * Get user with their roles
@@ -22578,6 +22772,7 @@ function createPostgresBootstrapper(pgConfig) {
22578
22772
  const wantsCdc = cdcMode !== "off";
22579
22773
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22580
22774
  let cdcEnabled = false;
22775
+ let provisionCdcForTables;
22581
22776
  if (wantsCdc && !directUrl) {
22582
22777
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22583
22778
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22594,6 +22789,9 @@ function createPostgresBootstrapper(pgConfig) {
22594
22789
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22595
22790
  await realtimeService.enableCdc(directUrl);
22596
22791
  cdcEnabled = true;
22792
+ provisionCdcForTables = async (tables) => {
22793
+ await provisionTriggerCdc(cdcRunSql, tables);
22794
+ };
22597
22795
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22598
22796
  } catch (err) {
22599
22797
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22618,13 +22816,14 @@ function createPostgresBootstrapper(pgConfig) {
22618
22816
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22619
22817
  const missing = [];
22620
22818
  for (const col of registeredCollections) {
22819
+ if (col.auth?.enabled) continue;
22621
22820
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22622
22821
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22623
22822
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22624
22823
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22625
22824
  if (!dbTables.has(fullCheckName)) missing.push({
22626
22825
  slug: col.slug,
22627
- table: checkName
22826
+ table: fullCheckName
22628
22827
  });
22629
22828
  }
22630
22829
  if (missing.length > 0) {
@@ -22658,7 +22857,8 @@ function createPostgresBootstrapper(pgConfig) {
22658
22857
  registry,
22659
22858
  realtimeService,
22660
22859
  driver,
22661
- poolManager
22860
+ poolManager,
22861
+ provisionCdcForTables
22662
22862
  }
22663
22863
  };
22664
22864
  },
@@ -22670,6 +22870,18 @@ function createPostgresBootstrapper(pgConfig) {
22670
22870
  const registry = internals.registry;
22671
22871
  const authCollection = authConfig.collection;
22672
22872
  await ensureAuthTablesExist(db, authCollection);
22873
+ if (authCollection && internals.provisionCdcForTables) {
22874
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22875
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22876
+ if (authTable) try {
22877
+ await internals.provisionCdcForTables([{
22878
+ schema: authSchema,
22879
+ table: authTable
22880
+ }]);
22881
+ } catch (err) {
22882
+ 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) });
22883
+ }
22884
+ }
22673
22885
  let emailService;
22674
22886
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22675
22887
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22740,6 +22952,6 @@ function createPostgresAdapter(pgConfig) {
22740
22952
  };
22741
22953
  }
22742
22954
  //#endregion
22743
- 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 };
22955
+ 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 };
22744
22956
 
22745
22957
  //# sourceMappingURL=index.es.js.map