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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +106 -0
  3. package/build-errors.txt +37 -0
  4. package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
  5. package/dist/common/src/collections/index.d.ts +1 -0
  6. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  7. package/dist/common/src/index.d.ts +3 -0
  8. package/dist/common/src/util/builders.d.ts +57 -0
  9. package/dist/common/src/util/callbacks.d.ts +6 -0
  10. package/dist/common/src/util/collections.d.ts +11 -0
  11. package/dist/common/src/util/common.d.ts +2 -0
  12. package/dist/common/src/util/conditions.d.ts +26 -0
  13. package/dist/common/src/util/entities.d.ts +58 -0
  14. package/dist/common/src/util/enums.d.ts +3 -0
  15. package/dist/common/src/util/index.d.ts +16 -0
  16. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  17. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  18. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  19. package/dist/common/src/util/paths.d.ts +14 -0
  20. package/dist/common/src/util/permissions.d.ts +5 -0
  21. package/dist/common/src/util/references.d.ts +2 -0
  22. package/dist/common/src/util/relations.d.ts +22 -0
  23. package/dist/common/src/util/resolutions.d.ts +72 -0
  24. package/dist/common/src/util/storage.d.ts +24 -0
  25. package/dist/index.es.js +11298 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +11306 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
  30. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
  31. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
  32. package/dist/server-postgresql/src/auth/services.d.ts +192 -0
  33. package/dist/server-postgresql/src/cli.d.ts +1 -0
  34. package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
  35. package/dist/server-postgresql/src/connection.d.ts +40 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
  37. package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
  38. package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
  39. package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
  40. package/dist/server-postgresql/src/index.d.ts +13 -0
  41. package/dist/server-postgresql/src/interfaces.d.ts +18 -0
  42. package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
  43. package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
  45. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  46. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  47. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
  48. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  49. package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  51. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
  52. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  53. package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
  54. package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
  55. package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
  56. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  57. package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
  58. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  59. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  60. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  61. package/dist/types/src/controllers/auth.d.ts +119 -0
  62. package/dist/types/src/controllers/client.d.ts +170 -0
  63. package/dist/types/src/controllers/collection_registry.d.ts +45 -0
  64. package/dist/types/src/controllers/customization_controller.d.ts +60 -0
  65. package/dist/types/src/controllers/data.d.ts +168 -0
  66. package/dist/types/src/controllers/data_driver.d.ts +160 -0
  67. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  68. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  69. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  70. package/dist/types/src/controllers/email.d.ts +34 -0
  71. package/dist/types/src/controllers/index.d.ts +18 -0
  72. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  73. package/dist/types/src/controllers/navigation.d.ts +213 -0
  74. package/dist/types/src/controllers/registry.d.ts +54 -0
  75. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  76. package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
  77. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  78. package/dist/types/src/controllers/storage.d.ts +171 -0
  79. package/dist/types/src/index.d.ts +4 -0
  80. package/dist/types/src/rebase_context.d.ts +105 -0
  81. package/dist/types/src/types/backend.d.ts +536 -0
  82. package/dist/types/src/types/builders.d.ts +15 -0
  83. package/dist/types/src/types/chips.d.ts +5 -0
  84. package/dist/types/src/types/collections.d.ts +856 -0
  85. package/dist/types/src/types/cron.d.ts +102 -0
  86. package/dist/types/src/types/data_source.d.ts +64 -0
  87. package/dist/types/src/types/entities.d.ts +145 -0
  88. package/dist/types/src/types/entity_actions.d.ts +98 -0
  89. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  90. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  91. package/dist/types/src/types/entity_overrides.d.ts +10 -0
  92. package/dist/types/src/types/entity_views.d.ts +61 -0
  93. package/dist/types/src/types/export_import.d.ts +21 -0
  94. package/dist/types/src/types/index.d.ts +23 -0
  95. package/dist/types/src/types/locales.d.ts +4 -0
  96. package/dist/types/src/types/modify_collections.d.ts +5 -0
  97. package/dist/types/src/types/plugins.d.ts +279 -0
  98. package/dist/types/src/types/properties.d.ts +1176 -0
  99. package/dist/types/src/types/property_config.d.ts +70 -0
  100. package/dist/types/src/types/relations.d.ts +336 -0
  101. package/dist/types/src/types/slots.d.ts +252 -0
  102. package/dist/types/src/types/translations.d.ts +870 -0
  103. package/dist/types/src/types/user_management_delegate.d.ts +121 -0
  104. package/dist/types/src/types/websockets.d.ts +78 -0
  105. package/dist/types/src/users/index.d.ts +2 -0
  106. package/dist/types/src/users/roles.d.ts +22 -0
  107. package/dist/types/src/users/user.d.ts +46 -0
  108. package/drizzle-test/0000_woozy_junta.sql +6 -0
  109. package/drizzle-test/0001_youthful_arachne.sql +1 -0
  110. package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
  111. package/drizzle-test/0003_mean_king_cobra.sql +2 -0
  112. package/drizzle-test/meta/0000_snapshot.json +47 -0
  113. package/drizzle-test/meta/0001_snapshot.json +48 -0
  114. package/drizzle-test/meta/0002_snapshot.json +38 -0
  115. package/drizzle-test/meta/0003_snapshot.json +48 -0
  116. package/drizzle-test/meta/_journal.json +34 -0
  117. package/drizzle-test-out/0000_tan_trauma.sql +6 -0
  118. package/drizzle-test-out/0001_rapid_drax.sql +1 -0
  119. package/drizzle-test-out/meta/0000_snapshot.json +44 -0
  120. package/drizzle-test-out/meta/0001_snapshot.json +54 -0
  121. package/drizzle-test-out/meta/_journal.json +20 -0
  122. package/drizzle.test.config.ts +10 -0
  123. package/jest-all.log +3128 -0
  124. package/jest.log +49 -0
  125. package/package.json +92 -0
  126. package/scratch.ts +41 -0
  127. package/src/PostgresBackendDriver.ts +1008 -0
  128. package/src/PostgresBootstrapper.ts +231 -0
  129. package/src/auth/ensure-tables.ts +381 -0
  130. package/src/auth/services.ts +799 -0
  131. package/src/cli.ts +648 -0
  132. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  133. package/src/connection.ts +84 -0
  134. package/src/data-transformer.ts +608 -0
  135. package/src/databasePoolManager.ts +85 -0
  136. package/src/history/HistoryService.ts +248 -0
  137. package/src/history/ensure-history-table.ts +45 -0
  138. package/src/index.ts +13 -0
  139. package/src/interfaces.ts +60 -0
  140. package/src/schema/auth-schema.ts +169 -0
  141. package/src/schema/doctor-cli.ts +47 -0
  142. package/src/schema/doctor.ts +595 -0
  143. package/src/schema/generate-drizzle-schema-logic.ts +765 -0
  144. package/src/schema/generate-drizzle-schema.ts +151 -0
  145. package/src/schema/introspect-db-logic.ts +542 -0
  146. package/src/schema/introspect-db.ts +211 -0
  147. package/src/schema/test-schema.ts +11 -0
  148. package/src/services/BranchService.ts +237 -0
  149. package/src/services/EntityFetchService.ts +1576 -0
  150. package/src/services/EntityPersistService.ts +349 -0
  151. package/src/services/RelationService.ts +1274 -0
  152. package/src/services/entity-helpers.ts +147 -0
  153. package/src/services/entityService.ts +211 -0
  154. package/src/services/index.ts +13 -0
  155. package/src/services/realtimeService.ts +1034 -0
  156. package/src/utils/drizzle-conditions.ts +1000 -0
  157. package/src/websocket.ts +518 -0
  158. package/test/auth-services.test.ts +661 -0
  159. package/test/batch-many-to-many-regression.test.ts +573 -0
  160. package/test/branchService.test.ts +367 -0
  161. package/test/data-transformer-hardening.test.ts +417 -0
  162. package/test/data-transformer.test.ts +175 -0
  163. package/test/doctor.test.ts +182 -0
  164. package/test/drizzle-conditions.test.ts +895 -0
  165. package/test/entityService.errors.test.ts +367 -0
  166. package/test/entityService.relations.test.ts +1008 -0
  167. package/test/entityService.subcollection-search.test.ts +566 -0
  168. package/test/entityService.test.ts +1035 -0
  169. package/test/generate-drizzle-schema.test.ts +988 -0
  170. package/test/historyService.test.ts +141 -0
  171. package/test/introspect-db-generation.test.ts +436 -0
  172. package/test/introspect-db-utils.test.ts +389 -0
  173. package/test/n-plus-one-regression.test.ts +314 -0
  174. package/test/postgresDataDriver.test.ts +648 -0
  175. package/test/realtimeService.test.ts +307 -0
  176. package/test/relation-pipeline-gaps.test.ts +637 -0
  177. package/test/relations.test.ts +1115 -0
  178. package/test/unmapped-tables-safety.test.ts +345 -0
  179. package/test-drizzle-bug.ts +18 -0
  180. package/test-drizzle-out/0000_cultured_freak.sql +7 -0
  181. package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
  182. package/test-drizzle-out/meta/0000_snapshot.json +55 -0
  183. package/test-drizzle-out/meta/0001_snapshot.json +63 -0
  184. package/test-drizzle-out/meta/_journal.json +20 -0
  185. package/test-drizzle-prompt.sh +2 -0
  186. package/test-policy-prompt.sh +3 -0
  187. package/test-programmatic.ts +30 -0
  188. package/test-programmatic2.ts +59 -0
  189. package/test-schema-no-policies.ts +12 -0
  190. package/test_drizzle_mock.js +3 -0
  191. package/test_find_changed.mjs +32 -0
  192. package/test_hash.js +14 -0
  193. package/test_output.txt +3145 -0
  194. package/tsconfig.json +49 -0
  195. package/tsconfig.prod.json +20 -0
  196. package/vite.config.ts +82 -0
