@rebasepro/server-postgresql 0.0.1-canary.4d4fb3e

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 (147) 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 +48 -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 +36 -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 +12 -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 +10635 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/index.umd.js +10643 -0
  28. package/dist/index.umd.js.map +1 -0
  29. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +112 -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 +188 -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 +7 -0
  36. package/dist/server-postgresql/src/data-transformer.d.ts +36 -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 +767 -0
  43. package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
  44. package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
  45. package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
  46. package/dist/server-postgresql/src/services/EntityFetchService.d.ts +195 -0
  47. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
  48. package/dist/server-postgresql/src/services/RelationService.d.ts +92 -0
  49. package/dist/server-postgresql/src/services/entity-helpers.d.ts +24 -0
  50. package/dist/server-postgresql/src/services/entityService.d.ts +102 -0
  51. package/dist/server-postgresql/src/services/index.d.ts +4 -0
  52. package/dist/server-postgresql/src/services/realtimeService.d.ts +186 -0
  53. package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
  54. package/dist/server-postgresql/src/websocket.d.ts +5 -0
  55. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  56. package/dist/types/src/controllers/auth.d.ts +117 -0
  57. package/dist/types/src/controllers/client.d.ts +58 -0
  58. package/dist/types/src/controllers/collection_registry.d.ts +44 -0
  59. package/dist/types/src/controllers/customization_controller.d.ts +54 -0
  60. package/dist/types/src/controllers/data.d.ts +141 -0
  61. package/dist/types/src/controllers/data_driver.d.ts +168 -0
  62. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  63. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  64. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  65. package/dist/types/src/controllers/index.d.ts +17 -0
  66. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  67. package/dist/types/src/controllers/navigation.d.ts +213 -0
  68. package/dist/types/src/controllers/registry.d.ts +51 -0
  69. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  70. package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
  71. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  72. package/dist/types/src/controllers/storage.d.ts +173 -0
  73. package/dist/types/src/index.d.ts +4 -0
  74. package/dist/types/src/rebase_context.d.ts +101 -0
  75. package/dist/types/src/types/backend.d.ts +533 -0
  76. package/dist/types/src/types/builders.d.ts +14 -0
  77. package/dist/types/src/types/chips.d.ts +5 -0
  78. package/dist/types/src/types/collections.d.ts +812 -0
  79. package/dist/types/src/types/data_source.d.ts +64 -0
  80. package/dist/types/src/types/entities.d.ts +145 -0
  81. package/dist/types/src/types/entity_actions.d.ts +98 -0
  82. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  83. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  84. package/dist/types/src/types/entity_overrides.d.ts +9 -0
  85. package/dist/types/src/types/entity_views.d.ts +61 -0
  86. package/dist/types/src/types/export_import.d.ts +21 -0
  87. package/dist/types/src/types/index.d.ts +22 -0
  88. package/dist/types/src/types/locales.d.ts +4 -0
  89. package/dist/types/src/types/modify_collections.d.ts +5 -0
  90. package/dist/types/src/types/plugins.d.ts +225 -0
  91. package/dist/types/src/types/properties.d.ts +1091 -0
  92. package/dist/types/src/types/property_config.d.ts +70 -0
  93. package/dist/types/src/types/relations.d.ts +336 -0
  94. package/dist/types/src/types/slots.d.ts +228 -0
  95. package/dist/types/src/types/translations.d.ts +826 -0
  96. package/dist/types/src/types/user_management_delegate.d.ts +120 -0
  97. package/dist/types/src/types/websockets.d.ts +78 -0
  98. package/dist/types/src/users/index.d.ts +2 -0
  99. package/dist/types/src/users/roles.d.ts +22 -0
  100. package/dist/types/src/users/user.d.ts +46 -0
  101. package/jest-all.log +3128 -0
  102. package/jest.log +49 -0
  103. package/package.json +93 -0
  104. package/src/PostgresBackendDriver.ts +1024 -0
  105. package/src/PostgresBootstrapper.ts +232 -0
  106. package/src/auth/ensure-tables.ts +309 -0
  107. package/src/auth/services.ts +740 -0
  108. package/src/cli.ts +347 -0
  109. package/src/collections/PostgresCollectionRegistry.ts +96 -0
  110. package/src/connection.ts +62 -0
  111. package/src/data-transformer.ts +569 -0
  112. package/src/databasePoolManager.ts +84 -0
  113. package/src/history/HistoryService.ts +257 -0
  114. package/src/history/ensure-history-table.ts +45 -0
  115. package/src/index.ts +13 -0
  116. package/src/interfaces.ts +60 -0
  117. package/src/schema/auth-schema.ts +146 -0
  118. package/src/schema/generate-drizzle-schema-logic.ts +618 -0
  119. package/src/schema/generate-drizzle-schema.ts +151 -0
  120. package/src/services/BranchService.ts +237 -0
  121. package/src/services/EntityFetchService.ts +1447 -0
  122. package/src/services/EntityPersistService.ts +351 -0
  123. package/src/services/RelationService.ts +1012 -0
  124. package/src/services/entity-helpers.ts +121 -0
  125. package/src/services/entityService.ts +209 -0
  126. package/src/services/index.ts +13 -0
  127. package/src/services/realtimeService.ts +1005 -0
  128. package/src/utils/drizzle-conditions.ts +999 -0
  129. package/src/websocket.ts +487 -0
  130. package/test/auth-services.test.ts +569 -0
  131. package/test/branchService.test.ts +357 -0
  132. package/test/drizzle-conditions.test.ts +895 -0
  133. package/test/entityService.errors.test.ts +352 -0
  134. package/test/entityService.relations.test.ts +912 -0
  135. package/test/entityService.subcollection-search.test.ts +516 -0
  136. package/test/entityService.test.ts +977 -0
  137. package/test/generate-drizzle-schema.test.ts +795 -0
  138. package/test/historyService.test.ts +126 -0
  139. package/test/postgresDataDriver.test.ts +556 -0
  140. package/test/realtimeService.test.ts +276 -0
  141. package/test/relations.test.ts +662 -0
  142. package/test_drizzle_mock.js +3 -0
  143. package/test_find_changed.mjs +30 -0
  144. package/test_output.txt +3145 -0
  145. package/tsconfig.json +49 -0
  146. package/tsconfig.prod.json +20 -0
  147. package/vite.config.ts +82 -0
