@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,637 @@
1
+ /**
2
+ * Relation pipeline gap-analysis tests
3
+ *
4
+ * Covers the remaining risk areas identified by gap analysis:
5
+ *
6
+ * 1. 🔴 ID type coercion in batchFetchRelatedEntities (single-cardinality)
7
+ * — parsedParentIds.includes(parentId) used strict ===, silently dropping
8
+ * all inverse results when Drizzle returned string IDs for numeric PKs.
9
+ * Fixed by using Set<string> + String() normalization.
10
+ *
11
+ * 2. 🟡 Inverse M2M `through` write-path warning
12
+ * — updateRelationsUsingJoins had no explicit handler for inverse M2M
13
+ * through relations; they fell to a generic warning. Now emits a
14
+ * specific, actionable message.
15
+ *
16
+ * 3. 🟢 sanitizeRelation junction table naming convention
17
+ * — Verifies that auto-inferred junction table names from sorted slugs
18
+ * match expectations for various collection name patterns.
19
+ */
20
+ import { RelationService } from "../src/services/RelationService";
21
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
22
+ import { EntityCollection, Relation } from "@rebasepro/types";
23
+ import { sanitizeRelation } from "@rebasepro/common";
24
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
25
+
26
+ // ─── Mock Tables ──────────────────────────────────────────────────────
27
+ const mockPostsTable = {
28
+ id: { name: "id", dataType: "number" },
29
+ title: { name: "title" },
30
+ author_id: { name: "author_id", dataType: "number" },
31
+ _def: { tableName: "posts" }
32
+ };
33
+
34
+ const mockTagsTable = {
35
+ id: { name: "id", dataType: "number" },
36
+ name: { name: "name" },
37
+ _def: { tableName: "tags" }
38
+ };
39
+
40
+ const mockPostsTagsTable = {
41
+ post_id: { name: "post_id", dataType: "number" },
42
+ tag_id: { name: "tag_id", dataType: "number" },
43
+ _def: { tableName: "posts_tags" }
44
+ };
45
+
46
+ const mockAuthorsTable = {
47
+ id: { name: "id", dataType: "number" },
48
+ name: { name: "name" },
49
+ _def: { tableName: "authors" }
50
+ };
51
+
52
+ // ─── Mock Collections ─────────────────────────────────────────────────
53
+
54
+ const tagsCollection: EntityCollection = {
55
+ slug: "tags",
56
+ name: "Tags",
57
+ table: "tags",
58
+ properties: {
59
+ id: { type: "number" },
60
+ name: { type: "string" }
61
+ },
62
+ idField: "id"
63
+ };
64
+
65
+ const postsCollection: EntityCollection = {
66
+ slug: "posts",
67
+ name: "Posts",
68
+ table: "posts",
69
+ properties: {
70
+ id: { type: "number" },
71
+ title: { type: "string" },
72
+ tags: { type: "relation", relationName: "tags" }
73
+ },
74
+ relations: [
75
+ {
76
+ relationName: "tags",
77
+ target: () => tagsCollection,
78
+ cardinality: "many",
79
+ direction: "owning",
80
+ through: {
81
+ table: "posts_tags",
82
+ sourceColumn: "post_id",
83
+ targetColumn: "tag_id"
84
+ }
85
+ }
86
+ ],
87
+ idField: "id"
88
+ };
89
+
90
+ const authorsCollection: EntityCollection = {
91
+ slug: "authors",
92
+ name: "Authors",
93
+ table: "authors",
94
+ properties: {
95
+ id: { type: "number" },
96
+ name: { type: "string" },
97
+ posts: { type: "relation", relationName: "posts" }
98
+ },
99
+ relations: [
100
+ {
101
+ relationName: "posts",
102
+ target: () => postsCollection,
103
+ cardinality: "many",
104
+ direction: "inverse",
105
+ foreignKeyOnTarget: "author_id"
106
+ }
107
+ ],
108
+ idField: "id"
109
+ };
110
+
111
+ // Inverse M2M: tags → posts (from tag's perspective)
112
+ const tagsWithInversePosts: EntityCollection = {
113
+ slug: "tags_inv",
114
+ name: "Tags (inverse)",
115
+ table: "tags",
116
+ properties: {
117
+ id: { type: "number" },
118
+ name: { type: "string" },
119
+ posts: { type: "relation", relationName: "posts" }
120
+ },
121
+ relations: [
122
+ {
123
+ relationName: "posts",
124
+ target: () => postsCollection,
125
+ cardinality: "many",
126
+ direction: "inverse",
127
+ through: {
128
+ table: "posts_tags",
129
+ sourceColumn: "post_id",
130
+ targetColumn: "tag_id"
131
+ }
132
+ }
133
+ ],
134
+ idField: "id"
135
+ };
136
+
137
+ // ─── Mock DB Factory ──────────────────────────────────────────────────
138
+
139
+ function createMockDb(resolveResults: () => unknown[]) {
140
+ const recorder = {
141
+ selectCount: 0,
142
+ innerJoinCount: 0,
143
+ fromTable: undefined as string | undefined,
144
+ deleteCalls: [] as unknown[],
145
+ insertCalls: [] as unknown[],
146
+ };
147
+
148
+ function makeChainable(): Record<string, unknown> {
149
+ const chain: Record<string, unknown> = {
150
+ select: jest.fn(() => {
151
+ recorder.selectCount++;
152
+ return chain;
153
+ }),
154
+ from: jest.fn((table: Record<string, unknown>) => {
155
+ const tableDef = table._def as { tableName: string } | undefined;
156
+ recorder.fromTable = tableDef?.tableName ?? "unknown";
157
+ return chain;
158
+ }),
159
+ where: jest.fn(() => chain),
160
+ $dynamic: jest.fn(() => chain),
161
+ limit: jest.fn(() => chain),
162
+ offset: jest.fn(() => chain),
163
+ orderBy: jest.fn(() => chain),
164
+ innerJoin: jest.fn(() => {
165
+ recorder.innerJoinCount++;
166
+ return chain;
167
+ }),
168
+ delete: jest.fn((table: unknown) => {
169
+ recorder.deleteCalls.push(table);
170
+ return chain;
171
+ }),
172
+ insert: jest.fn((table: unknown) => {
173
+ recorder.insertCalls.push(table);
174
+ return { values: jest.fn(() => chain) };
175
+ }),
176
+ set: jest.fn(() => chain),
177
+ values: jest.fn(() => chain),
178
+ then: (resolve: (val: unknown[]) => void) => {
179
+ resolve(resolveResults());
180
+ }
181
+ };
182
+ return chain;
183
+ }
184
+
185
+ return {
186
+ db: makeChainable() as unknown as jest.Mocked<NodePgDatabase>,
187
+ recorder,
188
+ };
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════════════════
192
+ // 1. ID type coercion in batchFetchRelatedEntities (single cardinality)
193
+ // ═══════════════════════════════════════════════════════════════════════
194
+
195
+ describe("batchFetchRelatedEntities: ID type coercion (single cardinality)", () => {
196
+ let registry: PostgresCollectionRegistry;
197
+
198
+ beforeEach(() => {
199
+ registry = new PostgresCollectionRegistry();
200
+
201
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
202
+ if (path?.startsWith("posts")) return postsCollection;
203
+ if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
204
+ if (path?.startsWith("tags")) return tagsCollection;
205
+ if (path?.startsWith("authors")) return authorsCollection;
206
+ return undefined;
207
+ });
208
+
209
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
210
+ if (tableName === "posts") return mockPostsTable as any;
211
+ if (tableName === "tags") return mockTagsTable as any;
212
+ if (tableName === "posts_tags") return mockPostsTagsTable as any;
213
+ if (tableName === "authors") return mockAuthorsTable as any;
214
+ return undefined;
215
+ });
216
+
217
+ jest.spyOn(registry, "getCollections").mockReturnValue([
218
+ postsCollection, tagsCollection, authorsCollection
219
+ ]);
220
+ });
221
+
222
+ afterEach(() => {
223
+ jest.restoreAllMocks();
224
+ });
225
+
226
+ it("should match results when Drizzle returns string IDs but parsedParentIds are numbers (FK inverse)", async () => {
227
+ // Simulate Drizzle returning author_id as a string even though it's a numeric column
228
+ const resultRows = [
229
+ { id: 10, title: "Post A", author_id: "1" },
230
+ { id: 20, title: "Post B", author_id: "2" },
231
+ ];
232
+
233
+ const { db } = createMockDb(() => resultRows);
234
+ const service = new RelationService(db, registry);
235
+ const relation = authorsCollection.relations![0] as Relation;
236
+
237
+ // Pass numeric parent IDs — parseIdValues will return numbers
238
+ const results = await service.batchFetchRelatedEntities(
239
+ "authors", [1, 2], "posts", relation
240
+ );
241
+
242
+ // Before fix: both results would be silently dropped because
243
+ // parsedParentIds.includes("1") returns false when parsedParentIds contains 1
244
+ expect(results.get("1")).toBeDefined();
245
+ expect(results.get("1")!.values.title).toBe("Post A");
246
+ expect(results.get("2")).toBeDefined();
247
+ expect(results.get("2")!.values.title).toBe("Post B");
248
+ });
249
+
250
+ it("should match results when Drizzle returns number IDs but parsedParentIds contain strings", async () => {
251
+ // Simulate the reverse: Drizzle returns numbers, but parsed IDs might be strings
252
+ const resultRows = [
253
+ { id: 10, title: "Post A", author_id: 1 },
254
+ ];
255
+
256
+ const { db } = createMockDb(() => resultRows);
257
+ const service = new RelationService(db, registry);
258
+ const relation = authorsCollection.relations![0] as Relation;
259
+
260
+ const results = await service.batchFetchRelatedEntities(
261
+ "authors", [1], "posts", relation
262
+ );
263
+
264
+ expect(results.get("1")).toBeDefined();
265
+ expect(results.get("1")!.values.title).toBe("Post A");
266
+ });
267
+
268
+ it("should handle mixed string and number IDs across result rows", async () => {
269
+ const resultRows = [
270
+ { id: 10, title: "Post A", author_id: 1 }, // number
271
+ { id: 20, title: "Post B", author_id: "2" }, // string
272
+ ];
273
+
274
+ const { db } = createMockDb(() => resultRows);
275
+ const service = new RelationService(db, registry);
276
+ const relation = authorsCollection.relations![0] as Relation;
277
+
278
+ const results = await service.batchFetchRelatedEntities(
279
+ "authors", [1, 2], "posts", relation
280
+ );
281
+
282
+ expect(results.get("1")).toBeDefined();
283
+ expect(results.get("2")).toBeDefined();
284
+ });
285
+
286
+ it("should not match results for IDs not in the parent set", async () => {
287
+ const resultRows = [
288
+ { id: 10, title: "Post A", author_id: "999" },
289
+ ];
290
+
291
+ const { db } = createMockDb(() => resultRows);
292
+ const service = new RelationService(db, registry);
293
+ const relation = authorsCollection.relations![0] as Relation;
294
+
295
+ const results = await service.batchFetchRelatedEntities(
296
+ "authors", [1, 2], "posts", relation
297
+ );
298
+
299
+ // 999 is not in the parent set
300
+ expect(results.size).toBe(0);
301
+ });
302
+
303
+ it("should handle inferredForeignKeyName path with string IDs", async () => {
304
+ // Test the `inverseRelationName`-based FK inference path
305
+ const authorsWithInverseNameOnly: EntityCollection = {
306
+ slug: "authors_inr",
307
+ name: "Authors (inverseRelationName)",
308
+ table: "authors",
309
+ properties: {
310
+ id: { type: "number" },
311
+ name: { type: "string" }
312
+ },
313
+ idField: "id"
314
+ };
315
+
316
+ const postsWithFK: EntityCollection = {
317
+ slug: "posts_fk",
318
+ name: "Posts (FK)",
319
+ table: "posts",
320
+ properties: {
321
+ id: { type: "number" },
322
+ title: { type: "string" }
323
+ },
324
+ idField: "id"
325
+ };
326
+
327
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
328
+ if (path?.startsWith("authors_inr")) return authorsWithInverseNameOnly;
329
+ if (path?.startsWith("posts_fk")) return postsWithFK;
330
+ return undefined;
331
+ });
332
+
333
+ const relation: Relation = {
334
+ relationName: "posts",
335
+ target: () => postsWithFK,
336
+ cardinality: "one",
337
+ direction: "inverse",
338
+ inverseRelationName: "author"
339
+ };
340
+
341
+ // Drizzle returns author_id as string
342
+ const resultRows = [
343
+ { id: 10, title: "Post A", author_id: "1" },
344
+ ];
345
+
346
+ const { db } = createMockDb(() => resultRows);
347
+ const service = new RelationService(db, registry);
348
+
349
+ const results = await service.batchFetchRelatedEntities(
350
+ "authors_inr", [1], "posts", relation
351
+ );
352
+
353
+ expect(results.get("1")).toBeDefined();
354
+ expect(results.get("1")!.values.title).toBe("Post A");
355
+ });
356
+ });
357
+
358
+ // ═══════════════════════════════════════════════════════════════════════
359
+ // 2. Inverse M2M write-path warning
360
+ // ═══════════════════════════════════════════════════════════════════════
361
+
362
+ describe("updateRelationsUsingJoins: inverse M2M through warning", () => {
363
+ let registry: PostgresCollectionRegistry;
364
+
365
+ beforeEach(() => {
366
+ registry = new PostgresCollectionRegistry();
367
+
368
+ jest.spyOn(registry, "getCollectionByPath").mockImplementation(path => {
369
+ if (path?.startsWith("tags_inv")) return tagsWithInversePosts;
370
+ if (path?.startsWith("tags")) return tagsCollection;
371
+ if (path?.startsWith("posts")) return postsCollection;
372
+ return undefined;
373
+ });
374
+
375
+ jest.spyOn(registry, "getTable").mockImplementation(tableName => {
376
+ if (tableName === "posts") return mockPostsTable as any;
377
+ if (tableName === "tags") return mockTagsTable as any;
378
+ if (tableName === "posts_tags") return mockPostsTagsTable as any;
379
+ return undefined;
380
+ });
381
+
382
+ jest.spyOn(registry, "getCollections").mockReturnValue([
383
+ postsCollection, tagsCollection
384
+ ]);
385
+ });
386
+
387
+ afterEach(() => {
388
+ jest.restoreAllMocks();
389
+ });
390
+
391
+ it("should emit a specific warning when attempting to save an inverse M2M through relation", async () => {
392
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
393
+
394
+ const { db } = createMockDb(() => []);
395
+ const service = new RelationService(db, registry);
396
+
397
+ // Try to save posts from the tags (inverse) side
398
+ await service.updateRelationsUsingJoins(
399
+ db as any,
400
+ tagsWithInversePosts,
401
+ 1,
402
+ { posts: [{ id: 10 }, { id: 20 }] }
403
+ );
404
+
405
+ // Should warn about inverse M2M
406
+ expect(consoleSpy).toHaveBeenCalledWith(
407
+ expect.stringContaining("Inverse M2M relation")
408
+ );
409
+ expect(consoleSpy).toHaveBeenCalledWith(
410
+ expect.stringContaining("should be saved from the owning side")
411
+ );
412
+
413
+ consoleSpy.mockRestore();
414
+ });
415
+
416
+ it("should NOT warn for owning M2M through relations (normal save path)", async () => {
417
+ const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
418
+
419
+ const { db } = createMockDb(() => []);
420
+ const service = new RelationService(db, registry);
421
+
422
+ // Owning side save should work normally (or at least not trigger the inverse warning)
423
+ await service.updateRelationsUsingJoins(
424
+ db as any,
425
+ postsCollection,
426
+ 1,
427
+ { tags: [{ id: 1 }, { id: 2 }] }
428
+ );
429
+
430
+ // Should NOT have the inverse M2M warning
431
+ const inverseCalls = consoleSpy.mock.calls.filter(
432
+ call => typeof call[0] === "string" && call[0].includes("Inverse M2M")
433
+ );
434
+ expect(inverseCalls).toHaveLength(0);
435
+
436
+ consoleSpy.mockRestore();
437
+ });
438
+ });
439
+
440
+ // ═══════════════════════════════════════════════════════════════════════
441
+ // 3. sanitizeRelation junction table naming convention
442
+ // ═══════════════════════════════════════════════════════════════════════
443
+
444
+ describe("sanitizeRelation: auto-inferred junction table naming", () => {
445
+ it("should produce sorted junction table name: posts + tags → posts_tags", () => {
446
+ const source: EntityCollection = {
447
+ slug: "posts",
448
+ name: "Posts",
449
+ table: "posts",
450
+ properties: {}
451
+ };
452
+ const target: EntityCollection = {
453
+ slug: "tags",
454
+ name: "Tags",
455
+ table: "tags",
456
+ properties: {}
457
+ };
458
+
459
+ const relation: Partial<Relation> = {
460
+ relationName: "tags",
461
+ target: () => target,
462
+ cardinality: "many",
463
+ direction: "owning"
464
+ };
465
+
466
+ const normalized = sanitizeRelation(relation, source as any);
467
+
468
+ expect(normalized.through).toBeDefined();
469
+ expect(normalized.through!.table).toBe("posts_tags"); // ["posts", "tags"].sort().join("_")
470
+ });
471
+
472
+ it("should sort alphabetically: articles + tags → articles_tags (not tags_articles)", () => {
473
+ const source: EntityCollection = {
474
+ slug: "tags",
475
+ name: "Tags",
476
+ table: "tags",
477
+ properties: {}
478
+ };
479
+ const target: EntityCollection = {
480
+ slug: "articles",
481
+ name: "Articles",
482
+ table: "articles",
483
+ properties: {}
484
+ };
485
+
486
+ const relation: Partial<Relation> = {
487
+ relationName: "articles",
488
+ target: () => target,
489
+ cardinality: "many",
490
+ direction: "owning"
491
+ };
492
+
493
+ const normalized = sanitizeRelation(relation, source as any);
494
+
495
+ expect(normalized.through!.table).toBe("articles_tags"); // sorted
496
+ });
497
+
498
+ it("should handle underscore-containing slugs: blog_posts + labels → blog_posts_labels", () => {
499
+ const source: EntityCollection = {
500
+ slug: "blog-posts",
501
+ name: "Blog Posts",
502
+ table: "blog_posts",
503
+ properties: {}
504
+ };
505
+ const target: EntityCollection = {
506
+ slug: "labels",
507
+ name: "Labels",
508
+ table: "labels",
509
+ properties: {}
510
+ };
511
+
512
+ const relation: Partial<Relation> = {
513
+ relationName: "labels",
514
+ target: () => target,
515
+ cardinality: "many",
516
+ direction: "owning"
517
+ };
518
+
519
+ const normalized = sanitizeRelation(relation, source as any);
520
+
521
+ expect(normalized.through!.table).toBe("blog_posts_labels"); // sorted: blog_posts < labels
522
+ });
523
+
524
+ it("should generate correct source and target columns", () => {
525
+ const source: EntityCollection = {
526
+ slug: "posts",
527
+ name: "Posts",
528
+ table: "posts",
529
+ properties: {}
530
+ };
531
+ const target: EntityCollection = {
532
+ slug: "tags",
533
+ name: "Tags",
534
+ table: "tags",
535
+ properties: {}
536
+ };
537
+
538
+ const relation: Partial<Relation> = {
539
+ relationName: "tags",
540
+ target: () => target,
541
+ cardinality: "many",
542
+ direction: "owning"
543
+ };
544
+
545
+ const normalized = sanitizeRelation(relation, source as any);
546
+
547
+ // sourceColumn derives from source slug (singularized), targetColumn from relationName (singularized)
548
+ // generateForeignKeyName("posts") → "post_id", generateForeignKeyName("tags") → "tag_id"
549
+ expect(normalized.through!.sourceColumn).toBe("post_id");
550
+ expect(normalized.through!.targetColumn).toBe("tag_id");
551
+ });
552
+
553
+ it("should preserve explicit through config and not overwrite it", () => {
554
+ const source: EntityCollection = {
555
+ slug: "posts",
556
+ name: "Posts",
557
+ table: "posts",
558
+ properties: {}
559
+ };
560
+ const target: EntityCollection = {
561
+ slug: "tags",
562
+ name: "Tags",
563
+ table: "tags",
564
+ properties: {}
565
+ };
566
+
567
+ const relation: Partial<Relation> = {
568
+ relationName: "tags",
569
+ target: () => target,
570
+ cardinality: "many",
571
+ direction: "owning",
572
+ through: {
573
+ table: "custom_junction",
574
+ sourceColumn: "src_id",
575
+ targetColumn: "tgt_id"
576
+ }
577
+ };
578
+
579
+ const normalized = sanitizeRelation(relation, source as any);
580
+
581
+ expect(normalized.through!.table).toBe("custom_junction");
582
+ expect(normalized.through!.sourceColumn).toBe("src_id");
583
+ expect(normalized.through!.targetColumn).toBe("tgt_id");
584
+ });
585
+
586
+ it("should handle self-referencing M2M: users + users → users_users", () => {
587
+ const usersCollection: EntityCollection = {
588
+ slug: "users",
589
+ name: "Users",
590
+ table: "users",
591
+ properties: {}
592
+ };
593
+
594
+ const relation: Partial<Relation> = {
595
+ relationName: "friends",
596
+ target: () => usersCollection,
597
+ cardinality: "many",
598
+ direction: "owning"
599
+ };
600
+
601
+ const normalized = sanitizeRelation(relation, usersCollection as any);
602
+
603
+ // Both tables are "users", sorted = ["users", "users"], joined = "users_users"
604
+ expect(normalized.through!.table).toBe("users_users");
605
+ });
606
+
607
+ it("should NOT add through config for joinPath-based relations", () => {
608
+ const source: EntityCollection = {
609
+ slug: "users",
610
+ name: "Users",
611
+ table: "users",
612
+ properties: {}
613
+ };
614
+ const target: EntityCollection = {
615
+ slug: "permissions",
616
+ name: "Permissions",
617
+ table: "permissions",
618
+ properties: {}
619
+ };
620
+
621
+ const relation: Partial<Relation> = {
622
+ relationName: "permissions",
623
+ target: () => target,
624
+ cardinality: "many",
625
+ direction: "owning",
626
+ joinPath: [
627
+ { table: "user_roles", on: { from: "id", to: "user_id" } },
628
+ { table: "permissions", on: { from: "permission_id", to: "id" } }
629
+ ]
630
+ };
631
+
632
+ const normalized = sanitizeRelation(relation, source as any);
633
+
634
+ // joinPath takes precedence — no through should be auto-generated
635
+ expect(normalized.through).toBeUndefined();
636
+ });
637
+ });