@@ -0,0 +1,1115 @@
1
+ import { EntityCollection, Relation } from "@rebasepro/types";
2
+ import { generateSchema } from "../src/schema/generate-drizzle-schema-logic";
3
+ import { sanitizeRelation } from "@rebasepro/common";
4
+
5
+ const mockAuthorCollection: EntityCollection = {
6
+ name: "Author",
7
+ slug: "author",
8
+ table: "authors",
9
+ properties: {
10
+ id: {
11
+ type: "number"
12
+ },
13
+ name: {
14
+ type: "string"
15
+ }
16
+ },
17
+ idField: "id"
18
+ };
19
+
20
+ const mockPostCollection: EntityCollection = {
21
+ name: "Post",
22
+ slug: "posts",
23
+ table: "posts",
24
+ properties: {
25
+ id: {
26
+ type: "number"
27
+ },
28
+ title: {
29
+ type: "string"
30
+ },
31
+ author_id: {
32
+ type: "number"
33
+ }
34
+ },
35
+ idField: "id"
36
+ };
37
+
38
+ const mockTagCollection: EntityCollection = {
39
+ name: "Tag",
40
+ slug: "tags",
41
+ table: "tags",
42
+ properties: {
43
+ id: {
44
+ type: "string"
45
+ },
46
+ name: {
47
+ type: "string"
48
+ }
49
+ },
50
+ idField: "id"
51
+ };
52
+
53
+ describe("sanitizeRelation", () => {
54
+
55
+ it("should generate a default relationName if not provided", () => {
56
+ const relation: Partial<Relation> = {
57
+ target: () => mockPostCollection,
58
+ cardinality: "one"
59
+ };
60
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
61
+ expect(normalized.relationName).toBe("posts");
62
+ });
63
+
64
+ // --- Belongs-To (cardinality: 'one', direction: 'owning') ---
65
+ describe("Belongs-To (one-to-one/many-to-one)", () => {
66
+ it("should generate default localKey for a simple belongs-to relation", () => {
67
+ const relation: Partial<Relation> = {
68
+ relationName: "post",
69
+ target: () => mockPostCollection,
70
+ cardinality: "one"
71
+ };
72
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
73
+ expect(normalized.localKey).toEqual("post_id");
74
+ expect(normalized.direction).toEqual("owning");
75
+ });
76
+
77
+ it("should use provided `localKey` for a belongs-to relation", () => {
78
+ const relation: Partial<Relation> = {
79
+ relationName: "post",
80
+ target: () => mockPostCollection,
81
+ cardinality: "one",
82
+ localKey: "custom_post_fk"
83
+ };
84
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
85
+ expect(normalized.localKey).toEqual("custom_post_fk");
86
+ expect(normalized.direction).toEqual("owning");
87
+ });
88
+ });
89
+
90
+ // --- Inverse One-to-One (cardinality: 'one', direction: 'inverse') ---
91
+ describe("Inverse One-to-One", () => {
92
+ it("should generate default foreignKeyOnTarget for an inverse one-to-one relation", () => {
93
+ const relation: Partial<Relation> = {
94
+ relationName: "profile",
95
+ target: () => mockPostCollection,
96
+ cardinality: "one",
97
+ direction: "inverse"
98
+ };
99
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
100
+ expect(normalized.foreignKeyOnTarget).toEqual("author_id");
101
+ expect(normalized.direction).toEqual("inverse");
102
+ });
103
+
104
+ it("should use provided `foreignKeyOnTarget` for an inverse one-to-one relation", () => {
105
+ const relation: Partial<Relation> = {
106
+ relationName: "profile",
107
+ target: () => mockPostCollection,
108
+ cardinality: "one",
109
+ direction: "inverse",
110
+ foreignKeyOnTarget: "custom_author_fk"
111
+ };
112
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
113
+ expect(normalized.foreignKeyOnTarget).toEqual("custom_author_fk");
114
+ expect(normalized.direction).toEqual("inverse");
115
+ });
116
+
117
+ it("should work with inverseRelationName property", () => {
118
+ const relation: Partial<Relation> = {
119
+ relationName: "profile",
120
+ target: () => mockPostCollection,
121
+ cardinality: "one",
122
+ direction: "inverse",
123
+ inverseRelationName: "author"
124
+ };
125
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
126
+ expect(normalized.foreignKeyOnTarget).toEqual("author_id");
127
+ expect(normalized.inverseRelationName).toEqual("author");
128
+ expect(normalized.direction).toEqual("inverse");
129
+ });
130
+ });
131
+
132
+ // --- Has-Many (cardinality: 'many', direction: 'inverse') ---
133
+ describe("Has-Many (one-to-many)", () => {
134
+ it("should generate default foreignKeyOnTarget for a simple has-many relation", () => {
135
+ const relation: Partial<Relation> = {
136
+ relationName: "posts",
137
+ target: () => mockPostCollection,
138
+ cardinality: "many",
139
+ direction: "inverse"
140
+ };
141
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
142
+ expect(normalized.foreignKeyOnTarget).toEqual("author_id");
143
+ });
144
+
145
+ it("should use provided `foreignKeyOnTarget` for a has-many relation", () => {
146
+ const relation: Partial<Relation> = {
147
+ relationName: "posts",
148
+ target: () => mockPostCollection,
149
+ cardinality: "many",
150
+ direction: "inverse",
151
+ foreignKeyOnTarget: "writer_id"
152
+ };
153
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
154
+ expect(normalized.foreignKeyOnTarget).toEqual("writer_id");
155
+ });
156
+ });
157
+
158
+ // --- Many-To-Many (cardinality: 'many', through) ---
159
+ describe("Many-To-Many", () => {
160
+ it("should use provided `through` for a many-to-many relation", () => {
161
+ const relation: Partial<Relation> = {
162
+ relationName: "tags",
163
+ target: () => mockTagCollection,
164
+ cardinality: "many",
165
+ through: {
166
+ table: "posts_tags",
167
+ sourceColumn: "post_id",
168
+ targetColumn: "tag_id"
169
+ }
170
+ };
171
+ const normalized = sanitizeRelation(relation, mockPostCollection);
172
+ expect(normalized.through).toEqual({
173
+ table: "posts_tags",
174
+ sourceColumn: "post_id",
175
+ targetColumn: "tag_id"
176
+ });
177
+ expect(normalized.direction).toEqual("owning");
178
+ });
179
+ });
180
+
181
+ // --- Fallback/Default Behavior ---
182
+ describe("Fallback Behavior", () => {
183
+ it("should fallback to has-many for ambiguous 'many' without direction or through", () => {
184
+ const relation: Partial<Relation> = {
185
+ relationName: "posts",
186
+ target: () => mockPostCollection,
187
+ cardinality: "many"
188
+ };
189
+ const normalized = sanitizeRelation(relation, mockAuthorCollection);
190
+ // Should default to has-many (inverse) behavior
191
+ expect(normalized.direction).toEqual("inverse");
192
+ expect(normalized.foreignKeyOnTarget).toEqual("author_id");
193
+ });
194
+
195
+ it("should handle 'one' with 'owning' direction", () => {
196
+ const relation: Partial<Relation> = {
197
+ relationName: "author",
198
+ target: () => mockAuthorCollection,
199
+ cardinality: "one",
200
+ direction: "owning" // Changed from "inverse"
201
+ };
202
+ const normalized = sanitizeRelation(relation, mockPostCollection);
203
+ expect(normalized.localKey).toEqual("author_id");
204
+ });
205
+ });
206
+ });
207
+ /**
208
+ * Comprehensive test suite for complex relation scenarios
209
+ * This tests all the production use cases developers might implement
210
+ */
211
+ describe("Comprehensive Relations Test Suite", () => {
212
+
213
+ const cleanSchema = (schema: string) => {
214
+ return schema
215
+ .replace(/\/\/.*$/gm, "")
216
+ .replace(/\/\*[\s\S]*?\*\//g, "")
217
+ .replace(/\n{2,}/g, "\n")
218
+ .replace(/\s+/g, " ")
219
+ .trim();
220
+ };
221
+
222
+ describe("Many-to-Many Relations", () => {
223
+ it("should handle many-to-many with a through table", async () => {
224
+ const authorsCollection: EntityCollection = {
225
+ slug: "authors",
226
+ table: "authors",
227
+ name: "Authors",
228
+ properties: {
229
+ name: { type: "string" },
230
+ books: { type: "relation",
231
+ relationName: "books" }
232
+ },
233
+ relations: [
234
+ {
235
+ relationName: "books",
236
+ target: () => booksCollection,
237
+ cardinality: "many",
238
+ direction: "owning",
239
+ through: {
240
+ table: "author_books",
241
+ sourceColumn: "author_id",
242
+ targetColumn: "book_id"
243
+ }
244
+ }
245
+ ]
246
+ };
247
+
248
+ const booksCollection: EntityCollection = {
249
+ slug: "books",
250
+ table: "books",
251
+ name: "Books",
252
+ properties: {
253
+ title: { type: "string" }
254
+ }
255
+ };
256
+
257
+ const result = await generateSchema([authorsCollection, booksCollection]);
258
+ const cleanResult = cleanSchema(result);
259
+
260
+ // Should create junction table
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\" }) }));");
265
+ });
266
+
267
+ it("should handle a 4-table many-to-many chain with joinPath", async () => {
268
+ const usersCollection: EntityCollection = {
269
+ slug: "users",
270
+ table: "users",
271
+ name: "Users",
272
+ properties: {
273
+ name: { type: "string" },
274
+ permissions: { type: "relation",
275
+ relationName: "permissions" }
276
+ },
277
+ relations: [
278
+ {
279
+ relationName: "permissions",
280
+ target: () => permissionsCollection,
281
+ cardinality: "many",
282
+ joinPath: [
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" } }
295
+ ]
296
+ }
297
+ ]
298
+ };
299
+
300
+ const rolesCollection: EntityCollection = {
301
+ slug: "roles",
302
+ table: "roles",
303
+ name: "Roles",
304
+ properties: { name: { type: "string" } }
305
+ };
306
+
307
+ const permissionsCollection: EntityCollection = {
308
+ slug: "permissions",
309
+ table: "permissions",
310
+ name: "Permissions",
311
+ properties: { name: { type: "string" } }
312
+ };
313
+
314
+ const result = await generateSchema([usersCollection, rolesCollection, permissionsCollection]);
315
+ const cleanResult = cleanSchema(result);
316
+
317
+ // joinPath relations use existing user-controlled tables - no views or Drizzle relations generated
318
+ expect(cleanResult).not.toContain("view_users_to_permissions");
319
+ expect(cleanResult).not.toContain("viewUsersToPermissions");
320
+
321
+ // Should generate basic table definitions for users, roles, and permissions
322
+ expect(cleanResult).toContain("export const users = pgTable(\"users\"");
323
+ expect(cleanResult).toContain("export const roles = pgTable(\"roles\"");
324
+ expect(cleanResult).toContain("export const permissions = pgTable(\"permissions\"");
325
+
326
+ // No Drizzle relations generated for joinPath relations
327
+ expect(cleanResult).not.toContain("usersRelations");
328
+
329
+ // No SQL view generation comments
330
+ expect(result).not.toContain("CREATE OR REPLACE VIEW");
331
+ expect(result).not.toContain("SQL VIEWS FOR COMPLEX RELATIONS");
332
+ });
333
+ });
334
+
335
+ describe("Owning Relations", () => {
336
+ it("should generate owning one-to-one relations", async () => {
337
+ const authorsCollection: EntityCollection = {
338
+ slug: "authors",
339
+ table: "authors",
340
+ name: "Authors",
341
+ properties: {
342
+ name: { type: "string" },
343
+ profile: { type: "relation",
344
+ relationName: "profile" }
345
+ },
346
+ relations: [
347
+ {
348
+ relationName: "profile",
349
+ target: () => profilesCollection,
350
+ cardinality: "one",
351
+ direction: "inverse",
352
+ inverseRelationName: "author"
353
+ }
354
+ ]
355
+ };
356
+
357
+ const profilesCollection: EntityCollection = {
358
+ slug: "profiles",
359
+ table: "profiles",
360
+ name: "Profiles",
361
+ properties: {
362
+ bio: { type: "string" },
363
+ author: { type: "relation",
364
+ relationName: "author" }
365
+ },
366
+ relations: [
367
+ {
368
+ relationName: "author",
369
+ target: () => authorsCollection,
370
+ cardinality: "one",
371
+ localKey: "author_id"
372
+ }
373
+ ]
374
+ };
375
+
376
+ const result = await generateSchema([authorsCollection, profilesCollection]);
377
+ const cleanResult = cleanSchema(result);
378
+
379
+ // Should create FK on profiles table
380
+ expect(cleanResult).toContain("author_id: varchar(\"author_id\").references(() => authors.id, { onDelete: \"set null\" })");
381
+
382
+ // Should create owning relation on profiles
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\" }) }));");
384
+
385
+ // Should create inverse relation on authors (this was previously missing)
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\" }) }));");
387
+ });
388
+
389
+ it("should generate owning one-to-many relations", async () => {
390
+ const categoriesCollection: EntityCollection = {
391
+ slug: "categories",
392
+ table: "categories",
393
+ name: "Categories",
394
+ properties: {
395
+ name: { type: "string" }
396
+ }
397
+ };
398
+
399
+ const postsCollection: EntityCollection = {
400
+ slug: "posts",
401
+ table: "posts",
402
+ name: "Posts",
403
+ properties: {
404
+ title: { type: "string" },
405
+ category: { type: "relation",
406
+ relationName: "category" }
407
+ },
408
+ relations: [
409
+ {
410
+ relationName: "category",
411
+ target: () => categoriesCollection,
412
+ cardinality: "one",
413
+ localKey: "category_id"
414
+ }
415
+ ]
416
+ };
417
+
418
+ const result = await generateSchema([categoriesCollection, postsCollection]);
419
+ const cleanResult = cleanSchema(result);
420
+
421
+ // Should create FK on posts table
422
+ expect(cleanResult).toContain("category_id: varchar(\"category_id\").references(() => categories.id, { onDelete: \"set null\" })");
423
+ // Should create owning relation on posts
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\" }) }));");
425
+ });
426
+ });
427
+
428
+ describe("Mixed Relation Types", () => {
429
+ it("should handle collections with multiple relations", async () => {
430
+ const authorsCollection: EntityCollection = {
431
+ slug: "authors",
432
+ table: "authors",
433
+ name: "Authors",
434
+ properties: {
435
+ name: { type: "string" },
436
+ publisher: { type: "relation",
437
+ relationName: "publisher" }
438
+ },
439
+ relations: [
440
+ {
441
+ relationName: "publisher",
442
+ target: () => publishersCollection,
443
+ cardinality: "one",
444
+ localKey: "publisher_id"
445
+ }
446
+ ]
447
+ };
448
+
449
+ const publishersCollection: EntityCollection = {
450
+ slug: "publishers",
451
+ table: "publishers",
452
+ name: "Publishers",
453
+ properties: {
454
+ name: { type: "string" }
455
+ }
456
+ };
457
+
458
+ const booksCollection: EntityCollection = {
459
+ slug: "books",
460
+ table: "books",
461
+ name: "Books",
462
+ properties: {
463
+ title: { type: "string" },
464
+ author: { type: "relation",
465
+ relationName: "author" }
466
+ },
467
+ relations: [{
468
+ relationName: "author",
469
+ target: () => authorsCollection,
470
+ cardinality: "one",
471
+ localKey: "author_id"
472
+ }]
473
+ };
474
+
475
+ const result = await generateSchema([authorsCollection, publishersCollection, booksCollection]);
476
+ const cleanResult = cleanSchema(result);
477
+
478
+ // Check owning relation from author to 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\" })");
481
+
482
+ // Check owning relation from book to 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\" })");
485
+ });
486
+ });
487
+
488
+ describe("Complex Multi-Column Relations", () => {
489
+ it("should handle multi-column foreign keys", async () => {
490
+ const ordersCollection: EntityCollection = {
491
+ slug: "orders",
492
+ table: "orders",
493
+ name: "Orders",
494
+ properties: {
495
+ customer_code: { type: "string" },
496
+ region_id: { type: "number",
497
+ validation: { integer: true } },
498
+ customer: { type: "relation",
499
+ relationName: "customer" }
500
+ },
501
+ relations: [
502
+ {
503
+ relationName: "customer",
504
+ target: () => customersCollection,
505
+ cardinality: "many",
506
+ joinPath: [
507
+ { table: "customers",
508
+ on: { from: ["customer_code", "region_id"],
509
+ to: ["code", "region_id"] } }
510
+ ]
511
+ }
512
+ ]
513
+ };
514
+
515
+ const customersCollection: EntityCollection = {
516
+ slug: "customers",
517
+ table: "customers",
518
+ name: "Customers",
519
+ properties: {
520
+ code: { type: "string" },
521
+ region_id: { type: "number",
522
+ validation: { integer: true } },
523
+ name: { type: "string" }
524
+ }
525
+ };
526
+
527
+ const result = await generateSchema([ordersCollection, customersCollection]);
528
+ const cleanResult = cleanSchema(result);
529
+
530
+ // joinPath relations use existing user-controlled tables - no views generated
531
+ expect(cleanResult).not.toContain("view_orders_to_customers");
532
+ expect(cleanResult).not.toContain("viewOrdersToCustomers");
533
+
534
+ // Should generate basic table definitions
535
+ expect(cleanResult).toContain("export const orders = pgTable(\"orders\"");
536
+ expect(cleanResult).toContain("export const customers = pgTable(\"customers\"");
537
+
538
+ // Should include the multi-column properties in the tables
539
+ expect(cleanResult).toContain("customer_code: varchar(\"customer_code\")");
540
+ expect(cleanResult).toContain("region_id: integer(\"region_id\")");
541
+ expect(cleanResult).toContain("code: varchar(\"code\")");
542
+
543
+ // No Drizzle relations generated for joinPath relations
544
+ expect(cleanResult).not.toContain("ordersRelations");
545
+
546
+ // No SQL view generation
547
+ expect(result).not.toContain("CREATE OR REPLACE VIEW");
548
+ expect(result).not.toContain("SQL VIEWS FOR COMPLEX RELATIONS");
549
+ });
550
+ });
551
+
552
+ describe("Edge Cases and Production Scenarios", () => {
553
+ it("should handle self-referencing many-to-many", async () => {
554
+ const usersCollection: EntityCollection = {
555
+ slug: "users",
556
+ table: "users",
557
+ name: "Users",
558
+ properties: {
559
+ name: { type: "string" },
560
+ friends: { type: "relation",
561
+ relationName: "friends" }
562
+ },
563
+ relations: [
564
+ {
565
+ relationName: "friends",
566
+ target: () => usersCollection,
567
+ cardinality: "many",
568
+ direction: "owning",
569
+ through: {
570
+ table: "user_friends",
571
+ sourceColumn: "user_id",
572
+ targetColumn: "friend_id"
573
+ }
574
+ }
575
+ ]
576
+ };
577
+
578
+ const result = await generateSchema([usersCollection]);
579
+ const cleanResult = cleanSchema(result);
580
+
581
+ // Should handle self-referencing relations
582
+ expect(cleanResult).toContain("export const userFriends = pgTable(\"user_friends\"");
583
+ expect(cleanResult).toContain("user_id: varchar(\"user_id\").notNull().references(() => users.id, { onDelete: \"cascade\" })");
584
+ expect(cleanResult).toContain("friend_id: varchar(\"friend_id\").notNull().references(() => users.id, { onDelete: \"cascade\" })");
585
+ });
586
+
587
+ it("should handle mixed ID types in relations", async () => {
588
+ const productsCollection: EntityCollection = {
589
+ slug: "products",
590
+ table: "products",
591
+ name: "Products",
592
+ properties: {
593
+ sku: { type: "string",
594
+ isId: true },
595
+ name: { type: "string" },
596
+ categories: { type: "relation",
597
+ relationName: "categories" }
598
+ },
599
+ relations: [
600
+ {
601
+ relationName: "categories",
602
+ target: () => categoriesCollection,
603
+ cardinality: "many",
604
+ direction: "owning",
605
+ through: {
606
+ table: "product_categories",
607
+ sourceColumn: "product_sku",
608
+ targetColumn: "category_id"
609
+ }
610
+ }
611
+ ]
612
+ };
613
+
614
+ const categoriesCollection: EntityCollection = {
615
+ slug: "categories",
616
+ table: "categories",
617
+ name: "Categories",
618
+ properties: {
619
+ name: { type: "string" }
620
+ }
621
+ };
622
+
623
+ const result = await generateSchema([productsCollection, categoriesCollection]);
624
+ const cleanResult = cleanSchema(result);
625
+
626
+ // The primary key should be sku
627
+ expect(cleanResult).toContain("sku: varchar(\"sku\").primaryKey()");
628
+ expect(cleanResult).not.toContain("id: serial(\"id\").primaryKey()");
629
+ expect(cleanResult).toContain("product_sku: varchar(\"product_sku\").notNull().references(() => products.sku, { onDelete: \"cascade\" })");
630
+ expect(cleanResult).toContain("category_id: varchar(\"category_id\").notNull().references(() => categories.id, { onDelete: \"cascade\" })");
631
+ });
632
+
633
+ it("should handle circular references", async () => {
634
+ const aCollection: EntityCollection = {
635
+ slug: "a_entities",
636
+ table: "a_entities",
637
+ name: "A Entities",
638
+ properties: {
639
+ name: { type: "string" },
640
+ b_entities: { type: "relation",
641
+ relationName: "b_entities" }
642
+ },
643
+ relations: [
644
+ {
645
+ relationName: "b_entities",
646
+ target: () => bCollection,
647
+ cardinality: "many",
648
+ direction: "inverse",
649
+ foreignKeyOnTarget: "a_entity_id"
650
+ }
651
+ ]
652
+ };
653
+
654
+ const bCollection: EntityCollection = {
655
+ slug: "b_entities",
656
+ table: "b_entities",
657
+ name: "B Entities",
658
+ properties: {
659
+ name: { type: "string" },
660
+ a_entity: { type: "relation",
661
+ relationName: "a_entity" }
662
+ },
663
+ relations: [
664
+ {
665
+ relationName: "a_entity",
666
+ target: () => aCollection,
667
+ cardinality: "one",
668
+ direction: "owning",
669
+ localKey: "a_entity_id"
670
+ }
671
+ ]
672
+ };
673
+
674
+ const result = await generateSchema([aCollection, bCollection]);
675
+ const cleanResult = cleanSchema(result);
676
+
677
+ // Should handle circular references without infinite loops
678
+ // The 'owning' relation on bCollection should correctly generate the FK
679
+ expect(cleanResult).toContain("export const aEntities = pgTable(\"a_entities\"");
680
+ expect(cleanResult).toContain("export const bEntities = pgTable(\"b_entities\"");
681
+ expect(cleanResult).toContain("a_entity_id: varchar(\"a_entity_id\").references(() => aEntities.id, { onDelete: \"set null\" })");
682
+ // Check that both drizzle relations are generated
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\" })");
685
+ });
686
+ });
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
+ });