@rebasepro/server-postgres 0.0.1-canary.4829d6e

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