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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +58 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +22 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +11298 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11306 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +192 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +45 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +160 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/builders.d.ts +15 -0
  83. package/dist/types/src/types/chips.d.ts +5 -0
  84. package/dist/types/src/types/collections.d.ts +856 -0
  85. package/dist/types/src/types/cron.d.ts +102 -0
  86. package/dist/types/src/types/data_source.d.ts +64 -0
  87. package/dist/types/src/types/entities.d.ts +145 -0
  88. package/dist/types/src/types/entity_actions.d.ts +98 -0
  89. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  90. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  91. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  92. package/dist/types/src/types/entity_views.d.ts +61 -0
  93. package/dist/types/src/types/export_import.d.ts +21 -0
  94. package/dist/types/src/types/index.d.ts +23 -0
  95. package/dist/types/src/types/locales.d.ts +4 -0
  96. package/dist/types/src/types/modify_collections.d.ts +5 -0
  97. package/dist/types/src/types/plugins.d.ts +279 -0
  98. package/dist/types/src/types/properties.d.ts +1176 -0
  99. package/dist/types/src/types/property_config.d.ts +70 -0
  100. package/dist/types/src/types/relations.d.ts +336 -0
  101. package/dist/types/src/types/slots.d.ts +252 -0
  102. package/dist/types/src/types/translations.d.ts +870 -0
  103. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  104. package/dist/types/src/types/websockets.d.ts +78 -0
  105. package/dist/types/src/users/index.d.ts +2 -0
  106. package/dist/types/src/users/roles.d.ts +22 -0
  107. package/dist/types/src/users/user.d.ts +46 -0
  108. package/drizzle-test/0000_woozy_junta.sql +6 -0
  109. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  110. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  111. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  112. package/drizzle-test/meta/0000_snapshot.json +47 -0
  113. package/drizzle-test/meta/0001_snapshot.json +48 -0
  114. package/drizzle-test/meta/0002_snapshot.json +38 -0
  115. package/drizzle-test/meta/0003_snapshot.json +48 -0
  116. package/drizzle-test/meta/_journal.json +34 -0
  117. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  118. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  119. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  120. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  121. package/drizzle-test-out/meta/_journal.json +20 -0
  122. package/drizzle.test.config.ts +10 -0
  123. package/jest-all.log +3128 -0
  124. package/jest.log +49 -0
  125. package/package.json +92 -0
  126. package/scratch.ts +41 -0
  127. package/src/PostgresBackendDriver.ts +1008 -0
  128. package/src/PostgresBootstrapper.ts +231 -0
  129. package/src/auth/ensure-tables.ts +381 -0
  130. package/src/auth/services.ts +799 -0
  131. package/src/cli.ts +648 -0
  132. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  133. package/src/connection.ts +84 -0
  134. package/src/data-transformer.ts +608 -0
  135. package/src/databasePoolManager.ts +85 -0
  136. package/src/history/HistoryService.ts +248 -0
  137. package/src/history/ensure-history-table.ts +45 -0
  138. package/src/index.ts +13 -0
  139. package/src/interfaces.ts +60 -0
  140. package/src/schema/auth-schema.ts +169 -0
  141. package/src/schema/doctor-cli.ts +47 -0
  142. package/src/schema/doctor.ts +595 -0
  143. package/src/schema/generate-drizzle-schema-logic.ts +765 -0
  144. package/src/schema/generate-drizzle-schema.ts +151 -0
  145. package/src/schema/introspect-db-logic.ts +542 -0
  146. package/src/schema/introspect-db.ts +211 -0
  147. package/src/schema/test-schema.ts +11 -0
  148. package/src/services/BranchService.ts +237 -0
  149. package/src/services/EntityFetchService.ts +1576 -0
  150. package/src/services/EntityPersistService.ts +349 -0
  151. package/src/services/RelationService.ts +1274 -0
  152. package/src/services/entity-helpers.ts +147 -0
  153. package/src/services/entityService.ts +211 -0
  154. package/src/services/index.ts +13 -0
  155. package/src/services/realtimeService.ts +1034 -0
  156. package/src/utils/drizzle-conditions.ts +1000 -0
  157. package/src/websocket.ts +518 -0
  158. package/test/auth-services.test.ts +661 -0
  159. package/test/batch-many-to-many-regression.test.ts +573 -0
  160. package/test/branchService.test.ts +367 -0
  161. package/test/data-transformer-hardening.test.ts +417 -0
  162. package/test/data-transformer.test.ts +175 -0
  163. package/test/doctor.test.ts +182 -0
  164. package/test/drizzle-conditions.test.ts +895 -0
  165. package/test/entityService.errors.test.ts +367 -0
  166. package/test/entityService.relations.test.ts +1008 -0
  167. package/test/entityService.subcollection-search.test.ts +566 -0
  168. package/test/entityService.test.ts +1035 -0
  169. package/test/generate-drizzle-schema.test.ts +988 -0
  170. package/test/historyService.test.ts +141 -0
  171. package/test/introspect-db-generation.test.ts +436 -0
  172. package/test/introspect-db-utils.test.ts +389 -0
  173. package/test/n-plus-one-regression.test.ts +314 -0
  174. package/test/postgresDataDriver.test.ts +648 -0
  175. package/test/realtimeService.test.ts +307 -0
  176. package/test/relation-pipeline-gaps.test.ts +637 -0
  177. package/test/relations.test.ts +1115 -0
  178. package/test/unmapped-tables-safety.test.ts +345 -0
  179. package/test-drizzle-bug.ts +18 -0
  180. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  181. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  182. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  183. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  184. package/test-drizzle-out/meta/_journal.json +20 -0
  185. package/test-drizzle-prompt.sh +2 -0
  186. package/test-policy-prompt.sh +3 -0
  187. package/test-programmatic.ts +30 -0
  188. package/test-programmatic2.ts +59 -0
  189. package/test-schema-no-policies.ts +12 -0
  190. package/test_drizzle_mock.js +3 -0
  191. package/test_find_changed.mjs +32 -0
  192. package/test_hash.js +14 -0
  193. package/test_output.txt +3145 -0
  194. package/tsconfig.json +49 -0
  195. package/tsconfig.prod.json +20 -0
  196. package/vite.config.ts +82 -0
