@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee

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.
Files changed (51) hide show
  1. package/dist/auth/services.d.ts +6 -1
  2. package/dist/backup/backup-service.d.ts +10 -1
  3. package/dist/backup/pg-tools.d.ts +47 -0
  4. package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
  5. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  6. package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  7. package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
  9. package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
  10. package/dist/index.es.js +768 -537
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  13. package/dist/schema/introspect-db-logic.d.ts +94 -5
  14. package/dist/schema/introspect-db-queries.d.ts +119 -0
  15. package/dist/schema/introspect-db-structure.d.ts +263 -0
  16. package/dist/schema/introspect-db-types.d.ts +11 -0
  17. package/dist/services/RelationService.d.ts +24 -1
  18. package/dist/services/channel-bus/index.d.ts +1 -7
  19. package/dist/services/collection-helpers.d.ts +36 -2
  20. package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
  21. package/dist/src-DlPBctw_.js.map +1 -0
  22. package/dist/utils/connection-string.d.ts +29 -0
  23. package/dist/utils/drizzle-conditions.d.ts +5 -4
  24. package/dist/utils/pg-error-utils.d.ts +16 -0
  25. package/package.json +6 -6
  26. package/src/auth/services.ts +6 -3
  27. package/src/backup/backup-cli.ts +41 -2
  28. package/src/backup/backup-service.ts +38 -5
  29. package/src/backup/pg-tools.ts +96 -3
  30. package/src/cli.ts +11 -4
  31. package/src/collections/validate-relations.ts +15 -0
  32. package/src/data-transformer.ts +9 -3
  33. package/src/schema/generate-drizzle-schema-logic.ts +26 -1
  34. package/src/schema/introspect-db-constraints.ts +385 -0
  35. package/src/schema/introspect-db-inference.ts +18 -8
  36. package/src/schema/introspect-db-logic.ts +364 -68
  37. package/src/schema/introspect-db-queries.ts +326 -0
  38. package/src/schema/introspect-db-structure.ts +670 -0
  39. package/src/schema/introspect-db-types.ts +56 -0
  40. package/src/schema/introspect-db.ts +37 -80
  41. package/src/services/BranchService.ts +66 -28
  42. package/src/services/FetchService.ts +14 -0
  43. package/src/services/PersistService.ts +20 -6
  44. package/src/services/RelationService.ts +211 -45
  45. package/src/services/channel-bus/index.ts +0 -9
  46. package/src/services/collection-helpers.ts +69 -3
  47. package/src/utils/connection-string.ts +58 -0
  48. package/src/utils/drizzle-conditions.ts +31 -6
  49. package/src/utils/pg-error-utils.ts +19 -0
  50. package/dist/backup-service-DLb2drIH.js.map +0 -1
  51. package/dist/src-BkdpiQdw.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
4
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
6
  import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
7
7
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -11,6 +11,8 @@ import {
11
11
  requirePrimaryKeys,
12
12
  parseIdValues,
13
13
  buildCompositeId,
14
+ joinsOnNaturalKey,
15
+ sourceKeyField,
14
16
  type PrimaryKeyInfo
15
17
  } from "./collection-helpers";
16
18
  import { parseDataFromServer } from "../data-transformer";
