@rebasepro/server-postgresql 0.0.1-canary.09e5ec5

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 (196) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +58 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +22 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +11298 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11306 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +192 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +45 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +160 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/builders.d.ts +15 -0
  83. package/dist/types/src/types/chips.d.ts +5 -0
  84. package/dist/types/src/types/collections.d.ts +856 -0
  85. package/dist/types/src/types/cron.d.ts +102 -0
  86. package/dist/types/src/types/data_source.d.ts +64 -0
  87. package/dist/types/src/types/entities.d.ts +145 -0
  88. package/dist/types/src/types/entity_actions.d.ts +98 -0
  89. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  90. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  91. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  92. package/dist/types/src/types/entity_views.d.ts +61 -0
  93. package/dist/types/src/types/export_import.d.ts +21 -0
  94. package/dist/types/src/types/index.d.ts +23 -0
  95. package/dist/types/src/types/locales.d.ts +4 -0
  96. package/dist/types/src/types/modify_collections.d.ts +5 -0
  97. package/dist/types/src/types/plugins.d.ts +279 -0
  98. package/dist/types/src/types/properties.d.ts +1176 -0
  99. package/dist/types/src/types/property_config.d.ts +70 -0
  100. package/dist/types/src/types/relations.d.ts +336 -0
  101. package/dist/types/src/types/slots.d.ts +252 -0
  102. package/dist/types/src/types/translations.d.ts +870 -0
  103. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  104. package/dist/types/src/types/websockets.d.ts +78 -0
  105. package/dist/types/src/users/index.d.ts +2 -0
  106. package/dist/types/src/users/roles.d.ts +22 -0
  107. package/dist/types/src/users/user.d.ts +46 -0
  108. package/drizzle-test/0000_woozy_junta.sql +6 -0
  109. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  110. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  111. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  112. package/drizzle-test/meta/0000_snapshot.json +47 -0
  113. package/drizzle-test/meta/0001_snapshot.json +48 -0
  114. package/drizzle-test/meta/0002_snapshot.json +38 -0
  115. package/drizzle-test/meta/0003_snapshot.json +48 -0
  116. package/drizzle-test/meta/_journal.json +34 -0
  117. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  118. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  119. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  120. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  121. package/drizzle-test-out/meta/_journal.json +20 -0
  122. package/drizzle.test.config.ts +10 -0
  123. package/jest-all.log +3128 -0
  124. package/jest.log +49 -0
  125. package/package.json +92 -0
  126. package/scratch.ts +41 -0
  127. package/src/PostgresBackendDriver.ts +1008 -0
  128. package/src/PostgresBootstrapper.ts +231 -0
  129. package/src/auth/ensure-tables.ts +381 -0
  130. package/src/auth/services.ts +799 -0
  131. package/src/cli.ts +648 -0
  132. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  133. package/src/connection.ts +84 -0
  134. package/src/data-transformer.ts +608 -0
  135. package/src/databasePoolManager.ts +85 -0
  136. package/src/history/HistoryService.ts +248 -0
  137. package/src/history/ensure-history-table.ts +45 -0
  138. package/src/index.ts +13 -0
  139. package/src/interfaces.ts +60 -0
  140. package/src/schema/auth-schema.ts +169 -0
  141. package/src/schema/doctor-cli.ts +47 -0
  142. package/src/schema/doctor.ts +595 -0
  143. package/src/schema/generate-drizzle-schema-logic.ts +765 -0
  144. package/src/schema/generate-drizzle-schema.ts +151 -0
  145. package/src/schema/introspect-db-logic.ts +542 -0
  146. package/src/schema/introspect-db.ts +211 -0
  147. package/src/schema/test-schema.ts +11 -0
  148. package/src/services/BranchService.ts +237 -0
  149. package/src/services/EntityFetchService.ts +1576 -0
  150. package/src/services/EntityPersistService.ts +349 -0
  151. package/src/services/RelationService.ts +1274 -0
  152. package/src/services/entity-helpers.ts +147 -0
  153. package/src/services/entityService.ts +211 -0
  154. package/src/services/index.ts +13 -0
  155. package/src/services/realtimeService.ts +1034 -0
  156. package/src/utils/drizzle-conditions.ts +1000 -0
  157. package/src/websocket.ts +518 -0
  158. package/test/auth-services.test.ts +661 -0
  159. package/test/batch-many-to-many-regression.test.ts +573 -0
  160. package/test/branchService.test.ts +367 -0
  161. package/test/data-transformer-hardening.test.ts +417 -0
  162. package/test/data-transformer.test.ts +175 -0
  163. package/test/doctor.test.ts +182 -0
  164. package/test/drizzle-conditions.test.ts +895 -0
  165. package/test/entityService.errors.test.ts +367 -0
  166. package/test/entityService.relations.test.ts +1008 -0
  167. package/test/entityService.subcollection-search.test.ts +566 -0
  168. package/test/entityService.test.ts +1035 -0
  169. package/test/generate-drizzle-schema.test.ts +988 -0
  170. package/test/historyService.test.ts +141 -0
  171. package/test/introspect-db-generation.test.ts +436 -0
  172. package/test/introspect-db-utils.test.ts +389 -0
  173. package/test/n-plus-one-regression.test.ts +314 -0
  174. package/test/postgresDataDriver.test.ts +648 -0
  175. package/test/realtimeService.test.ts +307 -0
  176. package/test/relation-pipeline-gaps.test.ts +637 -0
  177. package/test/relations.test.ts +1115 -0
  178. package/test/unmapped-tables-safety.test.ts +345 -0
  179. package/test-drizzle-bug.ts +18 -0
  180. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  181. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  182. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  183. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  184. package/test-drizzle-out/meta/_journal.json +20 -0
  185. package/test-drizzle-prompt.sh +2 -0
  186. package/test-policy-prompt.sh +3 -0
  187. package/test-programmatic.ts +30 -0
  188. package/test-programmatic2.ts +59 -0
  189. package/test-schema-no-policies.ts +12 -0
  190. package/test_drizzle_mock.js +3 -0
  191. package/test_find_changed.mjs +32 -0
  192. package/test_hash.js +14 -0
  193. package/test_output.txt +3145 -0
  194. package/tsconfig.json +49 -0
  195. package/tsconfig.prod.json +20 -0
  196. package/vite.config.ts +82 -0