@@ -0,0 +1,573 @@
1
+ /**
2
+ * Regression tests for batchFetchRelatedEntitiesMany
3
+ *
4
+ * Root cause of the original bug:
5
+ * `batchFetchRelatedEntitiesMany` only had two code paths:
6
+ * 1. `relation.joinPath` — custom multi-hop join path
7
+ * 2. FK-based fallback — delegated to `buildRelationQuery`, then extracted
8
+ * parentId from `foreignKeyOnTarget` / `inverseRelationName`
9
+ *
10
+ * Many-to-many owning relations (e.g. posts→tags via posts_tags junction)
11
+ * use `relation.through` — NOT `joinPath`, and NOT an inverse FK.
12
+ * The FK fallback would:
13
+ * - Correctly JOIN through the junction table (via buildRelationQuery)
14
+ * - But FAIL to extract parentId from the result rows because
15
+ * `relation.direction === "owning"`, so lines checking
16
+ * `direction === "inverse"` were skipped
17
+ * - parentId stayed `undefined`, all results were silently dropped
18
+ *
19
+ * The same gap existed for inverse M2M with `through`.
20
+ *
21
+ * Fix: Added a dedicated `through` handler before the FK fallback that
22
+ * queries junction→target directly and extracts parentId from the junction
23
+ * table's sourceColumn/targetColumn.
24
+ */
25
+ import { RelationService } from "../src/services/RelationService";
26
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
27
+ import { EntityCollection, Relation } from "@rebasepro/types";
28
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
29
+
30
+ // ─── Mock Tables ──────────────────────────────────────────────────────
31
+ const mockPostsTable = {
32
+ id: { name: "id", dataType: "number" },
33
+ title: { name: "title" },
34
+ _def: { tableName: "posts" }
35
+ };
36
+
37
+ const mockTagsTable = {
38
+ id: { name: "id", dataType: "number" },
39
+ name: { name: "name" },
40
+ _def: { tableName: "tags" }
41
+ };
42
+
43
+ const mockPostsTagsTable = {
44
+ post_id: { name: "post_id", dataType: "number" },
45
+ tag_id: { name: "tag_id", dataType: "number" },
46
+ _def: { tableName: "posts_tags" }
47
+ };
48
+
49
+ const mockAuthorsTable = {
50
+ id: { name: "id", dataType: "number" },
51
+ name: { name: "name" },
52
+ _def: { tableName: "authors" }
53
+ };
54
+
55
+ const mockAuthorPostsTable = {
56
+ author_id: { name: "author_id", dataType: "number" },
57
+ post_id: { name: "post_id", dataType: "number" },
58
+ _def: { tableName: "author_posts" }
59
+ };
60
+
61
+ // ─── Mock Collections ─────────────────────────────────────────────────
62
+
63
+ const tagsCollection: EntityCollection = {
64
+ slug: "tags",
65
+ name: "Tags",
66
+ table: "tags",
67
+ properties: {
68
+ id: { type: "number" },
69
+ name: { type: "string" }
70
+ },
71
+ idField: "id"
72
+ };
73
+
74
+ const postsCollection: EntityCollection = {
75
+ slug: "posts",
76
+ name: "Posts",
77
+ table: "posts",
78
+ properties: {
79
+ id: { type: "number" },
80
+ title: { type: "string" },
81
+ tags: { type: "relation", relationName: "tags" }
82
+ },
83
+ relations: [
84
+ {
85
+ relationName: "tags",
86
+ target: () => tagsCollection,
87
+ cardinality: "many",
88
+ direction: "owning",
89
+ through: {
90
+ table: "posts_tags",
91
+ sourceColumn: "post_id",
92
+ targetColumn: "tag_id"
93
+ }
94
+ }
95
+ ],
96
+ idField: "id"
97
+ };
98
+
99
+ const authorsCollection: EntityCollection = {
100
+ slug: "authors",
101
+ name: "Authors",
102
+ table: "authors",
103
+ properties: {
104
+ id: { type: "number" },
105
+ name: { type: "string" }
106
+ },
107
+ idField: "id"
108
+ };
109
+
110
+ // Inverse M2M: tags → posts (from tag's perspective, "which posts use this tag?")
111
+ const tagsWithInversePosts: EntityCollection = {
112
+ slug: "tags_inv",
113
+ name: "Tags (inverse)",
114
+ table: "tags",
115
+ properties: {
116
+ id: { type: "number" },
117
+ name: { type: "string" },
118
+ posts: { type: "relation", relationName: "posts" }
119
+ },
120
+ relations: [
121
+ {
122
+ relationName: "posts",
123
+ target: () => postsCollection,
124
+ cardinality: "many",
125
+ direction: "inverse",
126
+ through: {
127
+ table: "posts_tags",
128
+ sourceColumn: "post_id",
129
+ targetColumn: "tag_id"
130
+ }
131
+ }
132
+ ],
133
+ idField: "id"
134
+ };
135
+
136
+ // JoinPath-based M2M: authors → posts via author_posts
137
+ const authorsWithJoinPath: EntityCollection = {
138
+ slug: "authors_jp",
139
+ name: "Authors (joinPath)",
140
+ table: "authors",
141
+ properties: {
142
+ id: { type: "number" },
143
+ name: { type: "string" },
144
+ posts: { type: "relation", relationName: "posts" }
145
+ },
146
+ relations: [
147
+ {
148
+ relationName: "posts",
149
+ target: () => postsCollection,
150
+ cardinality: "many",
151
+ direction: "owning",
152
+ joinPath: [
153
+ { table: "author_posts", on: { from: "id", to: "author_id" } },
154
+ { table: "posts", on: { from: "post_id", to: "id" } }
155
+ ]
156
+ }
157
+ ],
158
+ idField: "id"
159
+ };
160
+
161
+ // ─── Test Data Generators ─────────────────────────────────────────────
162
+
163
+ function generateJunctionRows(
164
+ postIds: number[],
165
+ tagIds: number[],
166
+ junctionTableName: string,
167
+ targetTableName: string,
168
+ sourceCol: string,
169
+ targetCol: string,
170
+ targetData: Record<string, unknown>[]
171
+ ) {
172
+ // Create junction results that look like what Drizzle returns from
173
+ // FROM junction INNER JOIN target
174
+ const rows: Record<string, unknown>[] = [];
175
+ for (const postId of postIds) {
176
+ for (const tagId of tagIds) {
177
+ const targetRow = targetData.find(t => t.id === tagId);
178
+ if (targetRow) {
179
+ rows.push({
180
+ [junctionTableName]: {
181
+ [sourceCol]: postId,
182
+ [targetCol]: tagId,
183
+ },
184
+ [targetTableName]: { ...targetRow }
185
+ });
186
+ }
187
+ }
188
+ }
189
+ return rows;
190
+ }
191
+
192
+ // ─── Tests ────────────────────────────────────────────────────────────
193
+
194
+ describe("batchFetchRelatedEntitiesMany: M2M through junction table regression", () => {
195
+ let registry: PostgresCollectionRegistry;
196
+
197
+ function createMockDb(resolveResults: () => unknown[]) {
198
+ let queryRecorder = {
199
+ selectCount: 0,
200
+ innerJoinCount: 0,
201
+ fromTable: undefined as string | undefined,
202
+ };
203
+
204
+ function makeChainable(): Record<string, unknown> {
205
+ const chain: Record<string, unknown> = {
206
+ select: jest.fn(() => {
207
+ queryRecorder.selectCount++;
208
+ return chain;
209
+ }),
210
+ from: jest.fn((table: Record<string, unknown>) => {
211
+ const tableDef = table._def as { tableName: string } | undefined;
212
+ queryRecorder.fromTable = tableDef?.tableName ?? "unknown";
213
+ return chain;
214
+ }),
215
+ where: jest.fn(() => chain),
216
+ $dynamic: jest.fn(() => chain),
217
+ limit: jest.fn(() => chain),
218
+ offset: jest.fn(() => chain),
219
+ orderBy: jest.fn(() => chain),
220
+ innerJoin: jest.fn(() => {
221
+ queryRecorder.innerJoinCount++;
222
+ return chain;
223
+ }),
224
+ then: (resolve: (val: unknown[]) => void) => {
225
+ resolve(resolveResults());
226
+ }
227
+ };
228
+ return chain;
229
+ }
230
+
231
+ return {
232
+ db: makeChainable() as unknown as jest.Mocked<NodePgDatabase>,
233
+ recorder: queryRecorder,
234
+ };
235
+ }
236
+
237
+ beforeEach(() => {
238
+ registry = new PostgresCollectionRegistry();
239
+
240
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
241
+ if (path?.startsWith("posts")) return postsCollection;
242
+ if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
243
+ if (path?.startsWith("tags")) return tagsCollection;
244
+ if (path?.startsWith("authors_jp")) return authorsWithJoinPath;
245
+ if (path?.startsWith("authors")) return authorsCollection;
246
+ return undefined;
247
+ });
248
+
249
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
250
+ if (tableName === "posts") return mockPostsTable as any;
251
+ if (tableName === "tags") return mockTagsTable as any;
252
+ if (tableName === "posts_tags") return mockPostsTagsTable as any;
253
+ if (tableName === "authors") return mockAuthorsTable as any;
254
+ if (tableName === "author_posts") return mockAuthorPostsTable as any;
255
+ return undefined;
256
+ });
257
+
258
+ jest.spyOn(registry, "getCollections").mockReturnValue([
259
+ postsCollection, tagsCollection, authorsCollection
260
+ ]);
261
+ });
262
+
263
+ afterEach(() => {
264
+ jest.restoreAllMocks();
265
+ });
266
+
267
+ // ━━━ Owning M2M with `through` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
268
+
269
+ describe("Owning M2M with `through` (posts → tags)", () => {
270
+ const mockTags = [
271
+ { id: 1, name: "TypeScript" },
272
+ { id: 2, name: "React" },
273
+ { id: 3, name: "Node.js" },
274
+ ];
275
+
276
+ it("should return tags for each post via junction table", async () => {
277
+ // Post 1 → tags 1,2; Post 2 → tags 2,3; Post 3 → tag 1
278
+ const junctionRows = [
279
+ { posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
280
+ { posts_tags: { post_id: 1, tag_id: 2 }, tags: { id: 2, name: "React" } },
281
+ { posts_tags: { post_id: 2, tag_id: 2 }, tags: { id: 2, name: "React" } },
282
+ { posts_tags: { post_id: 2, tag_id: 3 }, tags: { id: 3, name: "Node.js" } },
283
+ { posts_tags: { post_id: 3, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
284
+ ];
285
+
286
+ const { db } = createMockDb(() => junctionRows);
287
+ const service = new RelationService(db, registry);
288
+ const relation = postsCollection.relations![0] as Relation;
289
+
290
+ const results = await service.batchFetchRelatedEntitiesMany(
291
+ "posts", [1, 2, 3], "tags", relation
292
+ );
293
+
294
+ // Post 1 should have 2 tags
295
+ expect(results.get("1")).toHaveLength(2);
296
+ expect(results.get("1")!.map(e => e.values.name)).toEqual(
297
+ expect.arrayContaining(["TypeScript", "React"])
298
+ );
299
+
300
+ // Post 2 should have 2 tags
301
+ expect(results.get("2")).toHaveLength(2);
302
+ expect(results.get("2")!.map(e => e.values.name)).toEqual(
303
+ expect.arrayContaining(["React", "Node.js"])
304
+ );
305
+
306
+ // Post 3 should have 1 tag
307
+ expect(results.get("3")).toHaveLength(1);
308
+ expect(results.get("3")![0].values.name).toBe("TypeScript");
309
+ });
310
+
311
+ it("should set correct entity id and path on each relation result", async () => {
312
+ const junctionRows = [
313
+ { posts_tags: { post_id: 1, tag_id: 42 }, tags: { id: 42, name: "GraphQL" } },
314
+ ];
315
+
316
+ const { db } = createMockDb(() => junctionRows);
317
+ const service = new RelationService(db, registry);
318
+ const relation = postsCollection.relations![0] as Relation;
319
+
320
+ const results = await service.batchFetchRelatedEntitiesMany(
321
+ "posts", [1], "tags", relation
322
+ );
323
+
324
+ const tags = results.get("1")!;
325
+ expect(tags).toHaveLength(1);
326
+ expect(tags[0].id).toBe("42");
327
+ expect(tags[0].path).toBe("tags");
328
+ });
329
+
330
+ it("should use exactly 1 SQL query (junction JOIN target)", async () => {
331
+ const junctionRows = [
332
+ { posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
333
+ ];
334
+
335
+ const { db, recorder } = createMockDb(() => junctionRows);
336
+ const service = new RelationService(db, registry);
337
+ const relation = postsCollection.relations![0] as Relation;
338
+
339
+ await service.batchFetchRelatedEntitiesMany(
340
+ "posts", [1, 2, 3], "tags", relation
341
+ );
342
+
343
+ // Single SELECT from junction with innerJoin to target
344
+ expect(recorder.selectCount).toBe(1);
345
+ expect(recorder.innerJoinCount).toBe(1);
346
+ });
347
+
348
+ it("should return empty map when no junction rows exist", async () => {
349
+ const { db } = createMockDb(() => []);
350
+ const service = new RelationService(db, registry);
351
+ const relation = postsCollection.relations![0] as Relation;
352
+
353
+ const results = await service.batchFetchRelatedEntitiesMany(
354
+ "posts", [1, 2, 3], "tags", relation
355
+ );
356
+
357
+ expect(results.size).toBe(0);
358
+ });
359
+
360
+ it("should return empty map for empty parent IDs", async () => {
361
+ const { db, recorder } = createMockDb(() => []);
362
+ const service = new RelationService(db, registry);
363
+ const relation = postsCollection.relations![0] as Relation;
364
+
365
+ const results = await service.batchFetchRelatedEntitiesMany(
366
+ "posts", [], "tags", relation
367
+ );
368
+
369
+ expect(results.size).toBe(0);
370
+ // No queries should fire
371
+ expect(recorder.selectCount).toBe(0);
372
+ });
373
+
374
+ it("should handle posts where some have tags and some don't", async () => {
375
+ const junctionRows = [
376
+ { posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
377
+ // Post 2 has no junction rows
378
+ { posts_tags: { post_id: 3, tag_id: 2 }, tags: { id: 2, name: "React" } },
379
+ ];
380
+
381
+ const { db } = createMockDb(() => junctionRows);
382
+ const service = new RelationService(db, registry);
383
+ const relation = postsCollection.relations![0] as Relation;
384
+
385
+ const results = await service.batchFetchRelatedEntitiesMany(
386
+ "posts", [1, 2, 3], "tags", relation
387
+ );
388
+
389
+ expect(results.get("1")).toHaveLength(1);
390
+ expect(results.has("2")).toBe(false); // No tags for post 2
391
+ expect(results.get("3")).toHaveLength(1);
392
+ });
393
+ });
394
+
395
+ // ━━━ Inverse M2M with `through` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
396
+
397
+ describe("Inverse M2M with `through` (tags → posts, inverse perspective)", () => {
398
+ it("should return posts for each tag via junction table", async () => {
399
+ // For the inverse direction, buildRelationQuery JOINs through the junction.
400
+ // Result rows contain junction + target data.
401
+ // The parentId (tag) is in the junction's targetColumn.
402
+ const joinedRows = [
403
+ { posts_tags: { post_id: 10, tag_id: 1 }, posts: { id: 10, title: "Post A" } },
404
+ { posts_tags: { post_id: 20, tag_id: 1 }, posts: { id: 20, title: "Post B" } },
405
+ { posts_tags: { post_id: 30, tag_id: 2 }, posts: { id: 30, title: "Post C" } },
406
+ ];
407
+
408
+ const { db } = createMockDb(() => joinedRows);
409
+ const service = new RelationService(db, registry);
410
+ const relation = tagsWithInversePosts.relations![0] as Relation;
411
+
412
+ const results = await service.batchFetchRelatedEntitiesMany(
413
+ "tags_inv", [1, 2], "posts", relation
414
+ );
415
+
416
+ // Tag 1 should have 2 posts
417
+ expect(results.get("1")).toHaveLength(2);
418
+ expect(results.get("1")!.map(e => e.values.title)).toEqual(
419
+ expect.arrayContaining(["Post A", "Post B"])
420
+ );
421
+
422
+ // Tag 2 should have 1 post
423
+ expect(results.get("2")).toHaveLength(1);
424
+ expect(results.get("2")![0].values.title).toBe("Post C");
425
+ });
426
+
427
+ it("should return empty map when no junction rows exist for inverse", async () => {
428
+ const { db } = createMockDb(() => []);
429
+ const service = new RelationService(db, registry);
430
+ const relation = tagsWithInversePosts.relations![0] as Relation;
431
+
432
+ const results = await service.batchFetchRelatedEntitiesMany(
433
+ "tags_inv", [1, 2], "posts", relation
434
+ );
435
+
436
+ expect(results.size).toBe(0);
437
+ });
438
+ });
439
+
440
+ // ━━━ JoinPath (existing path, ensure no regression) ━━━━━━━━━━━━━━
441
+
442
+ describe("JoinPath M2M (authors → posts via author_posts)", () => {
443
+ it("should return posts for each author via joinPath", async () => {
444
+ // joinPath results are namespaced differently — parent data under parent table name
445
+ const joinedRows = [
446
+ { authors: { id: 1, name: "Alice" }, posts: { id: 10, title: "Post X" } },
447
+ { authors: { id: 1, name: "Alice" }, posts: { id: 20, title: "Post Y" } },
448
+ { authors: { id: 2, name: "Bob" }, posts: { id: 30, title: "Post Z" } },
449
+ ];
450
+
451
+ const { db } = createMockDb(() => joinedRows);
452
+ const service = new RelationService(db, registry);
453
+ const relation = authorsWithJoinPath.relations![0] as Relation;
454
+
455
+ const results = await service.batchFetchRelatedEntitiesMany(
456
+ "authors_jp", [1, 2], "posts", relation
457
+ );
458
+
459
+ // Author 1 should have 2 posts
460
+ expect(results.get("1")).toHaveLength(2);
461
+ expect(results.get("1")!.map(e => e.values.title)).toEqual(
462
+ expect.arrayContaining(["Post X", "Post Y"])
463
+ );
464
+
465
+ // Author 2 should have 1 post
466
+ expect(results.get("2")).toHaveLength(1);
467
+ expect(results.get("2")![0].values.title).toBe("Post Z");
468
+ });
469
+ });
470
+
471
+ // ━━━ Error handling ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
472
+
473
+ describe("Error handling", () => {
474
+ it("should return empty map when junction table is not found", async () => {
475
+ // Override getTable to return undefined for junction
476
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
477
+ if (tableName === "posts_tags") return undefined;
478
+ if (tableName === "posts") return mockPostsTable as any;
479
+ if (tableName === "tags") return mockTagsTable as any;
480
+ return undefined;
481
+ });
482
+
483
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
484
+ const { db } = createMockDb(() => []);
485
+ const service = new RelationService(db, registry);
486
+ const relation = postsCollection.relations![0] as Relation;
487
+
488
+ const results = await service.batchFetchRelatedEntitiesMany(
489
+ "posts", [1], "tags", relation
490
+ );
491
+
492
+ expect(results.size).toBe(0);
493
+ expect(consoleSpy).toHaveBeenCalledWith(
494
+ expect.stringContaining("Junction table 'posts_tags' not found")
495
+ );
496
+
497
+ consoleSpy.mockRestore();
498
+ });
499
+ });
500
+
501
+ // ━━━ ID type coercion (number vs string) ━━━━━━━━━━━━━━━━━━━━━━━━━
502
+ // Drizzle may return junction column values as strings even when the
503
+ // parent IDs are numeric. The batch handler must not silently drop
504
+ // results due to `===` mismatches (the original parsedParentIds.includes
505
+ // bug).
506
+
507
+ describe("ID type coercion", () => {
508
+ it("owning M2M: should map results when junction returns string IDs but parent IDs are numbers", async () => {
509
+ // Junction data returns post_id as STRING, but we passed numeric parent IDs
510
+ const junctionRows = [
511
+ { posts_tags: { post_id: "1", tag_id: "5" }, tags: { id: 5, name: "Rust" } },
512
+ { posts_tags: { post_id: "2", tag_id: "5" }, tags: { id: 5, name: "Rust" } },
513
+ ];
514
+
515
+ const { db } = createMockDb(() => junctionRows);
516
+ const service = new RelationService(db, registry);
517
+ const relation = postsCollection.relations![0] as Relation;
518
+
519
+ // Pass numeric parent IDs (parseIdValues will return numbers for numeric PKs)
520
+ const results = await service.batchFetchRelatedEntitiesMany(
521
+ "posts", [1, 2], "tags", relation
522
+ );
523
+
524
+ // Both posts should have their tag despite the string/number mismatch
525
+ expect(results.get("1")).toHaveLength(1);
526
+ expect(results.get("1")![0].values.name).toBe("Rust");
527
+ expect(results.get("2")).toHaveLength(1);
528
+ });
529
+
530
+ it("inverse M2M: should map results when junction returns string IDs but parent IDs are numbers", async () => {
531
+ // Junction data returns tag_id (the parentId for inverse) as STRING
532
+ const joinedRows = [
533
+ { posts_tags: { post_id: 10, tag_id: "1" }, posts: { id: 10, title: "Post A" } },
534
+ { posts_tags: { post_id: 20, tag_id: "2" }, posts: { id: 20, title: "Post B" } },
535
+ ];
536
+
537
+ const { db } = createMockDb(() => joinedRows);
538
+ const service = new RelationService(db, registry);
539
+ const relation = tagsWithInversePosts.relations![0] as Relation;
540
+
541
+ // Pass numeric parent IDs
542
+ const results = await service.batchFetchRelatedEntitiesMany(
543
+ "tags_inv", [1, 2], "posts", relation
544
+ );
545
+
546
+ // Both tags should have their post despite string/number mismatch
547
+ expect(results.get("1")).toHaveLength(1);
548
+ expect(results.get("1")![0].values.title).toBe("Post A");
549
+ expect(results.get("2")).toHaveLength(1);
550
+ expect(results.get("2")![0].values.title).toBe("Post B");
551
+ });
552
+
553
+ it("owning M2M: should handle mixed string and number IDs in same batch", async () => {
554
+ // Some junction rows return numbers, others return strings
555
+ const junctionRows = [
556
+ { posts_tags: { post_id: 1, tag_id: 1 }, tags: { id: 1, name: "TypeScript" } },
557
+ { posts_tags: { post_id: "2", tag_id: "2" }, tags: { id: 2, name: "React" } },
558
+ ];
559
+
560
+ const { db } = createMockDb(() => junctionRows);
561
+ const service = new RelationService(db, registry);
562
+ const relation = postsCollection.relations![0] as Relation;
563
+
564
+ const results = await service.batchFetchRelatedEntitiesMany(
565
+ "posts", [1, 2], "tags", relation
566
+ );
567
+
568
+ expect(results.get("1")).toHaveLength(1);
569
+ expect(results.get("2")).toHaveLength(1);
570
+ });
571
+ });
572
+ });
573
+