@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
@@ -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
+
@@ -9,7 +9,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  /** Create a minimal mock DrizzleClient with a configurable `execute` spy. */
10
10
  function createMockDb() {
11
11
  return {
12
- execute: jest.fn().mockResolvedValue({ rows: [] }),
12
+ execute: jest.fn().mockResolvedValue({ rows: [] })
13
13
  } as unknown as jest.Mocked<NodePgDatabase>;
14
14
  }
15
15
 
@@ -21,7 +21,7 @@ function createMockPoolManager(defaultDbName = "my_app_db") {
21
21
  getDrizzle: jest.fn(),
22
22
  getPool: jest.fn(),
23
23
  hasPool: jest.fn(),
24
- shutdown: jest.fn().mockResolvedValue(undefined),
24
+ shutdown: jest.fn().mockResolvedValue(undefined)
25
25
  } as unknown as jest.Mocked<DatabasePoolManager>;
26
26
  }
27
27
 
@@ -78,9 +78,9 @@ describe("BranchService", () => {
78
78
  // No existing branch
79
79
  db.execute
80
80
  .mockResolvedValueOnce({ rows: [] } as never) // existence check
81
- .mockResolvedValueOnce(undefined as never) // disconnectDatabase (noop)
82
- .mockResolvedValueOnce(undefined as never) // CREATE DATABASE
83
- .mockResolvedValueOnce(undefined as never); // INSERT metadata
81
+ .mockResolvedValueOnce(undefined as never) // disconnectDatabase (noop)
82
+ .mockResolvedValueOnce(undefined as never) // CREATE DATABASE
83
+ .mockResolvedValueOnce(undefined as never); // INSERT metadata
84
84
 
85
85
  const result = await service.createBranch("staging");
86
86
 
@@ -121,7 +121,7 @@ describe("BranchService", () => {
121
121
 
122
122
  it("should throw when the branch already exists in metadata", async () => {
123
123
  db.execute.mockResolvedValueOnce({
124
- rows: [{ name: "staging" }],
124
+ rows: [{ name: "staging" }]
125
125
  } as never);
126
126
 
127
127
  await expect(service.createBranch("staging")).rejects.toThrow(
@@ -242,9 +242,15 @@ describe("BranchService", () => {
242
242
  const now = new Date().toISOString();
243
243
  db.execute.mockResolvedValueOnce({
244
244
  rows: [
245
- { name: "staging", parent_db: "my_app_db", created_at: now, size_bytes: 1048576 },
246
- { name: "preview", parent_db: "my_app_db", created_at: now, size_bytes: null },
247
- ],
245
+ { name: "staging",
246
+ parent_db: "my_app_db",
247
+ created_at: now,
248
+ size_bytes: 1048576 },
249
+ { name: "preview",
250
+ parent_db: "my_app_db",
251
+ created_at: now,
252
+ size_bytes: null }
253
+ ]
248
254
  } as never);
249
255
 
250
256
  const result = await service.listBranches();
@@ -269,10 +275,12 @@ describe("BranchService", () => {
269
275
  const now = new Date().toISOString();
270
276
  db.execute
271
277
  .mockResolvedValueOnce({
272
- rows: [{ name: "staging", parent_db: "my_app_db", created_at: now }],
278
+ rows: [{ name: "staging",
279
+ parent_db: "my_app_db",
280
+ created_at: now }]
273
281
  } as never)
274
282
  .mockResolvedValueOnce({
275
- rows: [{ size_bytes: 2097152 }],
283
+ rows: [{ size_bytes: 2097152 }]
276
284
  } as never);
277
285
 
278
286
  const result = await service.getBranchInfo("staging");
@@ -296,7 +304,9 @@ describe("BranchService", () => {
296
304
  const now = new Date().toISOString();
297
305
  db.execute
298
306
  .mockResolvedValueOnce({
299
- rows: [{ name: "staging", parent_db: "my_app_db", created_at: now }],
307
+ rows: [{ name: "staging",
308
+ parent_db: "my_app_db",
309
+ created_at: now }]
300
310
  } as never)
301
311
  .mockRejectedValueOnce(new Error("database does not exist")); // size query fails
302
312