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