@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e

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 (136) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index.es.js +1254 -591
  6. package/dist/index.es.js.map +1 -1
  7. package/dist/index.umd.js +1254 -591
  8. package/dist/index.umd.js.map +1 -1
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +17 -29
  10. package/dist/server-postgresql/src/auth/services.d.ts +7 -3
  11. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +1 -1
  12. package/dist/server-postgresql/src/connection.d.ts +34 -1
  13. package/dist/server-postgresql/src/data-transformer.d.ts +26 -4
  14. package/dist/server-postgresql/src/databasePoolManager.d.ts +2 -2
  15. package/dist/server-postgresql/src/schema/auth-schema.d.ts +139 -38
  16. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  17. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  18. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +1 -1
  19. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  20. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +22 -8
  21. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +1 -1
  22. package/dist/server-postgresql/src/services/RelationService.d.ts +11 -5
  23. package/dist/server-postgresql/src/services/entity-helpers.d.ts +16 -2
  24. package/dist/server-postgresql/src/services/entityService.d.ts +8 -6
  25. package/dist/server-postgresql/src/services/realtimeService.d.ts +2 -0
  26. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +2 -2
  27. package/dist/types/src/controllers/auth.d.ts +2 -0
  28. package/dist/types/src/controllers/client.d.ts +119 -7
  29. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  30. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  31. package/dist/types/src/controllers/data.d.ts +34 -7
  32. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  33. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  34. package/dist/types/src/controllers/email.d.ts +34 -0
  35. package/dist/types/src/controllers/index.d.ts +1 -0
  36. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  37. package/dist/types/src/controllers/navigation.d.ts +5 -5
  38. package/dist/types/src/controllers/registry.d.ts +6 -3
  39. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  40. package/dist/types/src/controllers/storage.d.ts +24 -26
  41. package/dist/types/src/rebase_context.d.ts +8 -4
  42. package/dist/types/src/types/backend.d.ts +4 -1
  43. package/dist/types/src/types/builders.d.ts +5 -4
  44. package/dist/types/src/types/chips.d.ts +1 -1
  45. package/dist/types/src/types/collections.d.ts +169 -125
  46. package/dist/types/src/types/cron.d.ts +102 -0
  47. package/dist/types/src/types/data_source.d.ts +1 -1
  48. package/dist/types/src/types/entity_actions.d.ts +8 -8
  49. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  50. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  51. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  52. package/dist/types/src/types/entity_views.d.ts +8 -8
  53. package/dist/types/src/types/export_import.d.ts +3 -3
  54. package/dist/types/src/types/index.d.ts +1 -0
  55. package/dist/types/src/types/plugins.d.ts +72 -18
  56. package/dist/types/src/types/properties.d.ts +118 -33
  57. package/dist/types/src/types/relations.d.ts +1 -1
  58. package/dist/types/src/types/slots.d.ts +30 -6
  59. package/dist/types/src/types/translations.d.ts +44 -0
  60. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  61. package/drizzle-test/0000_woozy_junta.sql +6 -0
  62. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  63. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  64. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  65. package/drizzle-test/meta/0000_snapshot.json +47 -0
  66. package/drizzle-test/meta/0001_snapshot.json +48 -0
  67. package/drizzle-test/meta/0002_snapshot.json +38 -0
  68. package/drizzle-test/meta/0003_snapshot.json +48 -0
  69. package/drizzle-test/meta/_journal.json +34 -0
  70. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  71. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  72. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  73. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  74. package/drizzle-test-out/meta/_journal.json +20 -0
  75. package/drizzle.test.config.ts +10 -0
  76. package/package.json +88 -89
  77. package/scratch.ts +41 -0
  78. package/src/PostgresBackendDriver.ts +63 -79
  79. package/src/PostgresBootstrapper.ts +7 -8
  80. package/src/auth/ensure-tables.ts +158 -86
  81. package/src/auth/services.ts +109 -50
  82. package/src/cli.ts +259 -16
  83. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  84. package/src/connection.ts +70 -48
  85. package/src/data-transformer.ts +155 -116
  86. package/src/databasePoolManager.ts +6 -5
  87. package/src/history/HistoryService.ts +3 -12
  88. package/src/interfaces.ts +3 -3
  89. package/src/schema/auth-schema.ts +26 -3
  90. package/src/schema/doctor-cli.ts +47 -0
  91. package/src/schema/doctor.ts +595 -0
  92. package/src/schema/generate-drizzle-schema-logic.ts +204 -57
  93. package/src/schema/generate-drizzle-schema.ts +6 -6
  94. package/src/schema/test-schema.ts +11 -0
  95. package/src/services/BranchService.ts +5 -5
  96. package/src/services/EntityFetchService.ts +317 -188
  97. package/src/services/EntityPersistService.ts +15 -17
  98. package/src/services/RelationService.ts +299 -37
  99. package/src/services/entity-helpers.ts +39 -13
  100. package/src/services/entityService.ts +11 -9
  101. package/src/services/realtimeService.ts +58 -29
  102. package/src/utils/drizzle-conditions.ts +25 -24
  103. package/src/websocket.ts +52 -21
  104. package/test/auth-services.test.ts +131 -39
  105. package/test/batch-many-to-many-regression.test.ts +573 -0
  106. package/test/branchService.test.ts +22 -12
  107. package/test/data-transformer-hardening.test.ts +417 -0
  108. package/test/data-transformer.test.ts +175 -0
  109. package/test/doctor.test.ts +182 -0
  110. package/test/entityService.errors.test.ts +31 -16
  111. package/test/entityService.relations.test.ts +155 -59
  112. package/test/entityService.subcollection-search.test.ts +107 -57
  113. package/test/entityService.test.ts +105 -47
  114. package/test/generate-drizzle-schema.test.ts +262 -69
  115. package/test/historyService.test.ts +31 -16
  116. package/test/n-plus-one-regression.test.ts +314 -0
  117. package/test/postgresDataDriver.test.ts +260 -168
  118. package/test/realtimeService.test.ts +70 -39
  119. package/test/relation-pipeline-gaps.test.ts +637 -0
  120. package/test/relations.test.ts +492 -39
  121. package/test-drizzle-bug.ts +18 -0
  122. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  123. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  124. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  125. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  126. package/test-drizzle-out/meta/_journal.json +20 -0
  127. package/test-drizzle-prompt.sh +2 -0
  128. package/test-policy-prompt.sh +3 -0
  129. package/test-programmatic.ts +30 -0
  130. package/test-programmatic2.ts +59 -0
  131. package/test-schema-no-policies.ts +12 -0
  132. package/test_drizzle_mock.js +2 -2
  133. package/test_find_changed.mjs +3 -1
  134. package/test_hash.js +14 -0
  135. package/tsconfig.json +1 -1
  136. package/vite.config.ts +5 -5