@@ -168,18 +170,122 @@ export class RelationService {
168
170
  private assertSingleKeyAddressable(
169
171
  parentCollection: CollectionConfig,
170
172
  parentPks: PrimaryKeyInfo[],
171
- via: string
173
+ via: string,
174
+ relation?: ResolvedRelation
172
175
  ): void {
176
+ // A `sourceKey` names the single column the link actually points at, so
177
+ // the parent's key can be as wide as it likes — nothing here references
178
+ // it. That is the one way out of this error, and the message below now
179
+ // has to say so.
180
+ if (relation && hasForeignKeyOnTarget(relation) && relation.sourceKey) return;
181
+
173
182
  if (parentPks.length > 1) {
174
183
  throw new Error(
175
184
  `Relation on '${parentCollection.slug}' uses '${via}', a single foreign-key column, but ` +
176
185
  `'${parentCollection.slug}' is keyed on ${parentPks.map(k => `'${k.fieldName}'`).join(" + ")}. ` +
177
- `One column cannot reference a composite key — express this relation with \`joinPath\`, whose ` +
186
+ `One column cannot reference a composite key — give the relation a \`sourceKey\` naming ` +
187
+ `a single unique column to point at, or express it with \`joinPath\`, whose ` +
178
188
  `\`on.from\`/\`on.to\` take every key column.`
179
189
  );
180
190
  }
181
191
  }
182
192
 
193
+ /**
194
+ * What the target's foreign key holds, for each of these parent rows.
195
+ *
196
+ * Ordinarily the parent's id, and then this is free. When the relation
197
+ * declares a `sourceKey` the two are different values, and the mapping
198
+ * between them lives in the source table — so it costs one SELECT, issued
199
+ * once for the whole batch rather than per parent.
200
+ *
201
+ * Both directions come back because both are needed and deriving one from
202
+ * the other by hand is how a batch loader ends up attributing a child to the
203
+ * wrong parent: reads translate id → key to build the WHERE, and then
204
+ * translate key → id to attribute each row that comes back.
205
+ */
206
+ /**
207
+ * The value a related row's foreign key must hold to belong to this parent.
208
+ *
209
+ * `undefined` when the parent's source key is null — which is not an error
210
+ * here, only in the callers that were about to write it. Exposed for
211
+ * {@link PersistService}, which stamps this onto a child created under a
212
+ * nested path and would otherwise write the id and lose the row.
213
+ */
214
+ async parentKeyValue(
215
+ parentCollection: CollectionConfig,
216
+ relation: ResolvedHasOne | ResolvedHasMany,
217
+ parentId: string | number,
218
+ db: DrizzleClient = this.db
219
+ ): Promise<string | number | undefined> {
220
+ const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
221
+ return keyByParentId.get(String(parentId));
222
+ }
223
+
224
+ private async resolveSourceKeys(
225
+ parentCollection: CollectionConfig,
226
+ relation: ResolvedHasOne | ResolvedHasMany,
227
+ parentIds: (string | number)[],
228
+ // Writes pass their transaction: reading the source key on the pool
229
+ // while the same transaction is holding an uncommitted change to it
230
+ // would translate the id against a stale value.
231
+ db: DrizzleClient = this.db
232
+ ): Promise<{ keyByParentId: Map<string, string | number>; parentIdByKey: Map<string, string | number> }> {
233
+ const keyByParentId = new Map<string, string | number>();
234
+ const parentIdByKey = new Map<string, string | number>();
235
+
236
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
237
+
238
+ if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
239
+ // The ordinary case, and it must stay free of a round-trip: the
240
+ // key IS the id. Parsed, not passed through — an id arrives as the
241
+ // string from a URL, and comparing "7" against an integer column is
242
+ // how this returns nothing at all.
243
+ for (const id of parentIds) {
244
+ const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
245
+ keyByParentId.set(String(id), value);
246
+ parentIdByKey.set(String(value), id);
247
+ }
248
+ return { keyByParentId, parentIdByKey };
249
+ }
250
+
251
+ const field = sourceKeyField(relation, parentCollection, this.registry);
252
+ const parentTable = getTableForCollection(parentCollection, this.registry);
253
+ const keyColumn = parentTable[field as keyof typeof parentTable] as AnyPgColumn;
254
+ if (!keyColumn) {
255
+ throw new Error(
256
+ `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not a column on ` +
257
+ `'${parentCollection.slug}'. It names a column on the source table, not on the target.`
258
+ );
259
+ }
260
+
261
+ const rows = await db
262
+ .select()
263
+ .from(parentTable)
264
+ .where(this.parentKeyCondition(parentTable, parentPks, parentIds));
265
+
266
+ for (const row of rows as Array<Record<string, unknown>>) {
267
+ const keyValue = row[field] as string | number | null;
268
+ if (keyValue === null || keyValue === undefined) continue;
269
+ const parentId = buildCompositeId(row, parentPks);
270
+ keyByParentId.set(String(parentId), keyValue);
271
+ // A duplicate here means the source key is not unique, which makes
272
+ // "which parent does this child belong to" unanswerable. Refuse
273
+ // rather than pick the last one seen.
274
+ const existing = parentIdByKey.get(String(keyValue));
275
+ if (existing !== undefined && String(existing) !== String(parentId)) {
276
+ throw new Error(
277
+ `\`sourceKey: "${field}"\` on relation '${relation.relationName}' is not unique on ` +
278
+ `'${parentCollection.slug}': rows '${existing}' and '${parentId}' both hold ` +
279
+ `'${keyValue}'. Add a unique constraint — a link that addresses more than one source ` +
280
+ `row cannot say which one a related row belongs to.`
281
+ );
282
+ }
283
+ parentIdByKey.set(String(keyValue), parentId);
284
+ }
285
+
286
+ return { keyByParentId, parentIdByKey };
287
+ }
288
+
183
289
  /**
184
290
  * Fetch rows related to a parent row through a specific relation
185
291
  */