@@ -0,0 +1,912 @@
1
+ import { EntityService } from "../src/services/entityService";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { EntityCollection } from "@rebasepro/types";
4
+ import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
5
+ const collectionRegistry = new PostgresCollectionRegistry();
6
+
7
+ describe("EntityService - Relation Types Tests", () => {
8
+ let entityService: EntityService;
9
+ let db: jest.Mocked<NodePgDatabase<any>>;
10
+
11
+ // Mock tables for different relation scenarios
12
+ const mockOrdersTable = {
13
+ id: { name: "id" },
14
+ customer_id: { name: "customer_id" },
15
+ total: { name: "total" },
16
+ _def: { tableName: "orders" }
17
+ };
18
+
19
+ const mockCustomersTable = {
20
+ id: { name: "id" },
21
+ name: { name: "name" },
22
+ email: { name: "email" },
23
+ customer_id: { name: "customer_id" }, // Add for relations
24
+ _def: { tableName: "customers" }
25
+ };
26
+
27
+ const mockProductsTable = {
28
+ id: { name: "id" },
29
+ name: { name: "name" },
30
+ price: { name: "price" },
31
+ customer_id: { name: "customer_id" }, // Add for relations
32
+ _def: { tableName: "products" }
33
+ };
34
+
35
+ const mockOrderItemsTable = {
36
+ order_id: { name: "order_id" },
37
+ product_id: { name: "product_id" },
38
+ quantity: { name: "quantity" },
39
+ _def: { tableName: "order_items" }
40
+ };
41
+
42
+ const mockUserProfilesTable = {
43
+ id: { name: "id" },
44
+ user_id: { name: "user_id" },
45
+ bio: { name: "bio" },
46
+ _def: { tableName: "user_profiles" }
47
+ };
48
+
49
+ // Collection definitions for testing different relation types
50
+ const customersCollection: EntityCollection = {
51
+ slug: "customers",
52
+ name: "Customers",
53
+ table: "customers",
54
+ properties: {
55
+ id: { type: "number" },
56
+ name: { type: "string" },
57
+ email: { type: "string" },
58
+ orders: { type: "relation", relationName: "orders" },
59
+ profile: { type: "relation", relationName: "profile" }
60
+ },
61
+ relations: [
62
+ {
63
+ relationName: "orders",
64
+ target: () => ordersCollection,
65
+ cardinality: "many",
66
+ direction: "inverse",
67
+ foreignKeyOnTarget: "customer_id"
68
+ },
69
+ {
70
+ relationName: "profile",
71
+ target: () => userProfilesCollection,
72
+ cardinality: "one",
73
+ direction: "inverse",
74
+ foreignKeyOnTarget: "user_id"
75
+ }
76
+ ],
77
+ idField: "id"
78
+ };
79
+
80
+ const ordersCollection: EntityCollection = {
81
+ slug: "orders",
82
+ name: "Orders",
83
+ table: "orders",
84
+ properties: {
85
+ id: { type: "number" },
86
+ total: { type: "number" },
87
+ customer: { type: "relation", relationName: "customer" },
88
+ products: { type: "relation", relationName: "products" }
89
+ },
90
+ relations: [
91
+ {
92
+ relationName: "customer",
93
+ target: () => customersCollection,
94
+ cardinality: "one",
95
+ direction: "owning",
96
+ localKey: "customer_id"
97
+ },
98
+ {
99
+ relationName: "products",
100
+ target: () => productsCollection,
101
+ cardinality: "many",
102
+ direction: "owning",
103
+ through: {
104
+ table: "order_items",
105
+ sourceColumn: "order_id",
106
+ targetColumn: "product_id"
107
+ }
108
+ }
109
+ ],
110
+ idField: "id"
111
+ };
112
+
113
+ const productsCollection: EntityCollection = {
114
+ slug: "products",
115
+ name: "Products",
116
+ table: "products",
117
+ properties: {
118
+ id: { type: "number" },
119
+ name: { type: "string" },
120
+ price: { type: "number" }
121
+ },
122
+ idField: "id"
123
+ };
124
+
125
+ const userProfilesCollection: EntityCollection = {
126
+ slug: "user_profiles",
127
+ name: "User Profiles",
128
+ table: "user_profiles",
129
+ properties: {
130
+ id: { type: "number" },
131
+ bio: { type: "string" },
132
+ user: { type: "relation", relationName: "user" }
133
+ },
134
+ relations: [
135
+ {
136
+ relationName: "user",
137
+ target: () => customersCollection,
138
+ cardinality: "one",
139
+ direction: "owning",
140
+ localKey: "user_id"
141
+ }
142
+ ],
143
+ idField: "id"
144
+ };
145
+
146
+ beforeEach(() => {
147
+ jest.clearAllMocks();
148
+
149
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
150
+ if (path.startsWith("customers")) return customersCollection;
151
+ if (path.startsWith("orders")) return ordersCollection;
152
+ if (path.startsWith("products")) return productsCollection;
153
+ if (path.startsWith("user_profiles")) return userProfilesCollection;
154
+ return undefined;
155
+ });
156
+
157
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
158
+ if (tableName === "customers") return mockCustomersTable as any;
159
+ if (tableName === "orders") return mockOrdersTable as any;
160
+ if (tableName === "products") return mockProductsTable as any;
161
+ if (tableName === "user_profiles") return mockUserProfilesTable as any;
162
+ if (tableName === "order_items") return mockOrderItemsTable as any;
163
+ return undefined;
164
+ });
165
+
166
+ // Create a mock query object that can be awaited
167
+ const mockQuery = {
168
+ then: jest.fn((resolve) => resolve([]))
169
+ };
170
+
171
+ db = {
172
+ select: jest.fn().mockReturnThis(),
173
+ from: jest.fn().mockReturnThis(),
174
+ where: jest.fn().mockReturnThis(),
175
+ $dynamic: jest.fn().mockReturnThis(),
176
+ limit: jest.fn().mockReturnThis(),
177
+ orderBy: jest.fn().mockReturnValue(mockQuery),
178
+ innerJoin: jest.fn().mockReturnThis(),
179
+ insert: jest.fn().mockReturnThis(),
180
+ values: jest.fn().mockReturnThis(),
181
+ returning: jest.fn().mockResolvedValue([]),
182
+ update: jest.fn().mockReturnThis(),
183
+ set: jest.fn().mockReturnThis(),
184
+ delete: jest.fn().mockReturnThis(),
185
+ transaction: jest.fn((callback) => callback(db)),
186
+ then: jest.fn((resolve) => resolve([]))
187
+ } as any;
188
+
189
+ entityService = new EntityService(db, collectionRegistry);
190
+ });
191
+
192
+ describe("One-to-Many Relations (Inverse)", () => {
193
+ it("should fetch related entities using foreign key where clause", async () => {
194
+ const mockOrders = [
195
+ { id: 1, total: 100, customer_id: 1 },
196
+ { id: 2, total: 200, customer_id: 1 }
197
+ ];
198
+ // RelationService.fetchEntitiesUsingJoins ends query chain with where(), not orderBy()
199
+ db.where.mockReturnValue({
200
+ then: (resolve: Function) => resolve(mockOrders),
201
+ limit: jest.fn().mockReturnValue({
202
+ then: (resolve: Function) => resolve(mockOrders)
203
+ })
204
+ });
205
+
206
+ const entities = await entityService.fetchCollection("customers/1/orders", {});
207
+
208
+ expect(entities).toHaveLength(2);
209
+ expect(entities[0].values.total).toBe(100);
210
+ // Should use WHERE clause, not JOIN for simple inverse relations
211
+ expect(db.where).toHaveBeenCalled();
212
+ });
213
+
214
+ it("should handle empty result sets", async () => {
215
+ db.orderBy.mockResolvedValue([]);
216
+
217
+ const entities = await entityService.fetchCollection("customers/999/orders", {});
218
+
219
+ expect(entities).toHaveLength(0);
220
+ });
221
+ });
222
+
223
+ describe("Many-to-One Relations (Owning)", () => {
224
+ it("should serialize owning relation correctly on create", async () => {
225
+ const newOrder = {
226
+ total: 150,
227
+ customer: { id: "1", path: "customers", __type: "relation" }
228
+ };
229
+
230
+ db.returning.mockResolvedValue([{ id: 3 }]);
231
+ db.limit.mockResolvedValue([{
232
+ id: 3,
233
+ total: 150,
234
+ customer_id: 1
235
+ }]);
236
+
237
+ const entity = await entityService.saveEntity("orders", newOrder);
238
+
239
+ expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
240
+ total: 150,
241
+ customer_id: "1"
242
+ }));
243
+ expect(entity.values.customer).toEqual({
244
+ id: "1",
245
+ path: "customers",
246
+ __type: "relation"
247
+ });
248
+ });
249
+
250
+ it("should deserialize owning relation correctly on fetch", async () => {
251
+ const mockOrder = {
252
+ id: 1,
253
+ total: 100,
254
+ customer_id: 5
255
+ };
256
+ db.limit.mockResolvedValue([mockOrder]);
257
+
258
+ const entity = await entityService.fetchEntity("orders", 1);
259
+
260
+ expect(entity?.values.customer).toEqual({
261
+ id: "5",
262
+ path: "customers",
263
+ __type: "relation"
264
+ });
265
+ });
266
+ });
267
+
268
+ describe("Many-to-Many Relations (Through Table)", () => {
269
+ it("should handle many-to-many relations with junction table", async () => {
270
+ const mockProducts = [
271
+ { id: 1, name: "Product 1", price: 10 },
272
+ { id: 2, name: "Product 2", price: 20 }
273
+ ];
274
+ // For many-to-many with through table, the query uses innerJoin and ends with where()
275
+ db.where.mockReturnValue({
276
+ then: (resolve: Function) => resolve(mockProducts),
277
+ limit: jest.fn().mockReturnValue({
278
+ then: (resolve: Function) => resolve(mockProducts)
279
+ })
280
+ });
281
+
282
+ const entities = await entityService.fetchCollection("orders/1/products", {});
283
+
284
+ expect(entities).toHaveLength(2);
285
+ expect(entities[0].values.name).toBe("Product 1");
286
+ // Should use JOIN for many-to-many relations
287
+ expect(db.innerJoin).toHaveBeenCalled();
288
+ });
289
+
290
+ it("should create many-to-many relations correctly", async () => {
291
+ const newOrder = {
292
+ total: 300,
293
+ customer: { id: "1", path: "customers", __type: "relation" }
294
+ };
295
+
296
+ db.returning.mockResolvedValue([{ id: 4 }]);
297
+ db.limit.mockResolvedValue([{
298
+ id: 4,
299
+ total: 300,
300
+ customer_id: 1
301
+ }]);
302
+
303
+ const entity = await entityService.saveEntity("orders", newOrder);
304
+
305
+ expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
306
+ total: 300,
307
+ customer_id: "1"
308
+ }));
309
+ });
310
+ });
311
+
312
+ describe("One-to-One Relations", () => {
313
+ it("should handle one-to-one relations correctly", async () => {
314
+ const mockProfile = [
315
+ { id: 1, bio: "User bio", user_id: 1 }
316
+ ];
317
+ // RelationService ends query chain with where()
318
+ db.where.mockReturnValue({
319
+ then: (resolve: Function) => resolve(mockProfile),
320
+ limit: jest.fn().mockReturnValue({
321
+ then: (resolve: Function) => resolve(mockProfile)
322
+ })
323
+ });
324
+
325
+ const entities = await entityService.fetchCollection("customers/1/profile", {});
326
+
327
+ expect(entities).toHaveLength(1);
328
+ expect(entities[0].values.bio).toBe("User bio");
329
+ });
330
+
331
+ it("should create one-to-one relations correctly", async () => {
332
+ const newProfile = {
333
+ bio: "New user bio",
334
+ user: { id: "1", path: "customers", __type: "relation" }
335
+ };
336
+
337
+ db.returning.mockResolvedValue([{ id: 1 }]);
338
+ db.limit.mockResolvedValue([{
339
+ id: 1,
340
+ bio: "New user bio",
341
+ user_id: 1
342
+ }]);
343
+
344
+ const entity = await entityService.saveEntity("user_profiles", newProfile);
345
+
346
+ expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
347
+ bio: "New user bio",
348
+ user_id: "1"
349
+ }));
350
+ });
351
+ });
352
+
353
+ describe("Relation Validation", () => {
354
+ it("should handle null relations gracefully", async () => {
355
+ const orderWithoutCustomer = {
356
+ total: 150
357
+ };
358
+
359
+ db.returning.mockResolvedValue([{ id: 5 }]);
360
+ // This mock is used by fetchEntity after save which chains .where().limit()
361
+ // NOTE: The mock returns customer_id: null, but due to how the EntityService
362
+ // deserializes owning relations from saved entities, it may still create a relation
363
+ // object. This test verifies that saveEntity works without providing a customer relation.
364
+ db.limit.mockResolvedValue([{
365
+ id: 5,
366
+ total: 150,
367
+ customer_id: null
368
+ }]);
369
+
370
+ const entity = await entityService.saveEntity("orders", orderWithoutCustomer);
371
+
372
+ // Verify the entity was saved with the correct values (no customer_id set)
373
+ expect(db.values).toHaveBeenCalledWith(expect.objectContaining({
374
+ total: 150
375
+ }));
376
+ // Verify the entity was returned successfully
377
+ expect(entity.id).toBe("5");
378
+ expect(entity.values.total).toBe(150);
379
+ });
380
+ });
381
+
382
+ describe("Complex Relation Queries", () => {
383
+ it("should handle deep nested relation paths", async () => {
384
+ const mockProducts = [
385
+ { id: 1, name: "Product 1" }
386
+ ];
387
+ // RelationService ends query chain with where()
388
+ db.where.mockReturnValue({
389
+ then: (resolve: Function) => resolve(mockProducts),
390
+ limit: jest.fn().mockReturnValue({
391
+ then: (resolve: Function) => resolve(mockProducts)
392
+ })
393
+ });
394
+
395
+ const entities = await entityService.fetchCollection("customers/1/orders/1/products", {});
396
+
397
+ expect(entities).toHaveLength(1);
398
+ expect(collectionRegistry.getCollectionByPath).toHaveBeenCalledWith("customers");
399
+ });
400
+
401
+ it("should apply filters on related entities", async () => {
402
+ const mockOrders = [
403
+ { id: 1, total: 100, customer_id: 1 }
404
+ ];
405
+ // RelationService ends query chain with where()
406
+ db.where.mockReturnValue({
407
+ then: (resolve: Function) => resolve(mockOrders),
408
+ limit: jest.fn().mockReturnValue({
409
+ then: (resolve: Function) => resolve(mockOrders)
410
+ })
411
+ });
412
+
413
+ const entities = await entityService.fetchCollection("customers/1/orders", {
414
+ filter: { total: [">=", 100] }
415
+ });
416
+
417
+ expect(entities).toHaveLength(1);
418
+ expect(db.where).toHaveBeenCalled();
419
+ });
420
+
421
+ it("should order related entities correctly", async () => {
422
+ const mockOrders = [
423
+ { id: 2, total: 200, customer_id: 1 },
424
+ { id: 1, total: 100, customer_id: 1 }
425
+ ];
426
+ // RelationService ends query chain with where()
427
+ db.where.mockReturnValue({
428
+ then: (resolve: Function) => resolve(mockOrders),
429
+ limit: jest.fn().mockReturnValue({
430
+ then: (resolve: Function) => resolve(mockOrders)
431
+ })
432
+ });
433
+
434
+ const entities = await entityService.fetchCollection("customers/1/orders", {
435
+ orderBy: "total",
436
+ order: "desc"
437
+ });
438
+
439
+ expect(entities[0].values.total).toBe(200);
440
+ // where() is always called for relation queries
441
+ expect(db.where).toHaveBeenCalled();
442
+ });
443
+ });
444
+
445
+ describe("Relation Updates", () => {
446
+ it("should update owning relations correctly", async () => {
447
+ const updatedOrder = {
448
+ customer: { id: "2", path: "customers", __type: "relation" }
449
+ };
450
+
451
+ db.returning.mockResolvedValue([{ id: 1 }]);
452
+ db.limit.mockResolvedValue([{
453
+ id: 1,
454
+ total: 100,
455
+ customer_id: 2
456
+ }]);
457
+
458
+ const entity = await entityService.saveEntity("orders", updatedOrder, 1);
459
+
460
+ expect(db.set).toHaveBeenCalledWith(expect.objectContaining({
461
+ customer_id: "2"
462
+ }));
463
+ });
464
+
465
+ it("should handle removing relations", async () => {
466
+ const orderWithoutCustomer = {
467
+ // Not setting customer property means it should remain unchanged
468
+ };
469
+
470
+ db.returning.mockResolvedValue([{ id: 1 }]);
471
+ db.limit.mockResolvedValue([{
472
+ id: 1,
473
+ total: 100,
474
+ customer_id: null
475
+ }]);
476
+
477
+ const entity = await entityService.saveEntity("orders", orderWithoutCustomer, 1);
478
+
479
+ // Since no customer property was provided, nothing should be set
480
+ expect(db.set).not.toHaveBeenCalled();
481
+ });
482
+ });
483
+
484
+ describe("Advanced Relation Types - Complete Coverage", () => {
485
+ // Additional mock tables for joinPath scenarios
486
+ const mockAuthorsTable = {
487
+ id: { name: "id" },
488
+ name: { name: "name" },
489
+ _def: { tableName: "authors" }
490
+ };
491
+
492
+ const mockPostsTable = {
493
+ id: { name: "id" },
494
+ title: { name: "title" },
495
+ author_id: { name: "author_id" },
496
+ _def: { tableName: "posts" }
497
+ };
498
+
499
+ const mockCommentsTable = {
500
+ id: { name: "id" },
501
+ content: { name: "content" },
502
+ post_id: { name: "post_id" },
503
+ _def: { tableName: "comments" }
504
+ };
505
+
506
+ const mockTagsTable = {
507
+ id: { name: "id" },
508
+ name: { name: "name" },
509
+ _def: { tableName: "tags" }
510
+ };
511
+
512
+ const mockPostTagsTable = {
513
+ post_id: { name: "post_id" },
514
+ tag_id: { name: "tag_id" },
515
+ _def: { tableName: "post_tags" }
516
+ };
517
+
518
+ // Test Case 1: ONE + owning + localKey (already covered in "Many-to-One Relations")
519
+ // This is the Post -> Author relationship
520
+
521
+ describe("ONE + owning + joinPath", () => {
522
+ const postsWithAuthorViaJoinPath: EntityCollection = {
523
+ slug: "posts_jp",
524
+ name: "Posts with JoinPath",
525
+ table: "posts",
526
+ properties: {
527
+ id: { type: "number" },
528
+ title: { type: "string" },
529
+ authorProfile: { type: "relation", relationName: "authorProfile" }
530
+ },
531
+ relations: [
532
+ {
533
+ relationName: "authorProfile",
534
+ target: () => userProfilesCollection,
535
+ cardinality: "one",
536
+ direction: "owning",
537
+ joinPath: [
538
+ { table: "authors", on: { from: "posts.author_id", to: "authors.id" } },
539
+ { table: "user_profiles", on: { from: "authors.id", to: "user_profiles.user_id" } }
540
+ ]
541
+ }
542
+ ],
543
+ idField: "id"
544
+ };
545
+
546
+ it("should handle one-to-one owning relation via joinPath on write", async () => {
547
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(postsWithAuthorViaJoinPath);
548
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
549
+ if (tableName === "posts") return mockPostsTable as any;
550
+ if (tableName === "authors") return mockAuthorsTable as any;
551
+ if (tableName === "user_profiles") return mockUserProfilesTable as any;
552
+ return undefined;
553
+ });
554
+
555
+ const newPost = {
556
+ title: "Test Post",
557
+ authorProfile: { id: "1", path: "user_profiles", __type: "relation" }
558
+ };
559
+
560
+ db.returning.mockResolvedValue([{ id: 1 }]);
561
+ db.limit.mockResolvedValue([{ id: 1, title: "Test Post", author_id: 1 }]);
562
+
563
+ const entity = await entityService.saveEntity("posts_jp", newPost);
564
+
565
+ // Should have captured the joinPath relation update
566
+ expect(db.transaction).toHaveBeenCalled();
567
+ });
568
+
569
+ it("applies joinPath updates BEFORE main UPDATE on existing entities to prevent stale data corruption", async () => {
570
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(postsWithAuthorViaJoinPath);
571
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
572
+ if (tableName === "posts") return mockPostsTable as any;
573
+ if (tableName === "authors") return mockAuthorsTable as any;
574
+ if (tableName === "user_profiles") return mockUserProfilesTable as any;
575
+ return undefined;
576
+ });
577
+
578
+ const postUpdate = {
579
+ title: "Test Post Updated",
580
+ authorProfile: { id: "1", path: "user_profiles", __type: "relation" }
581
+ };
582
+
583
+ const expectedOps: string[] = [];
584
+
585
+ // Track updateJoinPathOneToOneRelations
586
+ const relationService = entityService.getPersistService().getRelationService();
587
+ const spyJoinPath = jest.spyOn(relationService, "updateJoinPathOneToOneRelations").mockImplementation(async () => {
588
+ expectedOps.push("joinPathUpdate");
589
+ });
590
+
591
+ // Track main entity update
592
+ const originalUpdate = db.update;
593
+ db.update = jest.fn(function(this: any, table) {
594
+ if (table && (table as any)._def?.tableName === "posts") {
595
+ expectedOps.push("mainUpdate");
596
+ }
597
+ return originalUpdate.call(this, table);
598
+ }) as any;
599
+
600
+ db.limit.mockResolvedValue([{ id: 1, title: "Test Post Updated", author_id: 1 }]);
601
+
602
+ try {
603
+ await entityService.saveEntity("posts_jp", postUpdate, 1);
604
+ expect(expectedOps).toEqual(["joinPathUpdate", "mainUpdate"]);
605
+ } finally {
606
+ db.update = originalUpdate;
607
+ spyJoinPath.mockRestore();
608
+ }
609
+ });
610
+ });
611
+
612
+ describe("ONE + inverse + foreignKeyOnTarget (already covered in One-to-One Relations)", () => {
613
+ // This is the Customer <- Profile relationship
614
+ it("should update inverse one-to-one relation", async () => {
615
+ const customerWithProfile = {
616
+ name: "John Doe",
617
+ profile: { id: "1", path: "user_profiles", __type: "relation" }
618
+ };
619
+
620
+ db.returning.mockResolvedValue([{ id: 1 }]);
621
+ db.limit.mockResolvedValue([{ id: 1, name: "John Doe" }]);
622
+
623
+ const entity = await entityService.saveEntity("customers", customerWithProfile);
624
+
625
+ // Should trigger inverse relation update
626
+ expect(db.transaction).toHaveBeenCalled();
627
+ });
628
+ });
629
+
630
+ describe("ONE + inverse + joinPath", () => {
631
+ const authorsWithProfileViaJoinPath: EntityCollection = {
632
+ slug: "authors_jp",
633
+ name: "Authors with Profile via JoinPath",
634
+ table: "authors",
635
+ properties: {
636
+ id: { type: "number" },
637
+ name: { type: "string" },
638
+ profile: { type: "relation", relationName: "profile" }
639
+ },
640
+ relations: [
641
+ {
642
+ relationName: "profile",
643
+ target: () => userProfilesCollection,
644
+ cardinality: "one",
645
+ direction: "inverse",
646
+ joinPath: [
647
+ { table: "customers", on: { from: "authors.id", to: "customers.id" } },
648
+ { table: "user_profiles", on: { from: "customers.id", to: "user_profiles.user_id" } }
649
+ ]
650
+ }
651
+ ],
652
+ idField: "id"
653
+ };
654
+
655
+ it("should handle one-to-one inverse relation via joinPath on write", async () => {
656
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockReturnValue(authorsWithProfileViaJoinPath);
657
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
658
+ if (tableName === "authors") return mockAuthorsTable as any;
659
+ if (tableName === "customers") return mockCustomersTable as any;
660
+ if (tableName === "user_profiles") return mockUserProfilesTable as any;
661
+ return undefined;
662
+ });
663
+
664
+ const newAuthor = {
665
+ name: "Jane Author",
666
+ profile: { id: "2", path: "user_profiles", __type: "relation" }
667
+ };
668
+
669
+ db.returning.mockResolvedValue([{ id: 1 }]);
670
+ db.limit.mockResolvedValue([{ id: 1, name: "Jane Author" }]);
671
+
672
+ const entity = await entityService.saveEntity("authors_jp", newAuthor);
673
+
674
+ // Should trigger inverse joinPath relation update
675
+ expect(db.transaction).toHaveBeenCalled();
676
+ });
677
+ });
678
+
679
+ describe("MANY + owning + through (already covered in Many-to-Many Relations)", () => {
680
+ // This is the Order -> Products via order_items relationship
681
+ it("should update many-to-many owning relation with through", async () => {
682
+ const orderWithProducts = {
683
+ total: 500,
684
+ products: [
685
+ { id: "1", path: "products", __type: "relation" },
686
+ { id: "2", path: "products", __type: "relation" }
687
+ ]
688
+ };
689
+
690
+ db.returning.mockResolvedValue([{ id: 1 }]);
691
+ db.limit.mockResolvedValue([{ id: 1, total: 500 }]);
692
+
693
+ const entity = await entityService.saveEntity("orders", orderWithProducts);
694
+
695
+ // Should manage junction table entries
696
+ expect(db.transaction).toHaveBeenCalled();
697
+ });
698
+ });
699
+
700
+ describe("MANY + owning + joinPath", () => {
701
+ const postsWithTagsViaJoinPath: EntityCollection = {
702
+ slug: "posts_tags_jp",
703
+ name: "Posts with Tags via JoinPath",
704
+ table: "posts",
705
+ properties: {
706
+ id: { type: "number" },
707
+ title: { type: "string" },
708
+ tags: { type: "relation", relationName: "tags" }
709
+ },
710
+ relations: [
711
+ {
712
+ relationName: "tags",
713
+ target: () => ({
714
+ slug: "tags",
715
+ name: "Tags",
716
+ table: "tags",
717
+ properties: { id: { type: "number" }, name: { type: "string" } },
718
+ idField: "id"
719
+ }),
720
+ cardinality: "many",
721
+ direction: "owning",
722
+ joinPath: [
723
+ { table: "post_tags", on: { from: "posts.id", to: "post_tags.post_id" } },
724
+ { table: "tags", on: { from: "post_tags.tag_id", to: "tags.id" } }
725
+ ]
726
+ }
727
+ ],
728
+ idField: "id"
729
+ };
730
+
731
+ it("should handle many-to-many owning relation via joinPath on write", async () => {
732
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
733
+ if (path === "posts_tags_jp" || path === "posts") return postsWithTagsViaJoinPath;
734
+ if (path.startsWith("tags")) return postsWithTagsViaJoinPath.relations![0].target();
735
+ return undefined;
736
+ });
737
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
738
+ if (tableName === "posts") return mockPostsTable as any;
739
+ if (tableName === "post_tags") return mockPostTagsTable as any;
740
+ if (tableName === "tags") return mockTagsTable as any;
741
+ return undefined;
742
+ });
743
+
744
+ const postWithTags = {
745
+ title: "Tagged Post",
746
+ tags: [
747
+ { id: "1", path: "tags", __type: "relation" },
748
+ { id: "2", path: "tags", __type: "relation" }
749
+ ]
750
+ };
751
+
752
+ db.returning.mockResolvedValue([{ id: 1 }]);
753
+ db.limit.mockResolvedValue([{ id: 1, title: "Tagged Post" }]);
754
+ // Mock the fetch for tags relation - return empty array since we're testing write
755
+ db.orderBy.mockResolvedValue([]);
756
+
757
+ const _entity = await entityService.saveEntity("posts_tags_jp", postWithTags);
758
+
759
+ // Should manage junction table via joinPath
760
+ expect(db.transaction).toHaveBeenCalled();
761
+ expect(db.delete).toHaveBeenCalled(); // Should delete old junction entries
762
+ });
763
+ });
764
+
765
+ describe("MANY + inverse + foreignKeyOnTarget (already covered)", () => {
766
+ // This is the Customer <- Orders relationship
767
+ it("should update one-to-many inverse relation", async () => {
768
+ const customerWithOrders = {
769
+ name: "Big Customer",
770
+ orders: [
771
+ { id: "1", path: "orders", __type: "relation" },
772
+ { id: "2", path: "orders", __type: "relation" }
773
+ ]
774
+ };
775
+
776
+ db.returning.mockResolvedValue([{ id: 1 }]);
777
+ db.limit.mockResolvedValue([{ id: 1, name: "Big Customer" }]);
778
+
779
+ const entity = await entityService.saveEntity("customers", customerWithOrders);
780
+
781
+ // Should update FK on target entities
782
+ expect(db.transaction).toHaveBeenCalled();
783
+ });
784
+ });
785
+
786
+ describe("MANY + inverse + through", () => {
787
+ const productsWithOrdersInverse: EntityCollection = {
788
+ slug: "products_orders",
789
+ name: "Products with Orders Inverse",
790
+ table: "products",
791
+ properties: {
792
+ id: { type: "number" },
793
+ name: { type: "string" },
794
+ orders: { type: "relation", relationName: "orders" }
795
+ },
796
+ relations: [
797
+ {
798
+ relationName: "orders",
799
+ target: () => ordersCollection,
800
+ cardinality: "many",
801
+ direction: "inverse",
802
+ inverseRelationName: "products",
803
+ // Add joinPath to satisfy the validation
804
+ joinPath: [
805
+ { table: "order_items", on: { from: "products.id", to: "order_items.product_id" } },
806
+ { table: "orders", on: { from: "order_items.order_id", to: "orders.id" } }
807
+ ]
808
+ }
809
+ ],
810
+ idField: "id"
811
+ };
812
+
813
+ it("should handle many-to-many inverse relation with through", async () => {
814
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
815
+ if (path === "products_orders" || path === "products") return productsWithOrdersInverse;
816
+ if (path.startsWith("orders")) return ordersCollection;
817
+ return undefined;
818
+ });
819
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
820
+ if (tableName === "products") return mockProductsTable as any;
821
+ if (tableName === "orders") return mockOrdersTable as any;
822
+ if (tableName === "order_items") return mockOrderItemsTable as any;
823
+ return undefined;
824
+ });
825
+
826
+ const productWithOrders = {
827
+ name: "Popular Product",
828
+ orders: [
829
+ { id: "1", path: "orders", __type: "relation" },
830
+ { id: "2", path: "orders", __type: "relation" }
831
+ ]
832
+ };
833
+
834
+ db.returning.mockResolvedValue([{ id: 1 }]);
835
+ db.limit.mockResolvedValue([{ id: 1, name: "Popular Product" }]);
836
+ // Mock the fetch for orders relation - return empty array
837
+ db.orderBy.mockResolvedValue([]);
838
+
839
+ const _entity = await entityService.saveEntity("products_orders", productWithOrders);
840
+
841
+ // Should manage junction table from inverse side
842
+ expect(db.transaction).toHaveBeenCalled();
843
+ expect(db.delete).toHaveBeenCalled(); // Should delete old junction entries
844
+ });
845
+ });
846
+
847
+ describe("MANY + inverse + joinPath", () => {
848
+ const tagsWithPostsViaJoinPath: EntityCollection = {
849
+ slug: "tags_posts_jp",
850
+ name: "Tags with Posts via JoinPath",
851
+ table: "tags",
852
+ properties: {
853
+ id: { type: "number" },
854
+ name: { type: "string" },
855
+ posts: { type: "relation", relationName: "posts" }
856
+ },
857
+ relations: [
858
+ {
859
+ relationName: "posts",
860
+ target: () => ({
861
+ slug: "posts",
862
+ name: "Posts",
863
+ table: "posts",
864
+ properties: { id: { type: "number" }, title: { type: "string" } },
865
+ idField: "id"
866
+ }),
867
+ cardinality: "many",
868
+ direction: "inverse",
869
+ joinPath: [
870
+ { table: "post_tags", on: { from: "tags.id", to: "post_tags.tag_id" } },
871
+ { table: "posts", on: { from: "post_tags.post_id", to: "posts.id" } }
872
+ ]
873
+ }
874
+ ],
875
+ idField: "id"
876
+ };
877
+
878
+ it("should handle many-to-many inverse relation via joinPath on write", async () => {
879
+ jest.spyOn(collectionRegistry, "getCollectionByPath").mockImplementation(path => {
880
+ if (path === "tags_posts_jp" || path === "tags") return tagsWithPostsViaJoinPath;
881
+ if (path.startsWith("posts")) return tagsWithPostsViaJoinPath.relations![0].target();
882
+ return undefined;
883
+ });
884
+ jest.spyOn(collectionRegistry, "getTable").mockImplementation(tableName => {
885
+ if (tableName === "tags") return mockTagsTable as any;
886
+ if (tableName === "post_tags") return mockPostTagsTable as any;
887
+ if (tableName === "posts") return mockPostsTable as any;
888
+ return undefined;
889
+ });
890
+
891
+ const tagWithPosts = {
892
+ name: "Popular Tag",
893
+ posts: [
894
+ { id: "1", path: "posts", __type: "relation" },
895
+ { id: "2", path: "posts", __type: "relation" }
896
+ ]
897
+ };
898
+
899
+ db.returning.mockResolvedValue([{ id: 1 }]);
900
+ db.limit.mockResolvedValue([{ id: 1, name: "Popular Tag" }]);
901
+ // Mock the fetch for posts relation - return empty array since we're testing write
902
+ db.orderBy.mockResolvedValue([]);
903
+
904
+ const _entity = await entityService.saveEntity("tags_posts_jp", tagWithPosts);
905
+
906
+ // Should manage junction table via inverse joinPath
907
+ expect(db.transaction).toHaveBeenCalled();
908
+ expect(db.delete).toHaveBeenCalled(); // Should delete old junction entries
909
+ });
910
+ });
911
+ });
912
+ });