@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
@@ -212,9 +212,9 @@ describe("Comprehensive Relations Test Suite", () => {
212
212
 
213
213
  const cleanSchema = (schema: string) => {
214
214
  return schema
215
- .replace(/\/\/.*$/gm, '')
216
- .replace(/\/\*[\s\S]*?\*\//g, '')
217
- .replace(/\n{2,}/g, '\n')
215
+ .replace(/\/\/.*$/gm, "")
216
+ .replace(/\/\*[\s\S]*?\*\//g, "")
217
+ .replace(/\n{2,}/g, "\n")
218
218
  .replace(/\s+/g, " ")
219
219
  .trim();
220
220
  };
@@ -227,7 +227,8 @@ describe("Comprehensive Relations Test Suite", () => {
227
227
  name: "Authors",
228
228
  properties: {
229
229
  name: { type: "string" },
230
- books: { type: "relation", relationName: "books" }
230
+ books: { type: "relation",
231
+ relationName: "books" }
231
232
  },
232
233
  relations: [
233
234
  {
@@ -257,10 +258,10 @@ describe("Comprehensive Relations Test Suite", () => {
257
258
  const cleanResult = cleanSchema(result);
258
259
 
259
260
  // Should create junction table
260
- expect(cleanResult).toContain(`export const authorBooks = pgTable("author_books"`);
261
- expect(cleanResult).toContain(`author_id: varchar("author_id").notNull().references(() => authors.id, { onDelete: "cascade" })`);
262
- expect(cleanResult).toContain(`book_id: varchar("book_id").notNull().references(() => books.id, { onDelete: "cascade" })`);
263
- expect(cleanResult).toContain(`export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ books: many(authorBooks, { relationName: "books" }) }));`);
261
+ expect(cleanResult).toContain("export const authorBooks = pgTable(\"author_books\"");
262
+ expect(cleanResult).toContain("author_id: varchar(\"author_id\").notNull().references(() => authors.id, { onDelete: \"cascade\" })");
263
+ expect(cleanResult).toContain("book_id: varchar(\"book_id\").notNull().references(() => books.id, { onDelete: \"cascade\" })");
264
+ expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"books\": many(authorBooks, { relationName: \"books\" }) }));");
264
265
  });
265
266
 
266
267
  it("should handle a 4-table many-to-many chain with joinPath", async () => {
@@ -270,7 +271,8 @@ describe("Comprehensive Relations Test Suite", () => {
270
271
  name: "Users",
271
272
  properties: {
272
273
  name: { type: "string" },
273
- permissions: { type: "relation", relationName: "permissions" }
274
+ permissions: { type: "relation",
275
+ relationName: "permissions" }
274
276
  },
275
277
  relations: [
276
278
  {
@@ -278,10 +280,18 @@ describe("Comprehensive Relations Test Suite", () => {
278
280
  target: () => permissionsCollection,
279
281
  cardinality: "many",
280
282
  joinPath: [
281
- { table: "user_roles", on: { from: "id", to: "user_id" } },
282
- { table: "roles", on: { from: "role_id", to: "id" } },
283
- { table: "role_permissions", on: { from: "id", to: "role_id" } },
284
- { table: "permissions", on: { from: "permission_id", to: "id" } }
283
+ { table: "user_roles",
284
+ on: { from: "id",
285
+ to: "user_id" } },
286
+ { table: "roles",
287
+ on: { from: "role_id",
288
+ to: "id" } },
289
+ { table: "role_permissions",
290
+ on: { from: "id",
291
+ to: "role_id" } },
292
+ { table: "permissions",
293
+ on: { from: "permission_id",
294
+ to: "id" } }
285
295
  ]
286
296
  }
287
297
  ]
@@ -330,7 +340,8 @@ describe("Comprehensive Relations Test Suite", () => {
330
340
  name: "Authors",
331
341
  properties: {
332
342
  name: { type: "string" },
333
- profile: { type: "relation", relationName: "profile" }
343
+ profile: { type: "relation",
344
+ relationName: "profile" }
334
345
  },
335
346
  relations: [
336
347
  {
@@ -349,7 +360,8 @@ describe("Comprehensive Relations Test Suite", () => {
349
360
  name: "Profiles",
350
361
  properties: {
351
362
  bio: { type: "string" },
352
- author: { type: "relation", relationName: "author" }
363
+ author: { type: "relation",
364
+ relationName: "author" }
353
365
  },
354
366
  relations: [
355
367
  {
@@ -365,13 +377,13 @@ describe("Comprehensive Relations Test Suite", () => {
365
377
  const cleanResult = cleanSchema(result);
366
378
 
367
379
  // Should create FK on profiles table
368
- expect(cleanResult).toContain(`author_id: varchar("author_id").references(() => authors.id, { onDelete: "set null" })`);
380
+ expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
369
381
 
370
382
  // Should create owning relation on profiles
371
- expect(cleanResult).toContain(`export const profilesRelations = drizzleRelations(profiles, ({ one, many }) => ({ author: one(authors, { fields: [profiles.author_id], references: [authors.id], relationName: "author" }) }));`);
383
+ expect(cleanResult).toContain("export const profilesRelations = drizzleRelations(profiles, ({ one, many }) => ({ \"author\": one(authors, { fields: [profiles.author_id], references: [authors.id], relationName: \"profiles_author_id\" }) }));");
372
384
 
373
385
  // Should create inverse relation on authors (this was previously missing)
374
- expect(cleanResult).toContain(`export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ profile: one(profiles, { fields: [authors.id], references: [profiles.author_id], relationName: "profile" }) }));`);
386
+ expect(cleanResult).toContain("export const authorsRelations = drizzleRelations(authors, ({ one, many }) => ({ \"profile\": one(profiles, { fields: [authors.id], references: [profiles.author_id], relationName: \"profiles_author_id\" }) }));");
375
387
  });
376
388
 
377
389
  it("should generate owning one-to-many relations", async () => {
@@ -380,7 +392,7 @@ describe("Comprehensive Relations Test Suite", () => {
380
392
  table: "categories",
381
393
  name: "Categories",
382
394
  properties: {
383
- name: { type: "string" },
395
+ name: { type: "string" }
384
396
  }
385
397
  };
386
398
 
@@ -390,7 +402,8 @@ describe("Comprehensive Relations Test Suite", () => {
390
402
  name: "Posts",
391
403
  properties: {
392
404
  title: { type: "string" },
393
- category: { type: "relation", relationName: "category" }
405
+ category: { type: "relation",
406
+ relationName: "category" }
394
407
  },
395
408
  relations: [
396
409
  {
@@ -406,9 +419,9 @@ describe("Comprehensive Relations Test Suite", () => {
406
419
  const cleanResult = cleanSchema(result);
407
420
 
408
421
  // Should create FK on posts table
409
- expect(cleanResult).toContain(`category_id: varchar("category_id").references(() => categories.id, { onDelete: "set null" })`);
422
+ expect(cleanResult).toContain("category_id: varchar(\"category_id\").references(() => categories.id, { onDelete: \"set null\" })");
410
423
  // Should create owning relation on posts
411
- expect(cleanResult).toContain(`export const postsRelations = drizzleRelations(posts, ({ one, many }) => ({ category: one(categories, { fields: [posts.category_id], references: [categories.id], relationName: "category" }) }));`);
424
+ expect(cleanResult).toContain("export const postsRelations = drizzleRelations(posts, ({ one, many }) => ({ \"category\": one(categories, { fields: [posts.category_id], references: [categories.id], relationName: \"posts_category_id\" }) }));");
412
425
  });
413
426
  });
414
427
 
@@ -420,7 +433,8 @@ describe("Comprehensive Relations Test Suite", () => {
420
433
  name: "Authors",
421
434
  properties: {
422
435
  name: { type: "string" },
423
- publisher: { type: "relation", relationName: "publisher" },
436
+ publisher: { type: "relation",
437
+ relationName: "publisher" }
424
438
  },
425
439
  relations: [
426
440
  {
@@ -447,7 +461,8 @@ describe("Comprehensive Relations Test Suite", () => {
447
461
  name: "Books",
448
462
  properties: {
449
463
  title: { type: "string" },
450
- author: { type: "relation", relationName: "author" }
464
+ author: { type: "relation",
465
+ relationName: "author" }
451
466
  },
452
467
  relations: [{
453
468
  relationName: "author",
@@ -461,12 +476,12 @@ describe("Comprehensive Relations Test Suite", () => {
461
476
  const cleanResult = cleanSchema(result);
462
477
 
463
478
  // Check owning relation from author to publisher
464
- expect(cleanResult).toContain(`publisher_id: varchar("publisher_id").references(() => publishers.id, { onDelete: "set null" })`);
465
- expect(cleanResult).toContain(`publisher: one(publishers, { fields: [authors.publisher_id], references: [publishers.id], relationName: "publisher" })`);
479
+ expect(cleanResult).toContain("publisher_id: varchar(\"publisher_id\").references(() => publishers.id, { onDelete: \"set null\" })");
480
+ expect(cleanResult).toContain("\"publisher\": one(publishers, { fields: [authors.publisher_id], references: [publishers.id], relationName: \"authors_publisher_id\" })");
466
481
 
467
482
  // Check owning relation from book to author
468
- expect(cleanResult).toContain(`author_id: varchar("author_id").references(() => authors.id, { onDelete: "set null" })`);
469
- expect(cleanResult).toContain(`author: one(authors, { fields: [books.author_id], references: [authors.id], relationName: "author" })`);
483
+ expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
484
+ expect(cleanResult).toContain("\"author\": one(authors, { fields: [books.author_id], references: [authors.id], relationName: \"books_author_id\" })");
470
485
  });
471
486
  });
472
487
 
@@ -478,8 +493,10 @@ describe("Comprehensive Relations Test Suite", () => {
478
493
  name: "Orders",
479
494
  properties: {
480
495
  customer_code: { type: "string" },
481
- region_id: { type: "number", validation: { integer: true } },
482
- customer: { type: "relation", relationName: "customer" }
496
+ region_id: { type: "number",
497
+ validation: { integer: true } },
498
+ customer: { type: "relation",
499
+ relationName: "customer" }
483
500
  },
484
501
  relations: [
485
502
  {
@@ -487,7 +504,9 @@ describe("Comprehensive Relations Test Suite", () => {
487
504
  target: () => customersCollection,
488
505
  cardinality: "many",
489
506
  joinPath: [
490
- { table: "customers", on: { from: ["customer_code", "region_id"], to: ["code", "region_id"] } }
507
+ { table: "customers",
508
+ on: { from: ["customer_code", "region_id"],
509
+ to: ["code", "region_id"] } }
491
510
  ]
492
511
  }
493
512
  ]
@@ -499,7 +518,8 @@ describe("Comprehensive Relations Test Suite", () => {
499
518
  name: "Customers",
500
519
  properties: {
501
520
  code: { type: "string" },
502
- region_id: { type: "number", validation: { integer: true } },
521
+ region_id: { type: "number",
522
+ validation: { integer: true } },
503
523
  name: { type: "string" }
504
524
  }
505
525
  };
@@ -537,7 +557,8 @@ describe("Comprehensive Relations Test Suite", () => {
537
557
  name: "Users",
538
558
  properties: {
539
559
  name: { type: "string" },
540
- friends: { type: "relation", relationName: "friends" }
560
+ friends: { type: "relation",
561
+ relationName: "friends" }
541
562
  },
542
563
  relations: [
543
564
  {
@@ -569,9 +590,11 @@ describe("Comprehensive Relations Test Suite", () => {
569
590
  table: "products",
570
591
  name: "Products",
571
592
  properties: {
572
- sku: { type: "string", isId: true },
593
+ sku: { type: "string",
594
+ isId: true },
573
595
  name: { type: "string" },
574
- categories: { type: "relation", relationName: "categories" }
596
+ categories: { type: "relation",
597
+ relationName: "categories" }
575
598
  },
576
599
  relations: [
577
600
  {
@@ -614,7 +637,8 @@ describe("Comprehensive Relations Test Suite", () => {
614
637
  name: "A Entities",
615
638
  properties: {
616
639
  name: { type: "string" },
617
- b_entities: { type: "relation", relationName: "b_entities" }
640
+ b_entities: { type: "relation",
641
+ relationName: "b_entities" }
618
642
  },
619
643
  relations: [
620
644
  {
@@ -633,7 +657,8 @@ describe("Comprehensive Relations Test Suite", () => {
633
657
  name: "B Entities",
634
658
  properties: {
635
659
  name: { type: "string" },
636
- a_entity: { type: "relation", relationName: "a_entity" }
660
+ a_entity: { type: "relation",
661
+ relationName: "a_entity" }
637
662
  },
638
663
  relations: [
639
664
  {
@@ -655,8 +680,436 @@ describe("Comprehensive Relations Test Suite", () => {
655
680
  expect(cleanResult).toContain("export const bEntities = pgTable(\"b_entities\"");
656
681
  expect(cleanResult).toContain("a_entity_id: varchar(\"a_entity_id\").references(() => aEntities.id, { onDelete: \"set null\" })");
657
682
  // Check that both drizzle relations are generated
658
- expect(cleanResult).toContain("export const aEntitiesRelations = drizzleRelations(aEntities, ({ one, many }) => ({ b_entities: many(bEntities, { relationName: \"b_entities\" }) }));");
659
- expect(cleanResult).toContain("export const bEntitiesRelations = drizzleRelations(bEntities, ({ one, many }) => ({ a_entity: one(aEntities, { fields: [bEntities.a_entity_id], references: [aEntities.id], relationName: \"a_entity\" }) }));");
683
+ expect(cleanResult).toContain("\"b_entities\": many(bEntities, { relationName: \"b_entities_a_entity_id\" })");
684
+ expect(cleanResult).toContain("\"a_entity\": one(aEntities, { fields: [bEntities.a_entity_id], references: [aEntities.id], relationName: \"b_entities_a_entity_id\" })");
660
685
  });
661
686
  });
662
687
  });
688
+
689
+ /**
690
+ * Regression tests for https://github.com/rebasepro/rebase/issues/XXX
691
+ * Ensures both sides of an owning/inverse relation emit the same `relationName`.
692
+ */
693
+ describe("Shared relationName regression", () => {
694
+ const cleanSchema = (schema: string) => {
695
+ return schema
696
+ .replace(/\/\/.*$/gm, "")
697
+ .replace(/\/\*[\s\S]*?\*\//g, "")
698
+ .replace(/\n{2,}/g, "\n")
699
+ .replace(/\s+/g, " ")
700
+ .trim();
701
+ };
702
+
703
+ /**
704
+ * Helper that extracts all `relationName: "..."` values from generated schema output.
705
+ */
706
+ const extractRelationNames = (schema: string): string[] => {
707
+ const matches = schema.match(/relationName:\s*"([^"]+)"/g) ?? [];
708
+ return matches.map(m => m.replace(/relationName:\s*"/, "").replace(/"$/, ""));
709
+ };
710
+
711
+ it("should emit identical relationName for one-to-many owning + inverse pair", async () => {
712
+ const companiesCollection: EntityCollection = {
713
+ slug: "companies",
714
+ table: "companies",
715
+ name: "Companies",
716
+ properties: {
717
+ name: { type: "string" }
718
+ },
719
+ relations: [
720
+ {
721
+ relationName: "jobs",
722
+ target: () => jobsCollection,
723
+ cardinality: "many",
724
+ direction: "inverse",
725
+ foreignKeyOnTarget: "company_id"
726
+ }
727
+ ]
728
+ };
729
+
730
+ const jobsCollection: EntityCollection = {
731
+ slug: "jobs",
732
+ table: "jobs",
733
+ name: "Jobs",
734
+ properties: {
735
+ title: { type: "string" },
736
+ company: { type: "relation",
737
+ relationName: "company" }
738
+ },
739
+ relations: [
740
+ {
741
+ relationName: "company",
742
+ target: () => companiesCollection,
743
+ cardinality: "one",
744
+ direction: "owning",
745
+ localKey: "company_id"
746
+ }
747
+ ]
748
+ };
749
+
750
+ const result = await generateSchema([companiesCollection, jobsCollection]);
751
+ const cleanResult = cleanSchema(result);
752
+
753
+ // Both sides must use the same deterministic name: jobs_company_id
754
+ const expectedSharedName = "jobs_company_id";
755
+
756
+ // Owning side (jobs → companies)
757
+ expect(cleanResult).toContain(
758
+ `"company": one(companies, { fields: [jobs.company_id], references: [companies.id], relationName: \"${expectedSharedName}\" })`
759
+ );
760
+
761
+ // Inverse side (companies → jobs)
762
+ expect(cleanResult).toContain(
763
+ `"jobs": many(jobs, { relationName: \"${expectedSharedName}\" })`
764
+ );
765
+
766
+ // Verify there are exactly 2 occurrences of the shared name
767
+ const allNames = extractRelationNames(result);
768
+ const matchingNames = allNames.filter(n => n === expectedSharedName);
769
+ expect(matchingNames).toHaveLength(2);
770
+ });
771
+
772
+ it("should emit identical relationName for one-to-one owning + inverse pair", async () => {
773
+ const usersCollection: EntityCollection = {
774
+ slug: "users",
775
+ table: "users",
776
+ name: "Users",
777
+ properties: {
778
+ name: { type: "string" }
779
+ },
780
+ relations: [
781
+ {
782
+ relationName: "profile",
783
+ target: () => profilesCollection,
784
+ cardinality: "one",
785
+ direction: "inverse",
786
+ foreignKeyOnTarget: "user_id"
787
+ }
788
+ ]
789
+ };
790
+
791
+ const profilesCollection: EntityCollection = {
792
+ slug: "profiles",
793
+ table: "profiles",
794
+ name: "Profiles",
795
+ properties: {
796
+ bio: { type: "string" },
797
+ user: { type: "relation",
798
+ relationName: "user" }
799
+ },
800
+ relations: [
801
+ {
802
+ relationName: "user",
803
+ target: () => usersCollection,
804
+ cardinality: "one",
805
+ direction: "owning",
806
+ localKey: "user_id"
807
+ }
808
+ ]
809
+ };
810
+
811
+ const result = await generateSchema([usersCollection, profilesCollection]);
812
+ const cleanResult = cleanSchema(result);
813
+
814
+ const expectedSharedName = "profiles_user_id";
815
+
816
+ // Owning side (profiles → users)
817
+ expect(cleanResult).toContain(
818
+ `"user": one(users, { fields: [profiles.user_id], references: [users.id], relationName: \"${expectedSharedName}\" })`
819
+ );
820
+
821
+ // Inverse side (users → profiles)
822
+ expect(cleanResult).toContain(
823
+ `"profile": one(profiles, { fields: [users.id], references: [profiles.user_id], relationName: \"${expectedSharedName}\" })`
824
+ );
825
+
826
+ // Both must match
827
+ const allNames = extractRelationNames(result);
828
+ const matchingNames = allNames.filter(n => n === expectedSharedName);
829
+ expect(matchingNames).toHaveLength(2);
830
+ });
831
+
832
+ it("should emit different shared names for multiple relations between same tables", async () => {
833
+ const companiesCollection: EntityCollection = {
834
+ slug: "companies",
835
+ table: "companies",
836
+ name: "Companies",
837
+ properties: { name: { type: "string" } },
838
+ relations: [
839
+ {
840
+ relationName: "employees",
841
+ target: () => peopleCollection,
842
+ cardinality: "many",
843
+ direction: "inverse",
844
+ foreignKeyOnTarget: "employer_id"
845
+ },
846
+ {
847
+ relationName: "founders",
848
+ target: () => peopleCollection,
849
+ cardinality: "many",
850
+ direction: "inverse",
851
+ foreignKeyOnTarget: "startup_id"
852
+ }
853
+ ]
854
+ };
855
+
856
+ const peopleCollection: EntityCollection = {
857
+ slug: "people",
858
+ table: "people",
859
+ name: "People",
860
+ properties: {
861
+ name: { type: "string" },
862
+ employer: { type: "relation",
863
+ relationName: "employer" },
864
+ startup: { type: "relation",
865
+ relationName: "startup" }
866
+ },
867
+ relations: [
868
+ {
869
+ relationName: "employer",
870
+ target: () => companiesCollection,
871
+ cardinality: "one",
872
+ direction: "owning",
873
+ localKey: "employer_id"
874
+ },
875
+ {
876
+ relationName: "startup",
877
+ target: () => companiesCollection,
878
+ cardinality: "one",
879
+ direction: "owning",
880
+ localKey: "startup_id"
881
+ }
882
+ ]
883
+ };
884
+
885
+ const result = await generateSchema([companiesCollection, peopleCollection]);
886
+ const allNames = extractRelationNames(result);
887
+
888
+ // Each pair should have a distinct shared name
889
+ const employerNames = allNames.filter(n => n === "people_employer_id");
890
+ const startupNames = allNames.filter(n => n === "people_startup_id");
891
+ expect(employerNames).toHaveLength(2);
892
+ expect(startupNames).toHaveLength(2);
893
+ });
894
+ });
895
+
896
+ /**
897
+ * Regression tests for duplicate relation emission.
898
+ *
899
+ * Bug: resolveCollectionRelations used to add slug/snake_case alias entries
900
+ * for every relation. When the schema generator iterated the dictionary, it
901
+ * emitted multiple one() definitions with the same `relationName`, causing
902
+ * Drizzle ORM to throw:
903
+ * "There are multiple relations with name 'jobs_company_id' in table 'jobs'"
904
+ *
905
+ * Also, property-based entries (e.g. `company_id: { type: "relation", relationName: "company" }`)
906
+ * duplicated explicit relation entries because the deduplication only compared
907
+ * property key vs relation key — not the underlying relationName.
908
+ *
909
+ * This suite covers both scenarios.
910
+ */
911
+ describe("Duplicate relation deduplication regression", () => {
912
+ const cleanSchema = (schema: string) => {
913
+ return schema
914
+ .replace(/\/\/.*$/gm, "")
915
+ .replace(/\/\*[\s\S]*?\*\//g, "")
916
+ .replace(/\n{2,}/g, "\n")
917
+ .replace(/\s+/g, " ")
918
+ .trim();
919
+ };
920
+
921
+ const extractRelationNames = (schema: string): string[] => {
922
+ const matches = schema.match(/relationName:\s*"([^"]+)"/g) ?? [];
923
+ return matches.map(m => m.replace(/relationName:\s*"/, "").replace(/"$/, ""));
924
+ };
925
+
926
+ /**
927
+ * Count how many one() definitions exist for a specific relation key pattern.
928
+ * This matches `"<key>": one(` in the generated schema.
929
+ */
930
+ const countOneEntries = (schema: string, keyPattern: string): number => {
931
+ const regex = new RegExp(`"${keyPattern}":\\s*one\\(`, "g");
932
+ return (schema.match(regex) ?? []).length;
933
+ };
934
+
935
+ it("should emit exactly one one() per FK when explicit relation + property share the same FK", async () => {
936
+ // This models the exact Sustentalent scenario:
937
+ // - Explicit relation: { relationName: "company", localKey: "company_id", ... }
938
+ // - Property: { company_id: { type: "relation", relationName: "company" } }
939
+ // Both reference the same FK `company_id`, but under different keys.
940
+ const companiesCollection: EntityCollection = {
941
+ slug: "companies",
942
+ table: "companies",
943
+ name: "Companies",
944
+ properties: {
945
+ name: { type: "string" }
946
+ },
947
+ relations: [
948
+ {
949
+ relationName: "jobs",
950
+ target: () => jobsCollection,
951
+ cardinality: "many",
952
+ direction: "inverse",
953
+ foreignKeyOnTarget: "company_id"
954
+ }
955
+ ]
956
+ };
957
+
958
+ const jobsCollection: EntityCollection = {
959
+ slug: "jobs",
960
+ table: "jobs",
961
+ name: "Jobs",
962
+ properties: {
963
+ title: { type: "string" },
964
+ // Property referencing the same FK as the explicit relation
965
+ company: {
966
+ type: "relation",
967
+ relationName: "company"
968
+ }
969
+ },
970
+ relations: [
971
+ {
972
+ relationName: "company",
973
+ target: () => companiesCollection,
974
+ cardinality: "one",
975
+ direction: "owning",
976
+ localKey: "company_id"
977
+ }
978
+ ]
979
+ };
980
+
981
+ const result = await generateSchema([companiesCollection, jobsCollection]);
982
+ const cleanResult = cleanSchema(result);
983
+
984
+ // The jobs table should have exactly ONE one() entry for company_id
985
+ const jobsRelationNames = extractRelationNames(result);
986
+ const companyIdRelNames = jobsRelationNames.filter(n => n === "jobs_company_id");
987
+
988
+ // Exactly 2: one on the owning side (jobs), one on the inverse side (companies)
989
+ expect(companyIdRelNames).toHaveLength(2);
990
+
991
+ // There must be no duplicate one() definitions within jobsRelations
992
+ const jobsRelationsBlock = result.match(/export const jobsRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
993
+ const oneEntriesInJobs = (jobsRelationsBlock.match(/:\s*one\(/g) ?? []).length;
994
+ expect(oneEntriesInJobs).toBe(1);
995
+
996
+ // The companies table should have exactly ONE many() entry for jobs
997
+ const companiesRelationsBlock = result.match(/export const companiesRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
998
+ const manyEntriesInCompanies = (companiesRelationsBlock.match(/:\s*many\(/g) ?? []).length;
999
+ expect(manyEntriesInCompanies).toBe(1);
1000
+ });
1001
+
1002
+ it("should not create aliases when relation key contains underscores", async () => {
1003
+ // Verify that resolving a collection with a snake_case relation name
1004
+ // does NOT produce slug-variant alias entries in the generated schema
1005
+ const parentCollection: EntityCollection = {
1006
+ slug: "departments",
1007
+ table: "departments",
1008
+ name: "Departments",
1009
+ properties: {
1010
+ name: { type: "string" }
1011
+ },
1012
+ relations: [
1013
+ {
1014
+ relationName: "team_members",
1015
+ target: () => memberCollection,
1016
+ cardinality: "many",
1017
+ direction: "inverse",
1018
+ foreignKeyOnTarget: "department_id"
1019
+ }
1020
+ ]
1021
+ };
1022
+
1023
+ const memberCollection: EntityCollection = {
1024
+ slug: "team-members",
1025
+ table: "team_members",
1026
+ name: "Team Members",
1027
+ properties: {
1028
+ name: { type: "string" },
1029
+ department: {
1030
+ type: "relation",
1031
+ relationName: "department"
1032
+ }
1033
+ },
1034
+ relations: [
1035
+ {
1036
+ relationName: "department",
1037
+ target: () => parentCollection,
1038
+ cardinality: "one",
1039
+ direction: "owning",
1040
+ localKey: "department_id"
1041
+ }
1042
+ ]
1043
+ };
1044
+
1045
+ const result = await generateSchema([parentCollection, memberCollection]);
1046
+
1047
+ // team_members table should have exactly one one() definition
1048
+ const teamMembersRelBlock = result.match(/export const teamMembersRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
1049
+ const oneEntries = (teamMembersRelBlock.match(/:\s*one\(/g) ?? []).length;
1050
+ expect(oneEntries).toBe(1);
1051
+
1052
+ // No duplicate relation names anywhere
1053
+ const allNames = extractRelationNames(result);
1054
+ const nameCountMap = new Map<string, number>();
1055
+ for (const name of allNames) {
1056
+ nameCountMap.set(name, (nameCountMap.get(name) ?? 0) + 1);
1057
+ }
1058
+ // Every relation name should appear exactly twice (once per side)
1059
+ for (const [name, count] of nameCountMap) {
1060
+ expect(count).toBeLessThanOrEqual(2);
1061
+ }
1062
+ });
1063
+
1064
+ it("should handle multiple different relations to the same target without duplicates", async () => {
1065
+ // Two separate FKs from one table to the same target table
1066
+ const usersCollection: EntityCollection = {
1067
+ slug: "users",
1068
+ table: "users",
1069
+ name: "Users",
1070
+ properties: { name: { type: "string" } },
1071
+ relations: []
1072
+ };
1073
+
1074
+ const messagesCollection: EntityCollection = {
1075
+ slug: "messages",
1076
+ table: "messages",
1077
+ name: "Messages",
1078
+ properties: {
1079
+ content: { type: "string" },
1080
+ sender: { type: "relation",
1081
+ relationName: "sender" },
1082
+ recipient: { type: "relation",
1083
+ relationName: "recipient" }
1084
+ },
1085
+ relations: [
1086
+ {
1087
+ relationName: "sender",
1088
+ target: () => usersCollection,
1089
+ cardinality: "one",
1090
+ direction: "owning",
1091
+ localKey: "sender_id"
1092
+ },
1093
+ {
1094
+ relationName: "recipient",
1095
+ target: () => usersCollection,
1096
+ cardinality: "one",
1097
+ direction: "owning",
1098
+ localKey: "recipient_id"
1099
+ }
1100
+ ]
1101
+ };
1102
+
1103
+ const result = await generateSchema([usersCollection, messagesCollection]);
1104
+
1105
+ // messages table should have exactly TWO one() entries (one per FK)
1106
+ const messagesRelBlock = result.match(/export const messagesRelations[\s\S]*?\}\)\);/)?.[0] ?? "";
1107
+ const oneEntries = (messagesRelBlock.match(/:\s*one\(/g) ?? []).length;
1108
+ expect(oneEntries).toBe(2);
1109
+
1110
+ // The two must have DIFFERENT relationName values
1111
+ const namesInMessages = extractRelationNames(messagesRelBlock);
1112
+ expect(namesInMessages).toHaveLength(2);
1113
+ expect(namesInMessages[0]).not.toBe(namesInMessages[1]);
1114
+ });
1115
+ });