@@ -0,0 +1,1274 @@
1
+ import { and, eq, inArray, sql, SQL } from "drizzle-orm";
2
+ import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
+ import { DrizzleClient } from "../interfaces";
4
+ import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
5
+ import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
+ import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
+ import {
8
+ getCollectionByPath,
9
+ getTableForCollection,
10
+ getPrimaryKeys,
11
+ parseIdValues,
12
+ buildCompositeId
13
+ } from "./entity-helpers";
14
+ import { parseDataFromServer } from "../data-transformer";
15
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
16
+
17
+ /**
18
+ * Service for handling all relation-related operations.
19
+ * Handles fetching, updating, and managing entity relations.
20
+ */
21
+ export class RelationService {
22
+ constructor(private db: DrizzleClient, private registry: PostgresCollectionRegistry) { }
23
+
24
+ /**
25
+ * Fetch entities related to a parent entity through a specific relation
26
+ */
27
+ async fetchRelatedEntities<M extends Record<string, unknown>>(
28
+ parentCollectionPath: string,
29
+ parentEntityId: string | number,
30
+ relationKey: string,
31
+ options: {
32
+ filter?: FilterValues<Extract<keyof M, string>>;
33
+ orderBy?: string;
34
+ order?: "desc" | "asc";
35
+ limit?: number;
36
+ startAfter?: Record<string, unknown>;
37
+ searchString?: string;
38
+ databaseId?: string;
39
+ } = {}
40
+ ): Promise<Entity<M>[]> {
41
+ const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
42
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
43
+ const relation = findRelation(resolvedRelations, relationKey);
44
+
45
+ if (!relation) {
46
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
47
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
48
+ }
49
+
50
+ return this.fetchEntitiesUsingJoins<M>(parentCollection, parentEntityId, relation, options);
51
+ }
52
+
53
+ /**
54
+ * Fetch entities using join paths for complex relations
55
+ */
56
+ async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
57
+ parentCollection: EntityCollection,
58
+ parentEntityId: string | number,
59
+ relation: Relation,
60
+ options: {
61
+ filter?: FilterValues<Extract<keyof M, string>>;
62
+ orderBy?: string;
63
+ order?: "desc" | "asc";
64
+ limit?: number;
65
+ startAfter?: Record<string, unknown>;
66
+ searchString?: string;
67
+ databaseId?: string;
68
+ } = {}
69
+ ): Promise<Entity<M>[]> {
70
+ const targetCollection = relation.target();
71
+ const targetTable = getTableForCollection(targetCollection, this.registry);
72
+ const idInfo = getPrimaryKeys(targetCollection, this.registry);
73
+ const idField = targetTable[idInfo[0].fieldName as keyof typeof targetTable] as AnyPgColumn;
74
+
75
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
76
+ const parentIdInfo = parentPks[0];
77
+ const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
78
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
79
+ const parentTable = this.registry.getTable(getTableName(parentCollection));
80
+ if (!parentTable) throw new Error("Parent table not found");
81
+ const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
82
+
83
+ // Handle join path relations
84
+ if (relation.joinPath && relation.joinPath.length > 0) {
85
+ let query = this.db.select().from(parentTable).$dynamic();
86
+ let currentTable = parentTable;
87
+
88
+ // Apply each join in the path
89
+ for (const join of relation.joinPath) {
90
+ const joinTable = this.registry.getTable(join.table);
91
+ if (!joinTable) {
92
+ throw new Error(`Join table not found: ${join.table}`);
93
+ }
94
+
95
+ const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
96
+ const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
97
+
98
+ const fromParts = fromColumn.split(".");
99
+ const toParts = toColumn.split(".");
100
+
101
+ const fromColName = fromParts[fromParts.length - 1];
102
+ const toColName = toParts[toParts.length - 1];
103
+
104
+ const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
105
+ const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
106
+
107
+ if (!fromCol || !toCol) {
108
+ throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
109
+ }
110
+
111
+ // @ts-expect-error Drizzle mutates base query generic on innerJoin
112
+ query = query.innerJoin(joinTable, eq(fromCol, toCol));
113
+ currentTable = joinTable;
114
+ }
115
+
116
+ // Add where condition for the parent entity
117
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
118
+ query = query.where(eq(parentIdField, parsedParentId));
119
+
120
+ if (options.limit) {
121
+ query = query.limit(options.limit);
122
+ }
123
+
124
+ const results = await query;
125
+ const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
126
+
127
+ // Process results
128
+ const entities: Entity<M>[] = [];
129
+ for (const row of results as Array<Record<string, unknown>>) {
130
+ const targetEntity = (row[targetTableName] as Record<string, unknown>) || row;
131
+ const entityId = targetEntity[idInfo[0].fieldName as string];
132
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection, this.db, this.registry);
133
+
134
+ entities.push({
135
+ id: entityId?.toString() || "",
136
+ path: targetCollection.slug,
137
+ values: parsedValues as M
138
+ });
139
+ }
140
+
141
+ return entities;
142
+ }
143
+
144
+ // Handle other relation types
145
+ let query = this.db.select().from(targetTable).$dynamic();
146
+
147
+ // Build additional filter conditions
148
+ const additionalFilters: SQL[] = [];
149
+
150
+ // Handle search conditions if searchString is provided
151
+ if (options.searchString) {
152
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
153
+ options.searchString,
154
+ targetCollection.properties,
155
+ targetTable
156
+ );
157
+
158
+ if (searchConditions.length === 0) {
159
+ // No searchable fields found, return empty results
160
+ return [];
161
+ }
162
+
163
+ const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
164
+ if (searchCombined) {
165
+ additionalFilters.push(searchCombined);
166
+ }
167
+ }
168
+
169
+ // Use unified relation query builder
170
+ // @ts-expect-error buildRelationQuery uses dynamic queries
171
+ query = DrizzleConditionBuilder.buildRelationQuery(
172
+ query,
173
+ relation,
174
+ parsedParentId,
175
+ targetTable,
176
+ parentTable,
177
+ parentIdCol,
178
+ idField,
179
+ this.registry,
180
+ additionalFilters
181
+ );
182
+
183
+ if (options.limit) {
184
+ query = query.limit(options.limit);
185
+ }
186
+
187
+ const results = await query;
188
+
189
+ // Process results - ensure results is iterable
190
+ if (!results || !Array.isArray(results)) {
191
+ return [];
192
+ }
193
+
194
+ const entities: Entity<M>[] = [];
195
+ for (const row of results) {
196
+ const targetEntity = row[getTableName(targetCollection)] || row;
197
+ const entityId = targetEntity[idInfo[0].fieldName];
198
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection, this.db, this.registry);
199
+
200
+ entities.push({
201
+ id: entityId?.toString() || "",
202
+ path: targetCollection.slug,
203
+ values: parsedValues as M
204
+ });
205
+ }
206
+
207
+ return entities;
208
+ }
209
+
210
+ /**
211
+ * Count related entities for a parent entity
212
+ */
213
+ async countRelatedEntities<M extends Record<string, unknown>>(
214
+ parentCollectionPath: string,
215
+ parentEntityId: string | number,
216
+ relationKey: string,
217
+ options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
218
+ ): Promise<number> {
219
+ const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
220
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
221
+ const relation = findRelation(resolvedRelations, relationKey);
222
+ if (!relation) {
223
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
224
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
225
+ }
226
+
227
+ const targetCollection = relation.target();
228
+ const targetTable = getTableForCollection(targetCollection, this.registry);
229
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
230
+ const targetIdInfo = targetPks[0];
231
+ const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
232
+
233
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
234
+ const parentIdInfo = parentPks[0];
235
+ const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
236
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
237
+ const parentTable = this.registry.getTable(getTableName(parentCollection));
238
+ if (!parentTable) throw new Error("Parent table not found");
239
+ const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
240
+
241
+ // Start count with distinct to avoid duplicates from junction tables
242
+ let query = this.db.select({ count: sql<number>`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
243
+
244
+ // Build additional filter conditions
245
+ const additionalFilters: SQL[] = [];
246
+
247
+ // Use unified count query builder from DrizzleConditionBuilder
248
+ query = DrizzleConditionBuilder.buildRelationCountQuery(
249
+ query,
250
+ relation,
251
+ parsedParentId,
252
+ targetTable,
253
+ parentTable,
254
+ parentIdCol,
255
+ targetIdField,
256
+ this.registry,
257
+ additionalFilters
258
+ );
259
+
260
+ const result = await query;
261
+ return Number(result[0]?.count || 0);
262
+ }
263
+
264
+ /**
265
+ * Batch fetch related entities for multiple parent entities to avoid N+1 queries
266
+ */
267
+ async batchFetchRelatedEntities(
268
+ parentCollectionPath: string,
269
+ parentEntityIds: (string | number)[],
270
+ _relationKey: string,
271
+ relation: Relation
272
+ ): Promise<Map<string, Entity<Record<string, unknown>>>> {
273
+ if (parentEntityIds.length === 0) return new Map();
274
+
275
+ const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
276
+ const targetCollection = relation.target();
277
+ const targetTable = getTableForCollection(targetCollection, this.registry);
278
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
279
+ const targetIdInfo = targetPks[0];
280
+ const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
281
+
282
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
283
+ const parentIdInfo = parentPks[0];
284
+ const parentTable = this.registry.getTable(getTableName(parentCollection));
285
+ if (!parentTable) throw new Error("Parent table not found");
286
+ const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
287
+
288
+ // Parse all parent IDs once
289
+ const parsedParentIds = parentEntityIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
290
+
291
+ // Handle join path relations with batching
292
+ if (relation.joinPath && relation.joinPath.length > 0) {
293
+ let query = this.db.select().from(parentTable).$dynamic();
294
+ let currentTable = parentTable;
295
+
296
+ // Apply each join in the path
297
+ for (const join of relation.joinPath) {
298
+ const joinTable = this.registry.getTable(join.table);
299
+ if (!joinTable) {
300
+ throw new Error(`Join table not found: ${join.table}`);
301
+ }
302
+
303
+ const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
304
+ const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
305
+
306
+ const fromParts = fromColumn.split(".");
307
+ const toParts = toColumn.split(".");
308
+
309
+ const fromColName = fromParts[fromParts.length - 1];
310
+ const toColName = toParts[toParts.length - 1];
311
+
312
+ const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
313
+ const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
314
+
315
+ if (!fromCol || !toCol) {
316
+ throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
317
+ }
318
+
319
+ // @ts-expect-error Drizzle mutates base query generic on innerJoin
320
+ query = query.innerJoin(joinTable, eq(fromCol, toCol));
321
+ currentTable = joinTable;
322
+ }
323
+
324
+ // Add where condition for ALL parent entities at once
325
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
326
+ query = query.where(inArray(parentIdField, parsedParentIds));
327
+
328
+ const results = await query;
329
+ const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
330
+ const resultMap = new Map<string, Entity<Record<string, unknown>>>();
331
+
332
+ // Group results by parent ID
333
+ for (const row of results as Array<Record<string, unknown>>) {
334
+ const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
335
+ const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
336
+ const parentId = parentEntity[parentIdInfo.fieldName] as string | number;
337
+
338
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
339
+
340
+ resultMap.set(String(parentId), {
341
+ id: String(targetEntity[targetIdInfo.fieldName]),
342
+ path: targetCollection.slug,
343
+ values: parsedValues as Record<string, unknown>
344
+ });
345
+ }
346
+
347
+ return resultMap;
348
+ }
349
+
350
+ // Handle owning relations with proper FK-based batching.
351
+ // For owning relations, parent entities hold the FK (e.g. posts.author_id).
352
+ // We need to:
353
+ // 1. Fetch FK values from the parent table in a single query
354
+ // 2. Query the target table with unique FK values
355
+ // 3. Map results back to parent entities via their FK values
356
+ if (relation.direction === "owning" && relation.localKey) {
357
+ const localKeyCol = parentTable[relation.localKey as keyof typeof parentTable] as AnyPgColumn;
358
+ if (!localKeyCol) {
359
+ throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
360
+ }
361
+
362
+ // Step 1: Fetch all FK values from parent table in ONE query
363
+ const fkRows = await this.db
364
+ .select({
365
+ parentId: parentIdCol,
366
+ fkValue: localKeyCol
367
+ })
368
+ .from(parentTable)
369
+ .where(inArray(parentIdCol, parsedParentIds));
370
+
371
+ // Build parentId → fkValue mapping and collect unique FK values
372
+ const parentToFk = new Map<string, string | number>();
373
+ const uniqueFkValues: (string | number)[] = [];
374
+ const seenFks = new Set<string>();
375
+
376
+ for (const row of fkRows as Array<{ parentId: string | number; fkValue: string | number | null }>) {
377
+ if (row.fkValue == null) continue;
378
+ parentToFk.set(String(row.parentId), row.fkValue);
379
+ const fkStr = String(row.fkValue);
380
+ if (!seenFks.has(fkStr)) {
381
+ seenFks.add(fkStr);
382
+ uniqueFkValues.push(row.fkValue);
383
+ }
384
+ }
385
+
386
+ if (uniqueFkValues.length === 0) return new Map();
387
+
388
+ // Step 2: Fetch all target entities in ONE query
389
+ const targetResults = await this.db
390
+ .select()
391
+ .from(targetTable)
392
+ .where(inArray(targetIdField, uniqueFkValues));
393
+
394
+ // Index target entities by their ID
395
+ const targetById = new Map<string, Record<string, unknown>>();
396
+ for (const row of targetResults as Array<Record<string, unknown>>) {
397
+ const tid = String(row[targetIdInfo.fieldName]);
398
+ targetById.set(tid, row);
399
+ }
400
+
401
+ // Step 3: Map back to parent entities
402
+ const resultMap = new Map<string, Entity<Record<string, unknown>>>();
403
+ for (const [parentIdStr, fkValue] of parentToFk) {
404
+ const targetEntity = targetById.get(String(fkValue));
405
+ if (targetEntity) {
406
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
407
+ resultMap.set(parentIdStr, {
408
+ id: String(targetEntity[targetIdInfo.fieldName]),
409
+ path: targetCollection.slug,
410
+ values: parsedValues as Record<string, unknown>
411
+ });
412
+ }
413
+ }
414
+
415
+ return resultMap;
416
+ }
417
+
418
+ // Handle inverse relation types with batching
419
+ let query = this.db.select().from(targetTable).$dynamic();
420
+
421
+ // Build the relation query with ALL parent IDs
422
+ // @ts-expect-error buildRelationQuery uses dynamic queries
423
+ query = DrizzleConditionBuilder.buildRelationQuery(
424
+ query,
425
+ relation,
426
+ parsedParentIds, // Pass array instead of single ID
427
+ targetTable,
428
+ parentTable,
429
+ parentIdCol,
430
+ targetIdField,
431
+ this.registry,
432
+ []
433
+ );
434
+
435
+ const results = await query;
436
+ const resultMap = new Map<string, Entity<Record<string, unknown>>>();
437
+
438
+ // Build a Set<string> for O(1) parent-ID lookups that is immune to
439
+ // number-vs-string type mismatches (Drizzle may return either depending
440
+ // on the column type and driver).
441
+ const parentIdSet = new Set(parsedParentIds.map(String));
442
+
443
+ // Map results back to parent entities
444
+ for (const row of results as Array<Record<string, unknown>>) {
445
+ const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
446
+
447
+ // Determine the parent ID this result belongs to based on the relation type
448
+ let parentId: string | number | undefined;
449
+
450
+ if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
451
+ parentId = targetEntity[relation.foreignKeyOnTarget] as string | number | undefined;
452
+ } else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
453
+ const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
454
+ parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
455
+ }
456
+
457
+ if (parentId !== undefined && parentIdSet.has(String(parentId))) {
458
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
459
+ resultMap.set(String(parentId), {
460
+ id: String(targetEntity[targetIdInfo.fieldName]),
461
+ path: targetCollection.slug,
462
+ values: parsedValues as Record<string, unknown>
463
+ });
464
+ }
465
+ }
466
+
467
+ return resultMap;
468
+ }
469
+
470
+ /**
471
+ * Batch fetch many-cardinality related entities for multiple parent entities.
472
+ * Returns a Map<parentId, Entity[]> instead of Map<parentId, Entity>.
473
+ * Uses a single SQL query with IN clause to avoid N+1.
474
+ */
475
+ async batchFetchRelatedEntitiesMany(
476
+ parentCollectionPath: string,
477
+ parentEntityIds: (string | number)[],
478
+ _relationKey: string,
479
+ relation: Relation
480
+ ): Promise<Map<string, Entity<Record<string, unknown>>[]>> {
481
+ if (parentEntityIds.length === 0) return new Map();
482
+
483
+ const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
484
+ const targetCollection = relation.target();
485
+ const targetTable = getTableForCollection(targetCollection, this.registry);
486
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
487
+ const targetIdInfo = targetPks[0];
488
+ const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
489
+
490
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
491
+ const parentIdInfo = parentPks[0];
492
+ const parentTable = this.registry.getTable(getTableName(parentCollection));
493
+ if (!parentTable) throw new Error("Parent table not found");
494
+ const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
495
+
496
+ const parsedParentIds = parentEntityIds.map(id => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
497
+
498
+ // Handle join path relations (many-to-many through junction tables)
499
+ if (relation.joinPath && relation.joinPath.length > 0) {
500
+ let query = this.db.select().from(parentTable).$dynamic();
501
+ let currentTable = parentTable;
502
+
503
+ for (const join of relation.joinPath) {
504
+ const joinTable = this.registry.getTable(join.table);
505
+ if (!joinTable) throw new Error(`Join table not found: ${join.table}`);
506
+
507
+ const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
508
+ const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
509
+ const fromColName = fromColumn.split(".").pop()!;
510
+ const toColName = toColumn.split(".").pop()!;
511
+
512
+ const fromCol = currentTable[fromColName as keyof typeof currentTable] as AnyPgColumn;
513
+ const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
514
+ if (!fromCol || !toCol) throw new Error(`Join columns not found: ${fromColumn} -> ${toColumn}`);
515
+
516
+ // @ts-expect-error Drizzle mutates base query generic on innerJoin
517
+ query = query.innerJoin(joinTable, eq(fromCol, toCol));
518
+ currentTable = joinTable;
519
+ }
520
+
521
+ const parentIdField = parentTable[getPrimaryKeys(parentCollection, this.registry)[0].fieldName as keyof typeof parentTable] as AnyPgColumn;
522
+ query = query.where(inArray(parentIdField, parsedParentIds));
523
+
524
+ const results = await query;
525
+ const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
526
+ const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
527
+
528
+ for (const row of results as Array<Record<string, unknown>>) {
529
+ const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
530
+ const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
531
+ const parentId = String(parentEntity[parentIdInfo.fieldName]);
532
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
533
+
534
+ const arr = resultMap.get(parentId) || [];
535
+ arr.push({
536
+ id: String(targetEntity[targetIdInfo.fieldName]),
537
+ path: targetCollection.slug,
538
+ values: parsedValues as Record<string, unknown>
539
+ });
540
+ resultMap.set(parentId, arr);
541
+ }
542
+
543
+ return resultMap;
544
+ }
545
+
546
+ // Handle many-to-many owning relations with junction table (relation.through)
547
+ // This is the standard path for posts→tags style relations where
548
+ // sanitizeRelation populated the `through` config.
549
+ if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
550
+ const junctionTable = this.registry.getTable(relation.through.table);
551
+ if (!junctionTable) {
552
+ console.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
553
+ return new Map();
554
+ }
555
+
556
+ const sourceJunctionCol = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
557
+ const targetJunctionCol = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
558
+
559
+ if (!sourceJunctionCol || !targetJunctionCol) {
560
+ console.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
561
+ return new Map();
562
+ }
563
+
564
+ // SELECT target.*, junction.sourceColumn FROM junction
565
+ // INNER JOIN target ON junction.targetColumn = target.id
566
+ // WHERE junction.sourceColumn IN (parentIds)
567
+ const query = this.db
568
+ .select()
569
+ .from(junctionTable)
570
+ .innerJoin(targetTable, eq(targetJunctionCol, targetIdField))
571
+ .where(inArray(sourceJunctionCol, parsedParentIds));
572
+
573
+ const results = await query;
574
+ const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
575
+ const targetTableName = getTableName(targetCollection);
576
+
577
+ for (const row of results as Array<Record<string, unknown>>) {
578
+ // The junction table data is namespaced under its table name
579
+ const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
580
+ const targetData = (row[targetTableName] || row) as Record<string, unknown>;
581
+
582
+ const parentId = String(junctionData[relation.through.sourceColumn]);
583
+ const parsedValues = await parseDataFromServer(targetData, targetCollection);
584
+
585
+ const arr = resultMap.get(parentId) || [];
586
+ arr.push({
587
+ id: String(targetData[targetIdInfo.fieldName]),
588
+ path: targetCollection.slug,
589
+ values: parsedValues as Record<string, unknown>
590
+ });
591
+ resultMap.set(parentId, arr);
592
+ }
593
+
594
+ return resultMap;
595
+ }
596
+
597
+ // Handle FK-based relations (one-to-many inverse)
598
+ let query = this.db.select().from(targetTable).$dynamic();
599
+
600
+ // @ts-expect-error buildRelationQuery uses dynamic queries
601
+ query = DrizzleConditionBuilder.buildRelationQuery(
602
+ query,
603
+ relation,
604
+ parsedParentIds,
605
+ targetTable,
606
+ parentTable,
607
+ parentIdCol,
608
+ targetIdField,
609
+ this.registry,
610
+ []
611
+ );
612
+
613
+ const results = await query;
614
+ const resultMap = new Map<string, Entity<Record<string, unknown>>[]>();
615
+
616
+ // Build a Set<string> for O(1) parent-ID lookups that is immune to
617
+ // number-vs-string type mismatches (Drizzle may return either depending
618
+ // on the column type and driver).
619
+ const parentIdSet = new Set(parsedParentIds.map(String));
620
+
621
+ for (const row of results as Array<Record<string, unknown>>) {
622
+ const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
623
+
624
+ let parentId: string | number | undefined;
625
+
626
+ if (relation.through && relation.direction === "inverse") {
627
+ // Inverse many-to-many via junction table: the junction's targetColumn
628
+ // references the parent (since from the inverse perspective, source/target are swapped).
629
+ const junctionData = (row[relation.through.table] || row) as Record<string, unknown>;
630
+ parentId = junctionData[relation.through.targetColumn] as string | number | undefined;
631
+ } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
632
+ parentId = targetEntity[relation.foreignKeyOnTarget] as string | number | undefined;
633
+ } else if (relation.direction === "inverse" && relation.inverseRelationName) {
634
+ const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
635
+ parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
636
+ }
637
+
638
+ if (parentId !== undefined && parentIdSet.has(String(parentId))) {
639
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
640
+ const key = String(parentId);
641
+ const arr = resultMap.get(key) || [];
642
+ arr.push({
643
+ id: String(targetEntity[targetIdInfo.fieldName]),
644
+ path: targetCollection.slug,
645
+ values: parsedValues as Record<string, unknown>
646
+ });
647
+ resultMap.set(key, arr);
648
+ }
649
+ }
650
+
651
+ return resultMap;
652
+ }
653
+
654
+ /**
655
+ * Update many-to-many and junction relations
656
+ */
657
+ async updateRelationsUsingJoins<M extends Record<string, unknown>>(
658
+ tx: DrizzleClient,
659
+ collection: EntityCollection,
660
+ entityId: string | number,
661
+ relationValues: Partial<M>
662
+ ) {
663
+ const resolvedRelations = resolveCollectionRelations(collection);
664
+
665
+ for (const [key, value] of Object.entries(relationValues)) {
666
+ const relation = findRelation(resolvedRelations, key);
667
+ if (!relation || relation.cardinality !== "many") continue;
668
+
669
+ const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
670
+ const targetCollection = relation.target();
671
+
672
+ // Use joinPath if available
673
+ if (relation.joinPath && relation.joinPath.length > 0) {
674
+ const parentTableName = getTableName(collection);
675
+ const targetTableName = getTableName(targetCollection);
676
+
677
+ let junctionTable: PgTable | undefined = undefined;
678
+ let sourceJunctionColumn: AnyPgColumn | null = null;
679
+ let targetJunctionColumn: AnyPgColumn | null = null;
680
+
681
+ const junctionTableName = relation.joinPath.find(step =>
682
+ step.table !== parentTableName && step.table !== targetTableName
683
+ )?.table;
684
+
685
+ if (junctionTableName) {
686
+ junctionTable = this.registry.getTable(junctionTableName);
687
+
688
+ if (junctionTable) {
689
+ for (const joinStep of relation.joinPath) {
690
+ const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
691
+ const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
692
+
693
+ if (fromTable === parentTableName && toTable === junctionTableName) {
694
+ const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
695
+ sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
696
+ } else if (fromTable === junctionTableName && toTable === parentTableName) {
697
+ const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
698
+ sourceJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
699
+ }
700
+
701
+ if (fromTable === junctionTableName && toTable === targetTableName) {
702
+ const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
703
+ targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
704
+ } else if (fromTable === targetTableName && toTable === junctionTableName) {
705
+ const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
706
+ targetJunctionColumn = junctionTable[columnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
707
+ }
708
+ }
709
+ }
710
+ }
711
+
712
+ if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
713
+ console.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
714
+ continue;
715
+ }
716
+
717
+ const parentPks = getPrimaryKeys(collection, this.registry);
718
+ const parentIdInfo = parentPks[0];
719
+ const parsedParentIdObj = parseIdValues(entityId, parentPks);
720
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
721
+
722
+ // Delete existing relations for this entity
723
+ await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
724
+
725
+ if (targetEntityIds.length > 0) {
726
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
727
+ const targetIdInfo = targetPks[0];
728
+ const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
729
+
730
+ const newLinks = parsedTargetIds.map(targetId => ({
731
+ [sourceJunctionColumn.name]: parsedParentId,
732
+ [targetJunctionColumn.name]: targetId
733
+ }));
734
+
735
+ if (newLinks.length > 0) {
736
+ await tx.insert(junctionTable).values(newLinks);
737
+ }
738
+ }
739
+ } else if (relation.through && relation.cardinality === "many" && relation.direction === "owning") {
740
+ // Handle many-to-many relations with junction table using 'through' property
741
+ const junctionTable = this.registry.getTable(relation.through.table);
742
+ if (!junctionTable) {
743
+ console.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
744
+ continue;
745
+ }
746
+
747
+ const sourceJunctionColumn = junctionTable[relation.through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
748
+ const targetJunctionColumn = junctionTable[relation.through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
749
+
750
+ if (!sourceJunctionColumn || !targetJunctionColumn) {
751
+ console.warn(`Junction columns not found for relation '${key}'`);
752
+ continue;
753
+ }
754
+
755
+ const parentPks = getPrimaryKeys(collection, this.registry);
756
+ const parentIdInfo = parentPks[0];
757
+ const parsedParentIdObj = parseIdValues(entityId, parentPks);
758
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
759
+
760
+ // Delete existing relations for this entity
761
+ await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
762
+
763
+ if (targetEntityIds.length > 0) {
764
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
765
+ const targetIdInfo = targetPks[0];
766
+ const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
767
+
768
+ const newLinks = parsedTargetIds.map(targetId => ({
769
+ [sourceJunctionColumn.name]: parsedParentId,
770
+ [targetJunctionColumn.name]: targetId
771
+ }));
772
+
773
+ if (newLinks.length > 0) {
774
+ await tx.insert(junctionTable).values(newLinks);
775
+ }
776
+ }
777
+ } else if (relation.through && relation.cardinality === "many" && relation.direction === "inverse") {
778
+ // Inverse M2M relations should be saved from the owning side.
779
+ // The owning collection manages the junction table rows.
780
+ console.warn(`[updateRelationsUsingJoins] Inverse M2M relation '${key}' in collection '${collection.slug}' should be saved from the owning side. Skipping.`);
781
+ } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
782
+ // Handle one-to-many (inverse) by updating target FK to point to parent
783
+ const targetTable = getTableForCollection(targetCollection, this.registry);
784
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
785
+ const targetIdInfo = targetPks[0];
786
+ const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
787
+ const fkCol = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
788
+
789
+ if (!fkCol || !targetIdCol) {
790
+ console.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
791
+ continue;
792
+ }
793
+
794
+ const parentPks = getPrimaryKeys(collection, this.registry);
795
+ const parentIdInfo = parentPks[0];
796
+ const parsedParentIdObj = parseIdValues(entityId, parentPks);
797
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
798
+
799
+ // Clear existing links not in the new set
800
+ if (targetEntityIds.length > 0) {
801
+ const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
802
+ await tx
803
+ .update(targetTable)
804
+ .set({ [relation.foreignKeyOnTarget]: null })
805
+ .where(and(eq(fkCol, parsedParentId), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
806
+
807
+ // Set FK for the provided targets
808
+ await tx
809
+ .update(targetTable)
810
+ .set({ [relation.foreignKeyOnTarget]: parsedParentId })
811
+ .where(inArray(targetIdCol as AnyPgColumn, parsedTargetIds as unknown[]));
812
+ } else {
813
+ // If empty array provided, clear all existing links for this parent
814
+ await tx
815
+ .update(targetTable)
816
+ .set({ [relation.foreignKeyOnTarget]: null })
817
+ .where(eq(fkCol, parsedParentId));
818
+ }
819
+ } else {
820
+ console.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
821
+ }
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Update inverse relations (where FK is on the target table)
827
+ */
828
+ async updateInverseRelations(
829
+ tx: DrizzleClient,
830
+ sourceCollection: EntityCollection,
831
+ sourceEntityId: string | number,
832
+ inverseRelationUpdates: Array<{
833
+ relationKey: string;
834
+ relation: Relation;
835
+ newValue: unknown;
836
+ currentEntityId?: string | number;
837
+ }>
838
+ ) {
839
+ for (const update of inverseRelationUpdates) {
840
+ const { relation, newValue } = update;
841
+
842
+ try {
843
+ const targetCollection = relation.target();
844
+ const targetTable = getTableForCollection(targetCollection, this.registry);
845
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
846
+ const targetIdInfo = targetPks[0];
847
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
848
+ const sourceIdInfo = sourcePks[0];
849
+
850
+ // Handle inverse relations with joinPath
851
+ if (relation.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
852
+ await this.updateInverseJoinPathRelation(
853
+ tx,
854
+ sourceCollection,
855
+ sourceEntityId,
856
+ targetCollection,
857
+ relation,
858
+ newValue
859
+ );
860
+ continue;
861
+ }
862
+
863
+ // Check if this is a many-to-many inverse relation
864
+ if (relation.cardinality === "many" && relation.direction === "inverse") {
865
+ const targetCollectionRelations = resolveCollectionRelations(targetCollection);
866
+ let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
867
+
868
+ for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
869
+ if (targetRelation.cardinality === "many" &&
870
+ targetRelation.direction === "owning" &&
871
+ targetRelation.through &&
872
+ (targetRelation.relationName === relation.inverseRelationName || relationKey === relation.inverseRelationName)) {
873
+ junctionInfo = {
874
+ table: targetRelation.through.table,
875
+ sourceColumn: targetRelation.through.targetColumn,
876
+ targetColumn: targetRelation.through.sourceColumn
877
+ };
878
+ break;
879
+ }
880
+ }
881
+
882
+ if (junctionInfo) {
883
+ await this.updateManyToManyInverseRelation(
884
+ tx,
885
+ sourceCollection,
886
+ sourceEntityId,
887
+ targetCollection,
888
+ relation,
889
+ newValue,
890
+ junctionInfo
891
+ );
892
+ continue;
893
+ }
894
+ }
895
+
896
+ // Handle simple inverse relations
897
+ if (!relation.foreignKeyOnTarget) {
898
+ console.warn(`Inverse relation '${relation.relationName}' is missing foreignKeyOnTarget property. Skipping.`);
899
+ continue;
900
+ }
901
+
902
+ const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget! as keyof typeof targetTable] as AnyPgColumn;
903
+ if (!foreignKeyColumn) {
904
+ console.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
905
+ continue;
906
+ }
907
+
908
+ const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
909
+ const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
910
+
911
+ if (newValue === null || newValue === undefined) {
912
+ await tx
913
+ .update(targetTable)
914
+ .set({ [relation.foreignKeyOnTarget!]: null })
915
+ .where(eq(foreignKeyColumn, parsedSourceId));
916
+ } else {
917
+ const parsedNewTargetIdObj = parseIdValues(newValue as string | number, targetPks);
918
+ const parsedNewTargetId = parsedNewTargetIdObj[targetIdInfo.fieldName];
919
+ const targetIdField = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
920
+
921
+ // First, clear any existing FK that points to this source entity
922
+ await tx
923
+ .update(targetTable)
924
+ .set({ [relation.foreignKeyOnTarget!]: null })
925
+ .where(eq(foreignKeyColumn, parsedSourceId));
926
+
927
+ // Then, update the new target entity to point to this source entity
928
+ await tx
929
+ .update(targetTable)
930
+ .set({ [relation.foreignKeyOnTarget!]: parsedSourceId })
931
+ .where(eq(targetIdField, parsedNewTargetId));
932
+ }
933
+ } catch (e) {
934
+ console.warn(`Failed to update inverse relation '${relation.relationName}':`, e);
935
+ }
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Handle inverse relations with joinPath
941
+ */
942
+ private async updateInverseJoinPathRelation(
943
+ tx: DrizzleClient,
944
+ sourceCollection: EntityCollection,
945
+ sourceEntityId: string | number,
946
+ targetCollection: EntityCollection,
947
+ relation: Relation,
948
+ newValue: unknown
949
+ ) {
950
+ try {
951
+ if (!relation.joinPath || relation.joinPath.length === 0) {
952
+ console.warn(`Inverse relation '${relation.relationName}' missing joinPath`);
953
+ return;
954
+ }
955
+
956
+ const sourceTableName = getTableName(sourceCollection);
957
+ const targetTableName = getTableName(targetCollection);
958
+
959
+ // Find intermediate tables that are neither source nor target
960
+ const intermediateTables = relation.joinPath
961
+ .map(step => step.table)
962
+ .filter(table => table !== sourceTableName && table !== targetTableName);
963
+
964
+ // If there's exactly one intermediate table, it's likely a junction table for many-to-many
965
+ if (intermediateTables.length === 1 && relation.cardinality === "many") {
966
+ const junctionTableName = intermediateTables[0];
967
+ const junctionTable = this.registry.getTable(junctionTableName);
968
+
969
+ if (!junctionTable) {
970
+ console.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
971
+ return;
972
+ }
973
+
974
+ let sourceJunctionColumn: AnyPgColumn | null = null;
975
+ let targetJunctionColumn: AnyPgColumn | null = null;
976
+
977
+ for (const step of relation.joinPath) {
978
+ if (step.table === junctionTableName) {
979
+ const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
980
+ const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
981
+ const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
982
+
983
+ if (fromTable === sourceTableName) {
984
+ sourceJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
985
+ } else if (fromTable === targetTableName) {
986
+ targetJunctionColumn = junctionTable[toColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
987
+ } else {
988
+ const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
989
+ if (toTable === sourceTableName) {
990
+ sourceJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
991
+ } else if (toTable === targetTableName) {
992
+ targetJunctionColumn = junctionTable[fromColumnNames[0] as keyof typeof junctionTable] as AnyPgColumn;
993
+ }
994
+ }
995
+ }
996
+ }
997
+
998
+ if (!sourceJunctionColumn || !targetJunctionColumn) {
999
+ console.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
1000
+ return;
1001
+ }
1002
+
1003
+ // Perform the junction table update
1004
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1005
+ const sourceIdInfo = sourcePks[0];
1006
+ const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1007
+ const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1008
+
1009
+ // Clear existing entries for this source entity
1010
+ await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
1011
+
1012
+ // Add new entries if newValue is provided
1013
+ if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1014
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1015
+ const targetIdInfo = targetPks[0];
1016
+ const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
1017
+ const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1018
+
1019
+ const newLinks = parsedTargetIds.map(targetId => ({
1020
+ [sourceJunctionColumn!.name]: parsedSourceId,
1021
+ [targetJunctionColumn!.name]: targetId
1022
+ }));
1023
+
1024
+ if (newLinks.length > 0) {
1025
+ await tx.insert(junctionTable).values(newLinks);
1026
+ }
1027
+ } else if (newValue && !Array.isArray(newValue)) {
1028
+ // Single value for one-to-one
1029
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1030
+ const targetIdInfo = targetPks[0];
1031
+ const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
1032
+ const parsedTargetIdObj = parseIdValues(targetId, targetPks);
1033
+ const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
1034
+
1035
+ const newLink = {
1036
+ [sourceJunctionColumn.name]: parsedSourceId,
1037
+ [targetJunctionColumn.name]: parsedTargetId
1038
+ };
1039
+
1040
+ await tx.insert(junctionTable).values(newLink);
1041
+ }
1042
+ }
1043
+ } catch (error) {
1044
+ console.error(`Failed to update inverse joinPath relation '${relation.relationName}':`, error);
1045
+ throw error;
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Handle many-to-many inverse relation updates using junction tables
1051
+ */
1052
+ private async updateManyToManyInverseRelation(
1053
+ tx: DrizzleClient,
1054
+ sourceCollection: EntityCollection,
1055
+ sourceEntityId: string | number,
1056
+ targetCollection: EntityCollection,
1057
+ relation: Relation,
1058
+ newValue: unknown,
1059
+ junctionInfo: { table: string; sourceColumn: string; targetColumn: string }
1060
+ ) {
1061
+ try {
1062
+ const junctionTable = this.registry.getTable(junctionInfo.table);
1063
+ if (!junctionTable) {
1064
+ console.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
1065
+ return;
1066
+ }
1067
+
1068
+ const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1069
+ const targetJunctionColumn = junctionTable[junctionInfo.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1070
+
1071
+ if (!sourceJunctionColumn || !targetJunctionColumn) {
1072
+ console.warn(`Junction columns not found for relation '${relation.relationName}'`);
1073
+ return;
1074
+ }
1075
+
1076
+ const sourcePks = getPrimaryKeys(sourceCollection, this.registry);
1077
+ const sourceIdInfo = sourcePks[0];
1078
+ const parsedSourceIdObj = parseIdValues(sourceEntityId, sourcePks);
1079
+ const parsedSourceId = parsedSourceIdObj[sourceIdInfo.fieldName];
1080
+
1081
+ // Clear existing entries for this source entity
1082
+ await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
1083
+
1084
+ // Add new entries if newValue is provided
1085
+ if (newValue && Array.isArray(newValue) && newValue.length > 0) {
1086
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1087
+ const targetIdInfo = targetPks[0];
1088
+ const targetEntityIds = (newValue as Array<{ id: string | number }>).map((rel) => rel.id);
1089
+ const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
1090
+
1091
+ const newLinks = parsedTargetIds.map(targetId => ({
1092
+ [sourceJunctionColumn.name]: parsedSourceId,
1093
+ [targetJunctionColumn.name]: targetId
1094
+ }));
1095
+
1096
+ if (newLinks.length > 0) {
1097
+ await tx.insert(junctionTable).values(newLinks);
1098
+ }
1099
+ }
1100
+ } catch (error) {
1101
+ console.error(`Failed to update many-to-many inverse relation '${relation.relationName}':`, error);
1102
+ throw error;
1103
+ }
1104
+ }
1105
+
1106
+ /**
1107
+ * Update one-to-one relations that use joinPath
1108
+ */
1109
+ async updateJoinPathOneToOneRelations(
1110
+ tx: DrizzleClient,
1111
+ parentCollection: EntityCollection,
1112
+ parentEntityId: string | number,
1113
+ updates: Array<{
1114
+ relationKey: string;
1115
+ relation: Relation;
1116
+ newTargetId: string | number | null;
1117
+ }>
1118
+ ) {
1119
+ for (const upd of updates) {
1120
+ const { relation, newTargetId } = upd;
1121
+ const targetCollection = relation.target();
1122
+ const targetTable = getTableForCollection(targetCollection, this.registry);
1123
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1124
+ const targetIdInfo = targetPks[0];
1125
+ const targetIdCol = targetTable[targetIdInfo.fieldName as keyof typeof targetTable] as AnyPgColumn;
1126
+
1127
+ // Determine mapping of columns
1128
+ const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
1129
+ const parentTable = getTableForCollection(parentCollection, this.registry);
1130
+ const parentPks = getPrimaryKeys(parentCollection, this.registry);
1131
+ const parentIdInfo = parentPks[0];
1132
+ const parsedParentIdObj = parseIdValues(parentEntityId, parentPks);
1133
+ const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
1134
+
1135
+ const parentIdCol = parentTable[parentIdInfo.fieldName as keyof typeof parentTable] as AnyPgColumn;
1136
+ const parentSourceCol = parentTable[parentSourceColName as keyof typeof parentTable] as AnyPgColumn;
1137
+ const targetFKCol = targetTable[targetFKColName as keyof typeof targetTable] as AnyPgColumn;
1138
+
1139
+ if (!parentSourceCol) {
1140
+ console.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
1141
+ continue;
1142
+ }
1143
+ if (!targetFKCol) {
1144
+ console.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
1145
+ continue;
1146
+ }
1147
+
1148
+ // Fetch the parent row to obtain the value for parentSourceCol
1149
+ const parentRows = await tx
1150
+ .select({ val: parentSourceCol })
1151
+ .from(parentTable)
1152
+ .where(eq(parentIdCol, parsedParentId))
1153
+ .limit(1);
1154
+ if (parentRows.length === 0) continue;
1155
+ const parentFKValue = parentRows[0].val as string | number | null;
1156
+
1157
+ if (newTargetId === null || newTargetId === undefined) {
1158
+ // Clear any target rows currently linked to this parent via the FK
1159
+ if (parentFKValue !== null && parentFKValue !== undefined) {
1160
+ await tx.update(targetTable)
1161
+ .set({ [targetFKColName]: null })
1162
+ .where(eq(targetFKCol, parentFKValue as unknown as string));
1163
+ }
1164
+ continue;
1165
+ }
1166
+
1167
+ // Parse the new target id
1168
+ const parsedTargetIdObj = parseIdValues(newTargetId, targetPks);
1169
+ const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
1170
+
1171
+ // Ensure one-to-one by clearing existing link from any target rows with this parent FK
1172
+ if (parentFKValue !== null && parentFKValue !== undefined) {
1173
+ await tx.update(targetTable)
1174
+ .set({ [targetFKColName]: null })
1175
+ .where(eq(targetFKCol, parentFKValue as unknown as string));
1176
+ } else {
1177
+ console.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
1178
+ continue;
1179
+ }
1180
+
1181
+ // Now set the FK on the target entity
1182
+ await tx.update(targetTable)
1183
+ .set({ [targetFKColName]: parentFKValue })
1184
+ .where(eq(targetIdCol, parsedTargetId));
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Resolve joinPath write mapping for one-to-one relations
1190
+ */
1191
+ resolveJoinPathWriteMapping(
1192
+ parentCollection: EntityCollection,
1193
+ relation: Relation
1194
+ ): { targetFKColName: string; parentSourceColName: string } {
1195
+ if (!relation.joinPath || relation.joinPath.length === 0) {
1196
+ throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
1197
+ }
1198
+ const parentTableName = getTableName(parentCollection);
1199
+ const lastStep = relation.joinPath[relation.joinPath.length - 1];
1200
+ const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
1201
+ let currentFrom = lastStep.on.from;
1202
+
1203
+ let safety = 0;
1204
+ while (safety++ < 10) {
1205
+ const currentFromTable = DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0];
1206
+ if (currentFromTable === parentTableName) {
1207
+ break;
1208
+ }
1209
+ const prevStep = relation.joinPath.find((s) => {
1210
+ const to = Array.isArray(s.on.to) ? s.on.to[0] : s.on.to;
1211
+ return to === currentFrom;
1212
+ });
1213
+ if (!prevStep) {
1214
+ throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
1215
+ }
1216
+ currentFrom = prevStep.on.from;
1217
+ }
1218
+ const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
1219
+ return { targetFKColName,
1220
+ parentSourceColName };
1221
+ }
1222
+
1223
+ /**
1224
+ * Handle junction table creation for many-to-many path-based saves
1225
+ */
1226
+ async handleJunctionTableCreation(
1227
+ tx: DrizzleClient,
1228
+ newEntityId: string | number,
1229
+ junctionTableInfo: {
1230
+ parentCollection: EntityCollection;
1231
+ parentId: string | number;
1232
+ relation: Relation;
1233
+ relationKey: string;
1234
+ }
1235
+ ) {
1236
+ const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
1237
+ const targetCollection = relation.target();
1238
+
1239
+ try {
1240
+ const junctionTable = this.registry.getTable(relation.through!.table);
1241
+ if (!junctionTable) {
1242
+ console.warn(`Junction table '${relation.through!.table}' not found for relation '${relationKey}'`);
1243
+ return;
1244
+ }
1245
+
1246
+ const sourceJunctionColumn = junctionTable[relation.through!.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1247
+ const targetJunctionColumn = junctionTable[relation.through!.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1248
+
1249
+ if (!sourceJunctionColumn || !targetJunctionColumn) {
1250
+ console.warn(`Junction columns not found for relation '${relationKey}'`);
1251
+ return;
1252
+ }
1253
+
1254
+ // Parse the new entity ID to the correct type
1255
+ const targetPks = getPrimaryKeys(targetCollection, this.registry);
1256
+ const targetIdInfo = targetPks[0];
1257
+ const parsedNewEntityIdObj = parseIdValues(newEntityId, targetPks);
1258
+ const parsedNewEntityId = parsedNewEntityIdObj[targetIdInfo.fieldName];
1259
+
1260
+ // Create the junction table entry linking parent to the new entity
1261
+ const junctionData = {
1262
+ [sourceJunctionColumn.name]: parentId,
1263
+ [targetJunctionColumn.name]: parsedNewEntityId
1264
+ };
1265
+
1266
+ await tx.insert(junctionTable).values(junctionData);
1267
+
1268
+ console.log(`Created junction table entry for many-to-many relation '${relationKey}': ${JSON.stringify(junctionData)}`);
1269
+ } catch (error) {
1270
+ console.error(`Failed to create junction table entry for relation '${relationKey}':`, error);
1271
+ throw error;
1272
+ }
1273
+ }
1274
+ }