@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,13 +1,14 @@
1
- import { and, eq, inArray, or, sql, SQL } from "drizzle-orm";
1
+ import { and, eq, inArray, notInArray, or, sql, SQL, getTableName as drizzleTableName } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
4
  import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
5
+ import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
6
6
  import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
7
7
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
10
10
  getTableForCollection,
11
+ relationMisconfigured,
11
12
  requirePrimaryKeys,
12
13
  parseIdValues,
13
14
  buildCompositeId,
@@ -17,8 +18,14 @@ import {
17
18
  } from "./collection-helpers";
18
19
  import { parseDataFromServer } from "../data-transformer";
19
20
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
20
- import { logger } from "@rebasepro/server";
21
+ import { ApiError, logger } from "@rebasepro/server";
21
22
  import type { NestedPathHop } from "./nested-path";
23
+ import {
24
+ applyJunctionMembership,
25
+ bindJoinPathJunction,
26
+ bindThroughJunction,
27
+ removeJunctionLink
28
+ } from "./junction-writes";
22
29
 
23
30
  /**
24
31
  * The ids in a to-many relation write, whatever shape the caller sent.
@@ -221,7 +228,11 @@ export class RelationService {
221
228
  return keyByParentId.get(String(parentId));
222
229
  }
223
230
 
224
- private async resolveSourceKeys(
231
+ /**
232
+ * Shared with {@link RelationWriteService}: a write needs the same source
233
+ * key a read does, and resolving it twice is how the two would disagree.
234
+ */
235
+ async resolveSourceKeys(
225
236
  parentCollection: CollectionConfig,
226
237
  relation: ResolvedHasOne | ResolvedHasMany,
227
238
  parentIds: (string | number)[],
@@ -423,7 +434,8 @@ export class RelationService {
423
434
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
424
435
  options.searchString,
425
436
  targetCollection.properties,
426
- targetTable
437
+ targetTable,
438
+ targetCollection
427
439
  );
428
440
 
429
441
  if (searchConditions.length === 0) {
@@ -574,51 +586,6 @@ export class RelationService {
574
586
  return (await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity)) > 0;
575
587
  }
576
588
 
577
- /**
578
- * Remove the junction row linking a parent to `targetId`, leaving the target
579
- * row itself alone.
580
- *
581
- * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
582
- * target is shared, so deleting the row would remove the tag from every other
583
- * post that uses it. It used to do exactly that — resolve the path to the
584
- * `tags` table and delete by primary key.
585
- */
586
- async unlinkRelatedEntity(
587
- tx: DrizzleClient,
588
- hop: NestedPathHop,
589
- targetId: string | number
590
- ): Promise<void> {
591
- if (!isManyToMany(hop.relation)) {
592
- throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
593
- }
594
- const through = hop.relation.through;
595
-
596
- const junctionTable = this.registry.getTable(through.table);
597
- if (!junctionTable) {
598
- throw new Error(`Junction table not found: ${through.table}`);
599
- }
600
-
601
- const sourceJunctionColumn = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
602
- const targetJunctionColumn = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
603
-
604
- if (!sourceJunctionColumn || !targetJunctionColumn) {
605
- throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
606
- }
607
-
608
- const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
609
- const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
610
-
611
- const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
612
- const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
613
-
614
- await tx.delete(junctionTable).where(and(
615
- eq(sourceJunctionColumn, parsedParentId),
616
- eq(targetJunctionColumn, parsedTargetId)
617
- ));
618
-
619
- logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
620
- }
621
-
622
589
  /**
623
590
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
624
591
  */
@@ -708,7 +675,10 @@ export class RelationService {
708
675
  // 3. Map results back to parent rows via their FK values
709
676
  if (relation.kind === "belongsTo") {
710
677
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
711
- const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
678
+ // `localKey` is the column; the generated table is keyed by the wire
679
+ // name, so `author_id` finds nothing where the key is `authorId`.
680
+ const localKeyField = fieldKeyForColumn(parentCollection, relation.localKey);
681
+ const localKeyCol = parentTable[localKeyField as keyof typeof parentTable] as AnyPgColumn;
712
682
  if (!localKeyCol) {
713
683
  throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
714
684
  }
@@ -818,7 +788,9 @@ export class RelationService {
818
788
 
819
789
  // Keyed by string throughout: Drizzle returns a numeric column as a
820
790
  // number or a string depending on the column type and the driver.
821
- const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
791
+ const foreignKeyValue = targetRow[
792
+ fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
793
+ ] as string | number | undefined;
822
794
  if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
823
795
 
824
796
  const parentId = parentIdByKey.get(String(foreignKeyValue));
@@ -905,19 +877,17 @@ export class RelationService {
905
877
  // The junction names its parent with one column, so the same
906
878
  // single-key limit applies as for a direct foreign key.
907
879
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
908
- const junctionTable = this.registry.getTable(relation.through.table);
909
- if (!junctionTable) {
910
- logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
911
- return new Map();
912
- }
913
-
914
- const sourceJunctionCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
915
- const targetJunctionCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
916
880
 
917
- if (!sourceJunctionCol || !targetJunctionCol) {
918
- logger.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
919
- return new Map();
920
- }
881
+ // An empty map here is a lie a caller cannot see through: `posts/1/tags`
882
+ // answering `[]` is exactly what a post with no tags looks like. That
883
+ // is the failure `assertRelationsResolve` was written to make
884
+ // impossible at boot; this says the same thing if one gets past it.
885
+ const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } =
886
+ bindThroughJunction(
887
+ this.registry,
888
+ relation.through,
889
+ `${parentCollection.slug}.${relation.relationName}`
890
+ );
921
891
 
922
892
  // SELECT target.*, junction.sourceColumn FROM junction
923
893
  // INNER JOIN target ON junction.targetColumn = target.id
@@ -989,7 +959,9 @@ export class RelationService {
989
959
  // what reaches here names the parent with a column on the target.
990
960
  if (!hasForeignKeyOnTarget(relation)) continue;
991
961
 
992
- const foreignKeyValue = targetRow[relation.foreignKeyOnTarget] as string | number | undefined;
962
+ const foreignKeyValue = targetRow[
963
+ fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)
964
+ ] as string | number | undefined;
993
965
  if (foreignKeyValue === undefined || foreignKeyValue === null) continue;
994
966
 
995
967
  const parentId = parentIdByKey.get(String(foreignKeyValue));
@@ -1003,635 +975,4 @@ export class RelationService {
1003
975
 
1004
976
  return resultMap;
1005
977
  }
1006
-
1007
- /**
1008
- * Update many-to-many and junction relations
1009
- */
1010
- async updateRelationsUsingJoins<M extends Record<string, unknown>>(
1011
- tx: DrizzleClient,
1012
- collection: CollectionConfig,
1013
- id: string | number,
1014
- relationValues: Partial<M>
1015
- ) {
1016
- const resolvedRelations = resolveCollectionRelations(collection);
1017
-
1018
- for (const [key, value] of Object.entries(relationValues)) {
1019
- const relation = findRelation(resolvedRelations, key);
1020
- if (!relation || relation.cardinality !== "many") continue;
1021
-
1022
- const targetEntityIds = relationTargetIds(value, key, collection.slug);
1023
- const targetCollection = relation.target();
1024
-
1025
- // Use joinPath if available
1026
- if (relation.kind === "via") {
1027
- const parentTableName = getTableName(collection);
1028
- const targetTableName = getTableName(targetCollection);
1029
-
1030
- let junctionTable: PgTable | undefined = undefined;
1031
- let sourceJunctionColumn: AnyPgColumn | null = null;
1032
- let targetJunctionColumn: AnyPgColumn | null = null;
1033
-
1034
- const junctionTableName = relation.joinPath.find(step =>
1035
- step.table !== parentTableName && step.table !== targetTableName
1036
- )?.table;
1037
-
1038
- if (junctionTableName) {
1039
- junctionTable = this.registry.getTable(junctionTableName);
1040
-
1041
- if (junctionTable) {
1042
- for (const joinStep of relation.joinPath) {
1043
- const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
1044
- const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
1045
-
1046
- if (fromTable === parentTableName && toTable === junctionTableName) {
1047
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
1048
- sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1049
- } else if (fromTable === junctionTableName && toTable === parentTableName) {
1050
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
1051
- sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1052
- }
1053
-
1054
- if (fromTable === junctionTableName && toTable === targetTableName) {
1055
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
1056
- targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1057
- } else if (fromTable === targetTableName && toTable === junctionTableName) {
1058
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
1059
- targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1060
- }
1061
- }
1062
- }
1063
- }
1064
-
1065
- if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
1066
- logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
1067
- continue;
1068
- }
1069
-
1070
- const parentPks = requirePrimaryKeys(collection, this.registry);
1071
- const parentIdInfo = parentPks[0];
1072
- const parsedParentIdObj = parseIdValues(id, parentPks);
1073
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1074
-
1075
- // Delete existing relations for this row
1076
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
1077
-
1078
- if (targetEntityIds.length > 0) {
1079
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1080
- const targetIdInfo = targetPks[0];
1081
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1082
-
1083
- const newLinks = parsedTargetIds.map(targetId => ({
1084
- [sourceJunctionColumn.name]: parsedParentId,
1085
- [targetJunctionColumn.name]: targetId
1086
- }));
1087
-
1088
- if (newLinks.length > 0) {
1089
- await tx.insert(junctionTable).values(newLinks);
1090
- }
1091
- }
1092
- } else if (relation.kind === "manyToMany") {
1093
- // Handle many-to-many relations with junction table using 'through' property
1094
- const junctionTable = this.registry.getTable(relation.through.table);
1095
- if (!junctionTable) {
1096
- logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
1097
- continue;
1098
- }
1099
-
1100
- const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1101
- const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1102
-
1103
- if (!sourceJunctionColumn || !targetJunctionColumn) {
1104
- logger.warn(`Junction columns not found for relation '${key}'`);
1105
- continue;
1106
- }
1107
-
1108
- const parentPks = requirePrimaryKeys(collection, this.registry);
1109
- const parentIdInfo = parentPks[0];
1110
- const parsedParentIdObj = parseIdValues(id, parentPks);
1111
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1112
-
1113
- // Delete existing relations for this row
1114
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
1115
-
1116
- if (targetEntityIds.length > 0) {
1117
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1118
- const targetIdInfo = targetPks[0];
1119
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1120
-
1121
- const newLinks = parsedTargetIds.map(targetId => ({
1122
- [sourceJunctionColumn.name]: parsedParentId,
1123
- [targetJunctionColumn.name]: targetId
1124
- }));
1125
-
1126
- if (newLinks.length > 0) {
1127
- await tx.insert(junctionTable).values(newLinks);
1128
- }
1129
- }
1130
- } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
1131
- // Handle one-to-many (inverse) by updating target FK to point to parent
1132
- const targetTable = getTableForCollection(targetCollection, this.registry);
1133
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1134
- const targetIdInfo = targetPks[0];
1135
- const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1136
- const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
1137
-
1138
- if (!fkCol || !targetIdCol) {
1139
- logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
1140
- continue;
1141
- }
1142
-
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
- }
1156
-
1157
- // Clear existing links not in the new set
1158
- if (targetEntityIds.length > 0) {
1159
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1160
- await tx
1161
- .update(targetTable)
1162
- .set({ [relation.foreignKeyOnTarget]: null })
1163
- .where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
1164
-
1165
- // Set FK for the provided targets
1166
- await tx
1167
- .update(targetTable)
1168
- .set({ [relation.foreignKeyOnTarget]: parentKeyValue })
1169
- .where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
1170
- } else {
1171
- // If empty array provided, clear all existing links for this parent
1172
- await tx
1173
- .update(targetTable)
1174
- .set({ [relation.foreignKeyOnTarget]: null })
1175
- .where(eq(fkCol, parentKeyValue));
1176
- }
1177
- } else {
1178
- logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
1179
- }
1180
- }
1181
- }
1182
-
1183
- /**
1184
- * Update inverse relations (where FK is on the target table)
1185
- */
1186
- async updateInverseRelations(
1187
- tx: DrizzleClient,
1188
- sourceCollection: CollectionConfig,
1189
- sourceEntityId: string | number,
1190
- inverseRelationUpdates: Array<{
1191
- relationKey: string;
1192
- relation: ResolvedRelation;
1193
- newValue: unknown;
1194
- }>
1195
- ) {
1196
- for (const update of inverseRelationUpdates) {
1197
- const { relation, newValue } = update;
1198
-
1199
- try {
1200
- const targetCollection = relation.target();
1201
- const targetTable = getTableForCollection(targetCollection, this.registry);
1202
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1203
- const targetIdInfo = targetPks[0];
1204
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1205
- const sourceIdInfo = sourcePks[0];
1206
-
1207
- if (relation.kind === "via") {
1208
- await this.updateInverseJoinPathRelation(
1209
- tx,
1210
- sourceCollection,
1211
- sourceEntityId,
1212
- targetCollection,
1213
- relation,
1214
- newValue
1215
- );
1216
- continue;
1217
- }
1218
-
1219
- // A many-to-many names its own junction. This used to walk the
1220
- // *target's* relations looking for an owning side whose
1221
- // `relationName` matched `inverseRelationName`, and swap its
1222
- // source/target columns — a search that silently did nothing
1223
- // when the far side was declared differently than expected.
1224
- if (isManyToMany(relation)) {
1225
- await this.updateManyToManyInverseRelation(
1226
- tx,
1227
- sourceCollection,
1228
- sourceEntityId,
1229
- targetCollection,
1230
- relation,
1231
- newValue,
1232
- {
1233
- table: relation.through.table,
1234
- sourceColumn: relation.through.sourceColumn,
1235
- targetColumn: relation.through.targetColumn
1236
- }
1237
- );
1238
- continue;
1239
- }
1240
-
1241
- // What is left names the parent with a column on the target.
1242
- if (!hasForeignKeyOnTarget(relation)) {
1243
- logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
1244
- continue;
1245
- }
1246
-
1247
- const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget! as keyof typeof targetTable] as AnyPgColumn;
1248
- if (!foreignKeyColumn) {
1249
- logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
1250
- continue;
1251
- }
1252
-
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
- }
1266
-
1267
- if (newValue === null || newValue === undefined) {
1268
- await tx
1269
- .update(targetTable)
1270
- .set({ [relation.foreignKeyOnTarget!]: null })
1271
- .where(eq(foreignKeyColumn, sourceKeyValue));
1272
- } else {
1273
- const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
1274
- const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
1275
- const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1276
-
1277
- // First, clear any existing FK that points to this source row
1278
- await tx
1279
- .update(targetTable)
1280
- .set({ [relation.foreignKeyOnTarget!]: null })
1281
- .where(eq(foreignKeyColumn, sourceKeyValue));
1282
-
1283
- // Then, update the new target row to point to this source row
1284
- await tx
1285
- .update(targetTable)
1286
- .set({ [relation.foreignKeyOnTarget!]: sourceKeyValue })
1287
- .where(eq(targetIdField, parsedNewTargetId));
1288
- }
1289
- } catch (e) {
1290
- logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
1291
- }
1292
- }
1293
- }
1294
-
1295
- /**
1296
- * Handle inverse relations with joinPath
1297
- */
1298
- private async updateInverseJoinPathRelation(
1299
- tx: DrizzleClient,
1300
- sourceCollection: CollectionConfig,
1301
- sourceEntityId: string | number,
1302
- targetCollection: CollectionConfig,
1303
- relation: ResolvedVia,
1304
- newValue: unknown
1305
- ) {
1306
- try {
1307
-
1308
- const sourceTableName = getTableName(sourceCollection);
1309
- const targetTableName = getTableName(targetCollection);
1310
-
1311
- // Find intermediate tables that are neither source nor target
1312
- const intermediateTables = relation.joinPath
1313
- .map(step => step.table)
1314
- .filter(table => table !== sourceTableName && table !== targetTableName);
1315
-
1316
- // If there's exactly one intermediate table, it's likely a junction table for many-to-many
1317
- if (intermediateTables.length === 1 && relation.cardinality === "many") {
1318
- const junctionTableName = intermediateTables[0];
1319
- const junctionTable = this.registry.getTable(junctionTableName);
1320
-
1321
- if (!junctionTable) {
1322
- logger.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
1323
- return;
1324
- }
1325
-
1326
- let sourceJunctionColumn: AnyPgColumn | null = null;
1327
- let targetJunctionColumn: AnyPgColumn | null = null;
1328
-
1329
- for (const step of relation.joinPath) {
1330
- if (step.table === junctionTableName) {
1331
- const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
1332
- const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
1333
- const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
1334
-
1335
- if (fromTable === sourceTableName) {
1336
- sourceJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1337
- } else if (fromTable === targetTableName) {
1338
- targetJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1339
- } else {
1340
- const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
1341
- if (toTable === sourceTableName) {
1342
- sourceJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1343
- } else if (toTable === targetTableName) {
1344
- targetJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
1345
- }
1346
- }
1347
- }
1348
- }
1349
-
1350
- if (!sourceJunctionColumn || !targetJunctionColumn) {
1351
- logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
1352
- return;
1353
- }
1354
-
1355
- // Perform the junction table update
1356
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1357
- const sourceIdInfo = sourcePks[0];
1358
- const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1359
- const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1360
-
1361
- // Clear existing entries for this source row
1362
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
1363
-
1364
- // Add new entries if newValue is provided
1365
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1366
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1367
- const targetIdInfo = targetPks[0];
1368
- // This path already read both shapes; the other two did not.
1369
- // Same helper now, so the three cannot drift again.
1370
- const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1371
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1372
-
1373
- const newLinks = parsedTargetIds.map(targetId => ({
1374
- [sourceJunctionColumn!.name]: parsedSourceId,
1375
- [targetJunctionColumn!.name]: targetId
1376
- }));
1377
-
1378
- if (newLinks.length > 0) {
1379
- await tx.insert(junctionTable).values(newLinks);
1380
- }
1381
- } else if (newValue && !Array.isArray(newValue)) {
1382
- // Single value for one-to-one
1383
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1384
- const targetIdInfo = targetPks[0];
1385
- const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
1386
- const parsedTargetIdObj = parseIdValues(targetId, targetPks);
1387
- const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
1388
-
1389
- const newLink = {
1390
- [sourceJunctionColumn.name]: parsedSourceId,
1391
- [targetJunctionColumn.name]: parsedTargetId
1392
- };
1393
-
1394
- await tx.insert(junctionTable).values(newLink);
1395
- }
1396
- }
1397
- } catch (error) {
1398
- logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error: error });
1399
- throw error;
1400
- }
1401
- }
1402
-
1403
- /**
1404
- * Handle many-to-many inverse relation updates using junction tables
1405
- */
1406
- private async updateManyToManyInverseRelation(
1407
- tx: DrizzleClient,
1408
- sourceCollection: CollectionConfig,
1409
- sourceEntityId: string | number,
1410
- targetCollection: CollectionConfig,
1411
- relation: ResolvedRelation,
1412
- newValue: unknown,
1413
- junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
1414
- ) {
1415
- try {
1416
- const junctionTable = this.registry.getTable(junctionInfo.table);
1417
- if (!junctionTable) {
1418
- logger.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
1419
- return;
1420
- }
1421
-
1422
- const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1423
- const targetJunctionColumn = junctionTable[junctionInfo.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1424
-
1425
- if (!sourceJunctionColumn || !targetJunctionColumn) {
1426
- logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
1427
- return;
1428
- }
1429
-
1430
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
1431
- const sourceIdInfo = sourcePks[0];
1432
- const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1433
- const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1434
-
1435
- // Clear existing entries for this source row
1436
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
1437
-
1438
- // Add new entries if newValue is provided
1439
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1440
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1441
- const targetIdInfo = targetPks[0];
1442
- const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
1443
- const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1444
-
1445
- const newLinks = parsedTargetIds.map(targetId => ({
1446
- [sourceJunctionColumn.name]: parsedSourceId,
1447
- [targetJunctionColumn.name]: targetId
1448
- }));
1449
-
1450
- if (newLinks.length > 0) {
1451
- await tx.insert(junctionTable).values(newLinks);
1452
- }
1453
- }
1454
- } catch (error) {
1455
- logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error: error });
1456
- throw error;
1457
- }
1458
- }
1459
-
1460
- /**
1461
- * Update one-to-one relations that use joinPath
1462
- */
1463
- async updateJoinPathOneToOneRelations(
1464
- tx: DrizzleClient,
1465
- parentCollection: CollectionConfig,
1466
- parentId: string | number,
1467
- updates: Array<{
1468
- relationKey: string;
1469
- // Only a `via` relation has a join path to write through, and the
1470
- // caller only collects those.
1471
- relation: ResolvedVia;
1472
- newTargetId: string | number | null;
1473
- }>
1474
- ) {
1475
- for (const upd of updates) {
1476
- const { relation, newTargetId } = upd;
1477
- const targetCollection = relation.target();
1478
- const targetTable = getTableForCollection(targetCollection, this.registry);
1479
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1480
- const targetIdInfo = targetPks[0];
1481
- const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1482
-
1483
- // Determine mapping of columns
1484
- const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
1485
- const parentTable = getTableForCollection(parentCollection, this.registry);
1486
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
1487
- const parentIdInfo = parentPks[0];
1488
- const parsedParentIdObj = parseIdValues(parentId, parentPks);
1489
- const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1490
-
1491
- const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
1492
- const parentSourceCol = parentTable[parentSourceColName as keyof typeof parentTable] as AnyPgColumn;
1493
- const targetFKCol = targetTable[targetFKColName as keyof typeof targetTable] as AnyPgColumn;
1494
-
1495
- if (!parentSourceCol) {
1496
- logger.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
1497
- continue;
1498
- }
1499
- if (!targetFKCol) {
1500
- logger.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
1501
- continue;
1502
- }
1503
-
1504
- // Fetch the parent row to obtain the value for parentSourceCol
1505
- const parentRows = await tx
1506
- .select({ val: parentSourceCol })
1507
- .from(parentTable)
1508
- .where(eq(parentIdCol, parsedParentId))
1509
- .limit(1);
1510
- if (parentRows.length === 0) continue;
1511
- const parentFKValue = parentRows[0].val as string | number | null;
1512
-
1513
- if (newTargetId === null || newTargetId === undefined) {
1514
- // Clear any target rows currently linked to this parent via the FK
1515
- if (parentFKValue !== null && parentFKValue !== undefined) {
1516
- await tx.update(targetTable)
1517
- .set({ [targetFKColName]: null })
1518
- .where(eq(targetFKCol, String(parentFKValue)));
1519
- }
1520
- continue;
1521
- }
1522
-
1523
- // Parse the new target id
1524
- const parsedTargetIdObj = parseIdValues(newTargetId, targetPks);
1525
- const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
1526
-
1527
- // Ensure one-to-one by clearing existing link from any target rows with this parent FK
1528
- if (parentFKValue !== null && parentFKValue !== undefined) {
1529
- await tx.update(targetTable)
1530
- .set({ [targetFKColName]: null })
1531
- .where(eq(targetFKCol, String(parentFKValue)));
1532
- } else {
1533
- logger.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
1534
- continue;
1535
- }
1536
-
1537
- // Now set the FK on the target row
1538
- await tx.update(targetTable)
1539
- .set({ [targetFKColName]: parentFKValue })
1540
- .where(eq(targetIdCol, parsedTargetId));
1541
- }
1542
- }
1543
-
1544
- /**
1545
- * Resolve joinPath write mapping for one-to-one relations
1546
- */
1547
- resolveJoinPathWriteMapping(
1548
- parentCollection: CollectionConfig,
1549
- relation: ResolvedVia
1550
- ): { targetFKColName: string; parentSourceColName: string } {
1551
- if (!relation.joinPath || relation.joinPath.length === 0) {
1552
- throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
1553
- }
1554
- const parentTableName = getTableName(parentCollection);
1555
- const lastStep = relation.joinPath[relation.joinPath.length - 1];
1556
- const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
1557
- let currentFrom = lastStep.on.from;
1558
-
1559
- let safety = 0;
1560
- while (safety++ < 10) {
1561
- const currentFromTable = DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0];
1562
- if (currentFromTable === parentTableName) {
1563
- break;
1564
- }
1565
- const prevStep = relation.joinPath.find((s) => {
1566
- const to = Array.isArray(s.on.to) ? s.on.to[0] : s.on.to;
1567
- return to === currentFrom;
1568
- });
1569
- if (!prevStep) {
1570
- throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
1571
- }
1572
- currentFrom = prevStep.on.from;
1573
- }
1574
- const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
1575
- return { targetFKColName,
1576
- parentSourceColName };
1577
- }
1578
-
1579
- /**
1580
- * Handle junction table creation for many-to-many path-based saves
1581
- */
1582
- async handleJunctionTableCreation(
1583
- tx: DrizzleClient,
1584
- newEntityId: string | number,
1585
- junctionTableInfo: {
1586
- parentCollection: CollectionConfig;
1587
- parentId: string | number;
1588
- // Only a junction-backed relation has a link to write, and the
1589
- // caller only builds this for one — stated here so the body needs
1590
- // no narrowing.
1591
- relation: ResolvedManyToMany;
1592
- relationKey: string;
1593
- }
1594
- ) {
1595
- const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
1596
- const targetCollection = relation.target();
1597
-
1598
- try {
1599
- const junctionTable = this.registry.getTable(relation.through.table);
1600
- if (!junctionTable) {
1601
- logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
1602
- return;
1603
- }
1604
-
1605
- const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1606
- const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1607
-
1608
- if (!sourceJunctionColumn || !targetJunctionColumn) {
1609
- logger.warn(`Junction columns not found for relation '${relationKey}'`);
1610
- return;
1611
- }
1612
-
1613
- // Parse the new row ID to the correct type
1614
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
1615
- const targetIdInfo = targetPks[0];
1616
- const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
1617
- const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
1618
-
1619
- // Create the junction table entry linking parent to the target row.
1620
- const junctionData = {
1621
- [sourceJunctionColumn.name]: parentId,
1622
- [targetJunctionColumn.name]: parsedNewEntityId
1623
- };
1624
-
1625
- // Idempotent: a link either exists or it does not, so asking for one
1626
- // twice is not an error. This is what lets `PUT parent/id/child/childId`
1627
- // mean "this row belongs to this parent's set" — the only way to
1628
- // attach an *existing* row, which previously had none.
1629
- await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
1630
-
1631
- logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
1632
- } catch (error) {
1633
- logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error: error });
1634
- throw error;
1635
- }
1636
- }
1637
978
  }