@@ -292,7 +398,21 @@ export class RelationService {
292
398
  return rows;
293
399
  }
294
400
 
295
- // Handle other relation types
401
+ // Handle other relation types.
402
+ //
403
+ // The query builder compares the target's foreign key against a value,
404
+ // and for a link on a natural key that value is not the id in the URL.
405
+ // Resolved first, before anything is built, so a parent that reaches
406
+ // nothing costs one statement rather than two.
407
+ const matchValue = hasForeignKeyOnTarget(relation)
408
+ ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
409
+ .keyByParentId.get(String(parentId))
410
+ : parsedParentId;
411
+
412
+ // A parent whose source key is null reaches nothing: NULL never equals
413
+ // a foreign key. Say so with an empty list rather than an `= NULL`.
414
+ if (matchValue === undefined) return [];
415
+
296
416
  let query = this.db.select().from(targetTable).$dynamic();
297
417
 
298
418
  // Build additional filter conditions
@@ -322,7 +442,7 @@ export class RelationService {
322
442
  query,
323
443
  query,
324
444
  relation,
325
- parsedParentId,
445
+ matchValue,
326
446
  targetTable,
327
447
  parentTable,
328
448
  parentIdCol,
@@ -400,6 +520,16 @@ export class RelationService {
400
520
  if (!parentTable) throw new Error("Parent table not found");
401
521
  const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
402
522
 
523
+ // Same translation the listing does, and it has to be: `isRelated`
524
+ // gates writes on this count, so a count built from a different value
525
+ // than the read would authorise rows the read never returned.
526
+ const matchValue = hasForeignKeyOnTarget(relation)
527
+ ? (await this.resolveSourceKeys(parentCollection, relation, [parentId]))
528
+ .keyByParentId.get(String(parentId))
529
+ : parsedParentId;
530
+
531
+ if (matchValue === undefined) return 0;
532
+
403
533
  // Start count with distinct to avoid duplicates from junction tables
404
534
  let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
405
535
 
@@ -407,7 +537,7 @@ export class RelationService {
407
537
  query = DrizzleConditionBuilder.buildRelationCountQuery(
408
538
  query,
409
539
  relation,
410
- parsedParentId,
540
+ matchValue,
411
541
  targetTable,
412
542
  parentTable,
413
543
  parentIdCol,
@@ -640,8 +770,22 @@ export class RelationService {
640
770
  this.assertSingleKeyAddressable(
641
771
  parentCollection,
642
772
  parentPks,
643
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
773
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
774
+ relation
644
775
  );
776
+
777
+ // One lookup for the whole batch, both directions: the WHERE is built
778
+ // from the source-key values, and each row that comes back carries one
779
+ // of those values rather than a parent id.
780
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
781
+ ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
782
+ : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
783
+
784
+ const matchValues = hasForeignKeyOnTarget(relation)
785
+ ? [...keyByParentId.values()]
786
+ : parsedParentIds;
787
+ if (matchValues.length === 0) return new Map();
788
+
645
789
  let query = this.db.select().from(targetTable).$dynamic();
646
790
 
647
791
  // Build the relation query with ALL parent IDs
@@ -649,7 +793,7 @@ export class RelationService {
649
793
  query,
650
794
  query,
651
795
  relation,
652
- parsedParentIds, // Pass array instead of single ID
796
+ matchValues, // Pass array instead of single ID
653
797
  targetTable,
654
798
  parentTable,
655
799
  parentIdCol,
@@ -661,28 +805,24 @@ export class RelationService {
661
805
  const results = await query;
662
806
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>>();
663
807
 
664
- // Build a Set<string> for O(1) parent-ID lookups that is immune to
665
- // number-vs-string type mismatches (Drizzle may return either depending
666
- // on the column type and driver).
667
- const parentIdSet = new Set(parsedParentIds.map(String));
668
-
669
808
  // Map results back to parent rows
670
809
  for (const row of results as Array<Record<string, unknown>>) {
671
810
  const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
672
811
 
673
- // Determine the parent ID this result belongs to based on the relation type
674
- let parentId: string | number | undefined;
675
-
676
812
  // The parent's key is on the target row, in the relation's own
677
813
  // column. There used to be a second branch here that guessed the
678
814
  // column by appending `_id` to `inverseRelationName`, reached only
679
815
  // when the foreign key had not been resolved — which cannot happen
680
816
  // now that resolution fills it in.
681
- if (hasForeignKeyOnTarget(relation)) {
682
- parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
683
- }
817
+ if (!hasForeignKeyOnTarget(relation)) continue;
818
+
819
+ // Keyed by string throughout: Drizzle returns a numeric column as a
820
+ // number or a string depending on the column type and the driver.
821
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
822
+ if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
684
823
 
685
- if (parentId !== undefined && parentIdSet.has(String(parentId))) {
824
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
825
+ if (parentId !== undefined) {
686
826
  resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
687
827
  }
688
828
  }
@@ -811,15 +951,26 @@ export class RelationService {
811
951
  this.assertSingleKeyAddressable(
812
952
  parentCollection,
813
953
  parentPks,
814
- hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName
954
+ hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName,
955
+ relation
815
956
  );
957
+
958
+ const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation)
959
+ ? await this.resolveSourceKeys(parentCollection, relation, parentIds)
960
+ : { keyByParentId: new Map<string, string | number>(), parentIdByKey: new Map<string, string | number>() };
961
+
962
+ const matchValues = hasForeignKeyOnTarget(relation)
963
+ ? [...keyByParentId.values()]
964
+ : parsedParentIds;
965
+ if (matchValues.length === 0) return new Map();
966
+
816
967
  let query = this.db.select().from(targetTable).$dynamic();
817
968
 
818
969
  query = applyDynamicRelationQuery(
819
970
  query,
820
971
  query,
821
972
  relation,
822
- parsedParentIds,
973
+ matchValues,
823
974
  targetTable,
824
975
  parentTable,
825
976
  parentIdCol,
@@ -831,23 +982,18 @@ export class RelationService {
831
982
  const results = await query;
832
983
  const resultMap = new Map<string, RelatedRow<Record<string, unknown>>[]>();
833
984
 
834
- // Build a Set<string> for O(1) parent-ID lookups that is immune to
835
- // number-vs-string type mismatches (Drizzle may return either depending
836
- // on the column type and driver).
837
- const parentIdSet = new Set(parsedParentIds.map(String));
838
-
839
985
  for (const row of results as Array<Record<string, unknown>>) {
840
986
  const targetRow = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
841
987
 
842
- let parentId: string | number | undefined;
843
-
844
988
  // Junction-backed relations returned earlier in this method, so
845
989
  // what reaches here names the parent with a column on the target.
846
- if (hasForeignKeyOnTarget(relation)) {
847
- parentId = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
848
- }
990
+ if (!hasForeignKeyOnTarget(relation)) continue;
991
+
992
+ const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
993
+ if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
849
994
 
850
- if (parentId !== undefined && parentIdSet.has(String(parentId))) {
995
+ const parentId = parentIdByKey.get(String(foreignKeyValue));
996
+ if (parentId !== undefined) {
851
997
  const key = String(parentId);
852
998
  const arr = resultMap.get(key) || [];
853
999
  arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
@@ -994,10 +1140,19 @@ export class RelationService {
994
1140
  continue;
995
1141
  }
996
1142
 
997
- const parentPks = requirePrimaryKeys(collection, this.registry);
998
- const parentIdInfo = parentPks[0];
999
- const parsedParentIdObj = parseIdValues(id, parentPks);
1000
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1143
+ // What the children's foreign key must hold — the parent's id
1144
+ // unless the link declares a `sourceKey`, and then the value of
1145
+ // that column on this parent row.
1146
+ const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx))
1147
+ .keyByParentId.get(String(id));
1148
+
1149
+ if (parentKeyValue === undefined) {
1150
+ throw new Error(
1151
+ `Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in ` +
1152
+ `\`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is ` +
1153
+ "nothing for the related rows to point at."
1154
+ );
1155
+ }
1001
1156
 
1002
1157
  // Clear existing links not in the new set
1003
1158
  if (targetEntityIds.length > 0) {
@@ -1005,19 +1160,19 @@ export class RelationService {
1005
1160
  await tx
1006
1161
  .update(targetTable)
1007
1162
  .set({ [relation.foreignKeyOnTarget]: null })
1008
- .where(and(eq(fkCol, parsedParentId), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
1163
+ .where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
1009
1164
 
1010
1165
  // Set FK for the provided targets
1011
1166
  await tx
1012
1167
  .update(targetTable)
1013
- .set({ [relation.foreignKeyOnTarget]: parsedParentId })
1168
+ .set({ [relation.foreignKeyOnTarget]: parentKeyValue })
1014
1169
  .where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
1015
1170
  } else {
1016
1171
  // If empty array provided, clear all existing links for this parent
1017
1172
  await tx
1018
1173
  .update(targetTable)
1019
1174
  .set({ [relation.foreignKeyOnTarget]: null })
1020
- .where(eq(fkCol, parsedParentId));
1175
+ .where(eq(fkCol, parentKeyValue));
1021
1176
  }
1022
1177
  } else {
1023
1178
  logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
@@ -1095,14 +1250,25 @@ export class RelationService {
1095
1250
  continue;
1096
1251
  }
1097
1252
 
1098
- const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1099
- const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1253
+ // The value the target's foreign key holds: this row's id, or
1254
+ // the column named by `sourceKey` when the link joins on one.
1255
+ const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx))
1256
+ .keyByParentId.get(String(sourceEntityId));
1257
+
1258
+ if (sourceKeyValue === undefined) {
1259
+ throw new Error(
1260
+ `Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row ` +
1261
+ `'${sourceEntityId}' has no value in \`sourceKey: ` +
1262
+ `"${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing ` +
1263
+ "for the related row to point at."
1264
+ );
1265
+ }
1100
1266
 
1101
1267
  if (newValue === null || newValue === undefined) {
1102
1268
  await tx
1103
1269
  .update(targetTable)
1104
1270
  .set({ [relation.foreignKeyOnTarget!]: null })
1105
- .where(eq(foreignKeyColumn, parsedSourceId));
1271
+ .where(eq(foreignKeyColumn, sourceKeyValue));
1106
1272
  } else {
1107
1273
  const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
1108
1274
  const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
@@ -1112,12 +1278,12 @@ export class RelationService {
1112
1278
  await tx
1113
1279
  .update(targetTable)
1114
1280
  .set({ [relation.foreignKeyOnTarget!]: null })
1115
- .where(eq(foreignKeyColumn, parsedSourceId));
1281
+ .where(eq(foreignKeyColumn, sourceKeyValue));
1116
1282
 
1117
1283
  // Then, update the new target row to point to this source row
1118
1284
  await tx
1119
1285
  .update(targetTable)
1120
- .set({ [relation.foreignKeyOnTarget!]: parsedSourceId })
1286
+ .set({ [relation.foreignKeyOnTarget!]: sourceKeyValue })
1121
1287
  .where(eq(targetIdField, parsedNewTargetId));
1122
1288
  }
1123
1289
  } catch (e) {
@@ -80,15 +80,6 @@ export function resolveChannelBusSetting(configured?: ChannelBusSetting): Channe
80
80
  return raw === "memory" ? { type: "memory" } : { type: "postgres" };
81
81
  }
82
82
 
83
- /**
84
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
85
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
86
- * config-only callers keep their exact types.
87
- */
88
- export function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig {
89
- return resolveChannelBusSetting(configured) as ChannelBusConfig;
90
- }
91
-
92
83
  /**
93
84
  * Produce the bus a setting asks for.
94
85
  *
@@ -1,5 +1,5 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
- import { CollectionConfig, Property } from "@rebasepro/types";
2
+ import { CollectionConfig, Property, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
4
  import { getTableName } from "@rebasepro/common";
5
5
  import { logger } from "@rebasepro/server";
@@ -7,9 +7,9 @@ import { logger } from "@rebasepro/server";
7
7
  // Row identity is derived on both sides of the wire — the driver parses an
8
8
  // incoming address into key columns, the admin derives one from a served row —
9
9
  // so the implementation lives in `common` and both agree by construction.
10
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
10
+ export { buildCompositeId, parseIdValues, isAddressableId, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
11
11
  export type { PrimaryKeyInfo } from "@rebasepro/common";
12
- import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys } from "@rebasepro/common";
12
+ import { buildCompositeId, COMPOSITE_ID_SEPARATOR, getDeclaredPrimaryKeys, isAddressableId } from "@rebasepro/common";
13
13
  import type { PrimaryKeyInfo } from "@rebasepro/common";
14
14
 
15
15
  /**
@@ -40,6 +40,33 @@ export function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta {
40
40
  };
41
41
  }
42
42
 
43
+ /**
44
+ * Whether an address could name a row in this table, judged by the columns.
45
+ *
46
+ * {@link getPrimaryKeys} lets a config's `isId: "uuid"` win over the schema, so
47
+ * its `isUUID` is a claim rather than a fact — right for deriving addresses,
48
+ * wrong for refusing a query. Here the Drizzle column type decides, because it
49
+ * is what Postgres will enforce: a `uuid` column meets `/c/products/new` with
50
+ * `22P02`, which aborts the surrounding transaction and turns every later
51
+ * statement into an unrelated-looking `25P02`.
52
+ */
53
+ export function idCanAddressTable(
54
+ id: string | number,
55
+ table: PgTable,
56
+ idInfoArray: PrimaryKeyInfo[]
57
+ ): boolean {
58
+ const columnBacked = idInfoArray.map(info => {
59
+ const col = table[info.fieldName as keyof typeof table] as AnyPgColumn | undefined;
60
+ const meta = col ? getColumnMeta(col) : undefined;
61
+ // No column to ask (a key the schema does not carry) leaves the id
62
+ // addressable: refusing on a guess would 404 rows that do exist.
63
+ if (!meta?.columnType) return info;
64
+ return { ...info,
65
+ isUUID: meta.columnType === "PgUUID" };
66
+ });
67
+ return isAddressableId(id, columnBacked);
68
+ }
69
+
43
70
  export function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig {
44
71
  const collection = registry.getCollectionByPath(collectionPath);
45
72
  if (!collection) {
@@ -149,6 +176,45 @@ export function requirePrimaryKeys(collection: CollectionConfig, registry: Postg
149
176
  return keys;
150
177
  }
151
178
 
179
+ /**
180
+ * The column on the *source* table that a `hasOne`/`hasMany` link points at.
181
+ *
182
+ * `sourceKey` is authored when the two sides join on a natural key — an
183
+ * external identity id, a SKU — and left off when they join on the row id,
184
+ * which is the overwhelming majority. That makes `undefined` the only optional
185
+ * field on a resolved relation, so it gets exactly one reader: this function.
186
+ * Every consumer that needs the column asks here, and none of them re-derives
187
+ * "or else the primary key" for itself. That is the whole point — the fallback
188
+ * chains this codebase removed from relation resolution were dangerous because
189
+ * they were *duplicated* and could disagree, not because they existed.
190
+ */
191
+ export function sourceKeyField(
192
+ relation: ResolvedHasOne | ResolvedHasMany,
193
+ sourceCollection: CollectionConfig,
194
+ registry: PostgresCollectionRegistry
195
+ ): string {
196
+ if (relation.sourceKey) return relation.sourceKey;
197
+ return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
198
+ }
199
+
200
+ /**
201
+ * Whether this link joins on something other than the source's primary key.
202
+ *
203
+ * Callers that hold a parent *id* — which is most of them, since an id is what
204
+ * a URL carries — must translate it to the source key's value before it can be
205
+ * compared with the target's foreign key. Those that hold the parent *row*, or
206
+ * that build a correlated subquery over the source table, can read the column
207
+ * directly and skip the lookup.
208
+ */
209
+ export function joinsOnNaturalKey(
210
+ relation: ResolvedHasOne | ResolvedHasMany,
211
+ sourceCollection: CollectionConfig,
212
+ registry: PostgresCollectionRegistry
213
+ ): boolean {
214
+ if (!relation.sourceKey) return false;
215
+ return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
216
+ }
217
+
152
218
  /**
153
219
  * Collections whose key the *browser* cannot resolve, and what it will do
154
220
  * instead.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Connection-string rewrites for the tools Rebase shells out to.
3
+ *
4
+ * Pure and dependency-free: `pg-tools` builds argument vectors without a live
5
+ * server, and these have to be usable from there.
6
+ */
7
+
8
+ /**
9
+ * The `sslmode` values libpq accepts. `no-verify` is conspicuously not one.
10
+ */
11
+ const LIBPQ_SSLMODES = new Set(["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]);
12
+
13
+ /**
14
+ * Make a connection string safe to hand to a libpq program.
15
+ *
16
+ * `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
17
+ * the certificate — and libpq does not have it. It does not degrade or warn:
18
+ *
19
+ * psql: error: invalid sslmode value: "no-verify"
20
+ *
21
+ * which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
22
+ * `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
23
+ * points at the value rather than at the convention it comes from. It has cost
24
+ * this project time more than once.
25
+ *
26
+ * `require` is the honest translation: libpq's `require` encrypts and does not
27
+ * verify the certificate either, which is exactly what `no-verify` asks for.
28
+ * Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
29
+ * alone, so a connection string that asked for verification still gets it.
30
+ *
31
+ * Only `sslmode` is touched, and only when it is a value libpq would reject.
32
+ * Anything unparseable is returned as given: a connection that works
33
+ * unverified beats one this corrupted.
34
+ */
35
+ export function forLibpq(connectionString: string): string {
36
+ let url: URL;
37
+ try {
38
+ url = new URL(connectionString);
39
+ } catch {
40
+ // Key/value DSNs and anything else we cannot parse. A `sslmode` in one
41
+ // of those is space-separated rather than a query parameter, and
42
+ // rewriting it with a regex is how a password containing "sslmode="
43
+ // gets mangled.
44
+ return connectionString;
45
+ }
46
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
47
+
48
+ const sslmode = url.searchParams.get("sslmode");
49
+ if (sslmode === null || LIBPQ_SSLMODES.has(sslmode)) return connectionString;
50
+
51
+ url.searchParams.set("sslmode", "require");
52
+ // Re-serialize by hand for the reason `pinSearchPath` does: URLSearchParams
53
+ // writes a space as `+`, which node-postgres decodes and libpq does not.
54
+ url.search = Array.from(url.searchParams.entries())
55
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
56
+ .join("&");
57
+ return url.toString();
58
+ }
@@ -175,10 +175,11 @@ export class DrizzleConditionBuilder {
175
175
  static buildRelationScopeCondition(
176
176
  relation: ResolvedRelation,
177
177
  /**
178
- * Lazy: only `via` and `belongsTo` need the parent's own table. A
179
- * foreign key on the target and a junction are both expressible from
180
- * the parent's *id* alone, and requiring the table for them would make
181
- * a child listing fail on a parent whose table isn't registered.
178
+ * Lazy: `via`, `belongsTo`, and a foreign key that points at a
179
+ * `sourceKey` need the parent's own table. A junction and a plain
180
+ * foreign key are expressible from the parent's *id* alone, and
181
+ * requiring the table for them would make a child listing fail on a
182
+ * parent whose table isn't registered.
182
183
  */
183
184
  parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
184
185
  parentId: string | number,
@@ -239,7 +240,16 @@ export class DrizzleConditionBuilder {
239
240
  `relation '${relation.relationName}'.`
240
241
  );
241
242
  }
242
- return eq(fkColumn, parentId);
243
+ if (!relation.sourceKey) return eq(fkColumn, parentId);
244
+
245
+ // A link on a natural key: the foreign key holds a column of the
246
+ // parent row, not its id, so the parent has to be read. As a
247
+ // subquery rather than a prior SELECT, for the same reason
248
+ // `belongsTo` below is — one statement sees one snapshot, and a
249
+ // scope condition that read the key separately could be built
250
+ // from a value the very next statement no longer agrees with.
251
+ const { table, idColumn } = parent();
252
+ return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
243
253
  }
244
254
 
245
255
  case "belongsTo": {
@@ -385,7 +395,22 @@ export class DrizzleConditionBuilder {
385
395
  // inverse, so reversing it into a filter is a different problem
386
396
  // from the two shapes below rather than a third case of them.
387
397
  if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
388
- return { kind: "relation", relation, registry, sourceIdColumn };
398
+ // The `EXISTS` correlates the target's foreign key with a column
399
+ // on *this* table, and that is the primary key only when the
400
+ // link joins on it. A `sourceKey` names a different one, and
401
+ // correlating on the id anyway silently matches nothing —
402
+ // "filter by this relation" would quietly return zero rows.
403
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
404
+ ? columnAt(relation.sourceKey)
405
+ : sourceIdColumn;
406
+ if (!correlationColumn) {
407
+ throw new Error(
408
+ `\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
409
+ `'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
410
+ "that relation has nothing to correlate against."
411
+ );
412
+ }
413
+ return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
389
414
  }
390
415
  }
391
416