@@ -2,7 +2,7 @@ import { eq, and } from "drizzle-orm";
2
2
  import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { Entity, EntityCollection, Properties, Relation } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
5
+ import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
@@ -56,7 +56,7 @@ export class EntityPersistService {
56
56
  /**
57
57
  * Save an entity (create or update)
58
58
  */
59
- async saveEntity<M extends Record<string, any>>(
59
+ async saveEntity<M extends Record<string, unknown>>(
60
60
  collectionPath: string,
61
61
  values: Partial<M>,
62
62
  entityId?: string | number,
@@ -65,6 +65,7 @@ export class EntityPersistService {
65
65
  // If saving under a nested relation path, resolve the parent and inject FK
66
66
  let effectiveCollectionPath = collectionPath;
67
67
  const effectiveValues: Partial<M> = { ...values };
68
+ let junctionTableInfo: { parentCollection: EntityCollection; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
68
69
 
69
70
  if (collectionPath.includes("/")) {
70
71
  const segments = collectionPath.split("/").filter(Boolean);
@@ -75,11 +76,12 @@ export class EntityPersistService {
75
76
 
76
77
  for (let i = 2; i < segments.length; i += 2) {
77
78
  const relationKey = segments[i];
78
- const resolvedRelations = resolveCollectionRelations(currentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
79
- const relation = resolvedRelations[relationKey];
79
+ const resolvedRelations = resolveCollectionRelations(currentCollection);
80
+ const relation = findRelation(resolvedRelations, relationKey);
80
81
 
81
82
  if (!relation) {
82
- throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
83
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
84
+ throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'. Available relations: [${available}]`);
83
85
  }
84
86
 
85
87
  if (i === segments.length - 1) {
@@ -93,7 +95,7 @@ export class EntityPersistService {
93
95
  const parsedParentIdObj = parseIdValues(currentEntityId, parentIdInfoArray);
94
96
  const parsedParentId = parsedParentIdObj[parentIdInfo.fieldName];
95
97
 
96
- (effectiveValues as Record<string, unknown>).__junction_table_info = {
98
+ junctionTableInfo = {
97
99
  parentCollection: currentCollection,
98
100
  parentId: parsedParentId,
99
101
  relation: relation,
@@ -161,7 +163,7 @@ export class EntityPersistService {
161
163
  // Separate relations that require special handling
162
164
  const relationValues: Record<string, unknown> = {};
163
165
  const otherValues: Partial<M> = { ...effectiveValues };
164
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
166
+ const resolvedRelations = resolveCollectionRelations(collection);
165
167
 
166
168
  for (const key in resolvedRelations) {
167
169
  const relation = resolvedRelations[key];
@@ -174,17 +176,13 @@ export class EntityPersistService {
174
176
  }
175
177
 
176
178
  // Transform relations to IDs, then sanitize
177
- const processedData = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
179
+ const serializedResult = serializeDataToServer(otherValues as M, collection.properties as Properties, collection, this.registry);
178
180
 
179
- // Extract relation updates before sanitizing
180
- const inverseRelationUpdates = ((processedData as Record<string, unknown>).__inverseRelationUpdates as Array<{ relationKey: string; relation: Relation; newValue: unknown; currentEntityId?: string | number; }>) || [];
181
- const joinPathRelationUpdates = ((processedData as Record<string, unknown>).__joinPathRelationUpdates as Array<{ relationKey: string; relation: Relation; newTargetId: string | number | null; }>) || [];
182
- const junctionTableInfo = (processedData as Record<string, unknown>).__junction_table_info as { parentCollection: EntityCollection<any, any>; parentId: string | number; relation: Relation; relationKey: string; } | undefined;
183
- delete (processedData as Record<string, unknown>).__inverseRelationUpdates;
184
- delete (processedData as Record<string, unknown>).__joinPathRelationUpdates;
185
- delete (processedData as Record<string, unknown>).__junction_table_info;
181
+ // Extract relation updates from the typed result
182
+ const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
183
+ const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
186
184
 
187
- const entityData = sanitizeAndConvertDates(processedData);
185
+ const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
188
186
 
189
187
  let savedId: string | number;
190
188
  try {
@@ -211,7 +209,7 @@ export class EntityPersistService {
211
209
  // empty after relation stripping and Drizzle throws "No values to set".
212
210
  const scalarKeys = Object.keys(entityData as Record<string, unknown>);
213
211
  if (scalarKeys.length > 0) {
214
- let updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
212
+ const updateQuery = tx.update(table).set(entityData as Record<string, unknown>);
215
213
  const conditions = [];
216
214
  for (const info of idInfoArray) {
217
215
  const field = table[info.fieldName as keyof typeof table] as AnyPgColumn;
@@ -2,7 +2,7 @@ import { and, eq, inArray, sql, SQL } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
4
  import { Entity, EntityCollection, FilterValues, Relation } from "@rebasepro/types";
5
- import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
5
+ import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
6
6
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
7
7
  import {
8
8
  getCollectionByPath,
@@ -24,7 +24,7 @@ export class RelationService {
24
24
  /**
25
25
  * Fetch entities related to a parent entity through a specific relation
26
26
  */
27
- async fetchRelatedEntities<M extends Record<string, any>>(
27
+ async fetchRelatedEntities<M extends Record<string, unknown>>(
28
28
  parentCollectionPath: string,
29
29
  parentEntityId: string | number,
30
30
  relationKey: string,
@@ -39,11 +39,12 @@ export class RelationService {
39
39
  } = {}
40
40
  ): Promise<Entity<M>[]> {
41
41
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
42
- const resolvedRelations = resolveCollectionRelations(parentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
43
- const relation = resolvedRelations[relationKey];
42
+ const resolvedRelations = resolveCollectionRelations(parentCollection);
43
+ const relation = findRelation(resolvedRelations, relationKey);
44
44
 
45
45
  if (!relation) {
46
- throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'`);
46
+ const available = Object.keys(resolvedRelations).join(", ") || "(none)";
47
+ throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'. Available relations: [${available}]`);
47
48
  }
48
49
 
49
50
  return this.fetchEntitiesUsingJoins<M>(parentCollection, parentEntityId, relation, options);
@@ -52,7 +53,7 @@ export class RelationService {
52
53
  /**
53
54
  * Fetch entities using join paths for complex relations
54
55
  */
55
- async fetchEntitiesUsingJoins<M extends Record<string, any>>(
56
+ async fetchEntitiesUsingJoins<M extends Record<string, unknown>>(
56
57
  parentCollection: EntityCollection,
57
58
  parentEntityId: string | number,
58
59
  relation: Relation,
@@ -209,16 +210,19 @@ export class RelationService {
209
210
  /**
210
211
  * Count related entities for a parent entity
211
212
  */
212
- async countRelatedEntities<M extends Record<string, any>>(
213
+ async countRelatedEntities<M extends Record<string, unknown>>(
213
214
  parentCollectionPath: string,
214
215
  parentEntityId: string | number,
215
216
  relationKey: string,
216
217
  options: { filter?: FilterValues<Extract<keyof M, string>>; databaseId?: string } = {}
217
218
  ): Promise<number> {
218
219
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
219
- const resolvedRelations = resolveCollectionRelations(parentCollection as import("@rebasepro/types").PostgresCollection<any, any>);
220
- const relation = resolvedRelations[relationKey];
221
- if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${parentCollectionPath}'`);
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
+ }
222
226
 
223
227
  const targetCollection = relation.target();
224
228
  const targetTable = getTableForCollection(targetCollection, this.registry);
@@ -265,7 +269,7 @@ export class RelationService {
265
269
  parentEntityIds: (string | number)[],
266
270
  _relationKey: string,
267
271
  relation: Relation
268
- ): Promise<Map<string | number, Entity<Record<string, unknown>>>> {
272
+ ): Promise<Map<string, Entity<Record<string, unknown>>>> {
269
273
  if (parentEntityIds.length === 0) return new Map();
270
274
 
271
275
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
@@ -323,25 +327,95 @@ export class RelationService {
323
327
 
324
328
  const results = await query;
325
329
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
326
- const resultMap = new Map<string | number, Entity<Record<string, unknown>>>();
330
+ const resultMap = new Map<string, Entity<Record<string, unknown>>>();
327
331
 
328
332
  // Group results by parent ID
329
- results.forEach((row: Record<string, unknown>) => {
333
+ for (const row of results as Array<Record<string, unknown>>) {
330
334
  const parentEntity = (row[getTableName(parentCollection)] || row) as Record<string, unknown>;
331
335
  const targetEntity = (row[targetTableName] || row) as Record<string, unknown>;
332
336
  const parentId = parentEntity[parentIdInfo.fieldName] as string | number;
333
337
 
334
- resultMap.set(parentId, {
338
+ const parsedValues = await parseDataFromServer(targetEntity, targetCollection);
339
+
340
+ resultMap.set(String(parentId), {
335
341
  id: String(targetEntity[targetIdInfo.fieldName]),
336
342
  path: targetCollection.slug,
337
- values: targetEntity
343
+ values: parsedValues as Record<string, unknown>
338
344
  });
339
- });
345
+ }
340
346
 
341
347
  return resultMap;
342
348
  }
343
349
 
344
- // Handle other relation types with batching
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
345
419
  let query = this.db.select().from(targetTable).$dynamic();
346
420
 
347
421
  // Build the relation query with ALL parent IDs
@@ -359,10 +433,15 @@ export class RelationService {
359
433
  );
360
434
 
361
435
  const results = await query;
362
- const resultMap = new Map<string | number, Entity<Record<string, unknown>>>();
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));
363
442
 
364
443
  // Map results back to parent entities
365
- results.forEach((row: Record<string, unknown>) => {
444
+ for (const row of results as Array<Record<string, unknown>>) {
366
445
  const targetEntity = (row[getTableName(targetCollection)] || row) as Record<string, unknown>;
367
446
 
368
447
  // Determine the parent ID this result belongs to based on the relation type
@@ -373,23 +452,201 @@ export class RelationService {
373
452
  } else if (relation.direction === "inverse" && relation.cardinality === "one" && relation.inverseRelationName) {
374
453
  const inferredForeignKeyName = `${relation.inverseRelationName}_id`;
375
454
  parentId = targetEntity[inferredForeignKeyName] as string | number | undefined;
376
- } else if (relation.direction === "owning" && relation.localKey) {
377
- for (const parsedParentId of parsedParentIds) {
378
- if (!resultMap.has(parsedParentId)) {
379
- parentId = parsedParentId;
380
- break;
381
- }
382
- }
383
455
  }
384
456
 
385
- if (parentId !== undefined && parsedParentIds.includes(parentId)) {
386
- resultMap.set(parentId, {
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({
387
643
  id: String(targetEntity[targetIdInfo.fieldName]),
388
644
  path: targetCollection.slug,
389
- values: targetEntity
645
+ values: parsedValues as Record<string, unknown>
390
646
  });
647
+ resultMap.set(key, arr);
391
648
  }
392
- });
649
+ }
393
650
 
394
651
  return resultMap;
395
652
  }
@@ -397,16 +654,16 @@ export class RelationService {
397
654
  /**
398
655
  * Update many-to-many and junction relations
399
656
  */
400
- async updateRelationsUsingJoins<M extends Record<string, any>>(
657
+ async updateRelationsUsingJoins<M extends Record<string, unknown>>(
401
658
  tx: DrizzleClient,
402
659
  collection: EntityCollection,
403
660
  entityId: string | number,
404
661
  relationValues: Partial<M>
405
662
  ) {
406
- const resolvedRelations = resolveCollectionRelations(collection as import("@rebasepro/types").PostgresCollection<any, any>);
663
+ const resolvedRelations = resolveCollectionRelations(collection);
407
664
 
408
665
  for (const [key, value] of Object.entries(relationValues)) {
409
- const relation = resolvedRelations[key];
666
+ const relation = findRelation(resolvedRelations, key);
410
667
  if (!relation || relation.cardinality !== "many") continue;
411
668
 
412
669
  const targetEntityIds = (value && Array.isArray(value)) ? value.map((rel: { id: string | number }) => rel.id) : [];
@@ -517,6 +774,10 @@ export class RelationService {
517
774
  await tx.insert(junctionTable).values(newLinks);
518
775
  }
519
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.`);
520
781
  } else if (relation.cardinality === "many" && relation.direction === "inverse" && relation.foreignKeyOnTarget) {
521
782
  // Handle one-to-many (inverse) by updating target FK to point to parent
522
783
  const targetTable = getTableForCollection(targetCollection, this.registry);
@@ -601,7 +862,7 @@ export class RelationService {
601
862
 
602
863
  // Check if this is a many-to-many inverse relation
603
864
  if (relation.cardinality === "many" && relation.direction === "inverse") {
604
- const targetCollectionRelations = resolveCollectionRelations(targetCollection as import("@rebasepro/types").PostgresCollection<any, any>);
865
+ const targetCollectionRelations = resolveCollectionRelations(targetCollection);
605
866
  let junctionInfo: { table: string; sourceColumn: string; targetColumn: string } | null = null;
606
867
 
607
868
  for (const [relationKey, targetRelation] of Object.entries(targetCollectionRelations)) {
@@ -752,7 +1013,7 @@ export class RelationService {
752
1013
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
753
1014
  const targetPks = getPrimaryKeys(targetCollection, this.registry);
754
1015
  const targetIdInfo = targetPks[0];
755
- const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === 'object' && rel !== null ? rel.id : rel);
1016
+ const targetEntityIds = (newValue as Array<{ id: string | number } | string | number>).map((rel) => typeof rel === "object" && rel !== null ? rel.id : rel);
756
1017
  const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
757
1018
 
758
1019
  const newLinks = parsedTargetIds.map(targetId => ({
@@ -767,7 +1028,7 @@ export class RelationService {
767
1028
  // Single value for one-to-one
768
1029
  const targetPks = getPrimaryKeys(targetCollection, this.registry);
769
1030
  const targetIdInfo = targetPks[0];
770
- const targetId = typeof newValue === 'object' && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
1031
+ const targetId = typeof newValue === "object" && newValue !== null ? (newValue as Record<string, unknown>).id as string | number : newValue as string | number;
771
1032
  const parsedTargetIdObj = parseIdValues(targetId, targetPks);
772
1033
  const parsedTargetId = parsedTargetIdObj[targetIdInfo.fieldName];
773
1034
 
@@ -955,7 +1216,8 @@ export class RelationService {
955
1216
  currentFrom = prevStep.on.from;
956
1217
  }
957
1218
  const parentSourceColName = DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0];
958
- return { targetFKColName, parentSourceColName };
1219
+ return { targetFKColName,
1220
+ parentSourceColName };
959
1221
  }
960
1222
 
961
1223
  /**