@fragno-dev/db 0.1.13 → 0.1.15

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 (178) hide show
  1. package/.turbo/turbo-build.log +179 -132
  2. package/CHANGELOG.md +30 -0
  3. package/dist/adapters/adapters.d.ts +27 -1
  4. package/dist/adapters/adapters.d.ts.map +1 -1
  5. package/dist/adapters/adapters.js.map +1 -1
  6. package/dist/adapters/drizzle/drizzle-adapter.d.ts +5 -1
  7. package/dist/adapters/drizzle/drizzle-adapter.d.ts.map +1 -1
  8. package/dist/adapters/drizzle/drizzle-adapter.js +15 -3
  9. package/dist/adapters/drizzle/drizzle-adapter.js.map +1 -1
  10. package/dist/adapters/drizzle/drizzle-query.js +7 -5
  11. package/dist/adapters/drizzle/drizzle-query.js.map +1 -1
  12. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts +0 -1
  13. package/dist/adapters/drizzle/drizzle-uow-compiler.d.ts.map +1 -1
  14. package/dist/adapters/drizzle/drizzle-uow-compiler.js +76 -44
  15. package/dist/adapters/drizzle/drizzle-uow-compiler.js.map +1 -1
  16. package/dist/adapters/drizzle/drizzle-uow-decoder.js +23 -16
  17. package/dist/adapters/drizzle/drizzle-uow-decoder.js.map +1 -1
  18. package/dist/adapters/drizzle/drizzle-uow-executor.js +18 -7
  19. package/dist/adapters/drizzle/drizzle-uow-executor.js.map +1 -1
  20. package/dist/adapters/drizzle/generate.d.ts +4 -1
  21. package/dist/adapters/drizzle/generate.d.ts.map +1 -1
  22. package/dist/adapters/drizzle/generate.js +11 -18
  23. package/dist/adapters/drizzle/generate.js.map +1 -1
  24. package/dist/adapters/drizzle/shared.d.ts +14 -1
  25. package/dist/adapters/drizzle/shared.d.ts.map +1 -0
  26. package/dist/adapters/kysely/kysely-adapter.d.ts +5 -1
  27. package/dist/adapters/kysely/kysely-adapter.d.ts.map +1 -1
  28. package/dist/adapters/kysely/kysely-adapter.js +14 -3
  29. package/dist/adapters/kysely/kysely-adapter.js.map +1 -1
  30. package/dist/adapters/kysely/kysely-query-builder.js +1 -1
  31. package/dist/adapters/kysely/kysely-query-compiler.js +3 -2
  32. package/dist/adapters/kysely/kysely-query-compiler.js.map +1 -1
  33. package/dist/adapters/kysely/kysely-query.d.ts +1 -0
  34. package/dist/adapters/kysely/kysely-query.d.ts.map +1 -1
  35. package/dist/adapters/kysely/kysely-query.js +28 -19
  36. package/dist/adapters/kysely/kysely-query.js.map +1 -1
  37. package/dist/adapters/kysely/kysely-shared.d.ts +14 -0
  38. package/dist/adapters/kysely/kysely-shared.d.ts.map +1 -0
  39. package/dist/adapters/kysely/kysely-shared.js +16 -1
  40. package/dist/adapters/kysely/kysely-shared.js.map +1 -1
  41. package/dist/adapters/kysely/kysely-uow-compiler.js +68 -16
  42. package/dist/adapters/kysely/kysely-uow-compiler.js.map +1 -1
  43. package/dist/adapters/kysely/kysely-uow-executor.js +8 -4
  44. package/dist/adapters/kysely/kysely-uow-executor.js.map +1 -1
  45. package/dist/adapters/kysely/migration/execute-base.js +1 -1
  46. package/dist/adapters/kysely/migration/execute-base.js.map +1 -1
  47. package/dist/db-fragment-definition-builder.d.ts +152 -0
  48. package/dist/db-fragment-definition-builder.d.ts.map +1 -0
  49. package/dist/db-fragment-definition-builder.js +137 -0
  50. package/dist/db-fragment-definition-builder.js.map +1 -0
  51. package/dist/fragments/internal-fragment.d.ts +19 -0
  52. package/dist/fragments/internal-fragment.d.ts.map +1 -0
  53. package/dist/fragments/internal-fragment.js +39 -0
  54. package/dist/fragments/internal-fragment.js.map +1 -0
  55. package/dist/migration-engine/generation-engine.d.ts.map +1 -1
  56. package/dist/migration-engine/generation-engine.js +35 -15
  57. package/dist/migration-engine/generation-engine.js.map +1 -1
  58. package/dist/mod.d.ts +8 -18
  59. package/dist/mod.d.ts.map +1 -1
  60. package/dist/mod.js +7 -34
  61. package/dist/mod.js.map +1 -1
  62. package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js +165 -0
  63. package/dist/node_modules/.pnpm/rou3@0.7.8/node_modules/rou3/dist/index.js.map +1 -0
  64. package/dist/packages/fragno/dist/api/bind-services.js +20 -0
  65. package/dist/packages/fragno/dist/api/bind-services.js.map +1 -0
  66. package/dist/packages/fragno/dist/api/error.js +48 -0
  67. package/dist/packages/fragno/dist/api/error.js.map +1 -0
  68. package/dist/packages/fragno/dist/api/fragment-definition-builder.js +320 -0
  69. package/dist/packages/fragno/dist/api/fragment-definition-builder.js.map +1 -0
  70. package/dist/packages/fragno/dist/api/fragment-instantiator.js +487 -0
  71. package/dist/packages/fragno/dist/api/fragment-instantiator.js.map +1 -0
  72. package/dist/packages/fragno/dist/api/fragno-response.js +73 -0
  73. package/dist/packages/fragno/dist/api/fragno-response.js.map +1 -0
  74. package/dist/packages/fragno/dist/api/internal/response-stream.js +81 -0
  75. package/dist/packages/fragno/dist/api/internal/response-stream.js.map +1 -0
  76. package/dist/packages/fragno/dist/api/internal/route.js +10 -0
  77. package/dist/packages/fragno/dist/api/internal/route.js.map +1 -0
  78. package/dist/packages/fragno/dist/api/mutable-request-state.js +97 -0
  79. package/dist/packages/fragno/dist/api/mutable-request-state.js.map +1 -0
  80. package/dist/packages/fragno/dist/api/request-context-storage.js +43 -0
  81. package/dist/packages/fragno/dist/api/request-context-storage.js.map +1 -0
  82. package/dist/packages/fragno/dist/api/request-input-context.js +118 -0
  83. package/dist/packages/fragno/dist/api/request-input-context.js.map +1 -0
  84. package/dist/packages/fragno/dist/api/request-middleware.js +83 -0
  85. package/dist/packages/fragno/dist/api/request-middleware.js.map +1 -0
  86. package/dist/packages/fragno/dist/api/request-output-context.js +119 -0
  87. package/dist/packages/fragno/dist/api/request-output-context.js.map +1 -0
  88. package/dist/packages/fragno/dist/api/route.js +17 -0
  89. package/dist/packages/fragno/dist/api/route.js.map +1 -0
  90. package/dist/packages/fragno/dist/internal/symbols.js +10 -0
  91. package/dist/packages/fragno/dist/internal/symbols.js.map +1 -0
  92. package/dist/query/cursor.d.ts +10 -2
  93. package/dist/query/cursor.d.ts.map +1 -1
  94. package/dist/query/cursor.js +11 -4
  95. package/dist/query/cursor.js.map +1 -1
  96. package/dist/query/execute-unit-of-work.d.ts +123 -0
  97. package/dist/query/execute-unit-of-work.d.ts.map +1 -0
  98. package/dist/query/execute-unit-of-work.js +184 -0
  99. package/dist/query/execute-unit-of-work.js.map +1 -0
  100. package/dist/query/query.d.ts +3 -3
  101. package/dist/query/query.d.ts.map +1 -1
  102. package/dist/query/result-transform.js +4 -2
  103. package/dist/query/result-transform.js.map +1 -1
  104. package/dist/query/retry-policy.d.ts +88 -0
  105. package/dist/query/retry-policy.d.ts.map +1 -0
  106. package/dist/query/retry-policy.js +61 -0
  107. package/dist/query/retry-policy.js.map +1 -0
  108. package/dist/query/unit-of-work.d.ts +171 -32
  109. package/dist/query/unit-of-work.d.ts.map +1 -1
  110. package/dist/query/unit-of-work.js +530 -133
  111. package/dist/query/unit-of-work.js.map +1 -1
  112. package/dist/schema/serialize.js +12 -7
  113. package/dist/schema/serialize.js.map +1 -1
  114. package/dist/with-database.d.ts +28 -0
  115. package/dist/with-database.d.ts.map +1 -0
  116. package/dist/with-database.js +34 -0
  117. package/dist/with-database.js.map +1 -0
  118. package/package.json +10 -3
  119. package/src/adapters/adapters.ts +30 -0
  120. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +86 -17
  121. package/src/adapters/drizzle/drizzle-adapter-sqlite.test.ts +291 -7
  122. package/src/adapters/drizzle/drizzle-adapter.test.ts +3 -51
  123. package/src/adapters/drizzle/drizzle-adapter.ts +35 -7
  124. package/src/adapters/drizzle/drizzle-query.ts +25 -15
  125. package/src/adapters/drizzle/drizzle-uow-compiler-mysql.test.ts +1442 -0
  126. package/src/adapters/drizzle/drizzle-uow-compiler-sqlite.test.ts +1414 -0
  127. package/src/adapters/drizzle/drizzle-uow-compiler.test.ts +78 -61
  128. package/src/adapters/drizzle/drizzle-uow-compiler.ts +123 -42
  129. package/src/adapters/drizzle/drizzle-uow-decoder.ts +34 -27
  130. package/src/adapters/drizzle/drizzle-uow-executor.ts +41 -8
  131. package/src/adapters/drizzle/generate.test.ts +102 -269
  132. package/src/adapters/drizzle/generate.ts +12 -30
  133. package/src/adapters/drizzle/test-utils.ts +36 -5
  134. package/src/adapters/kysely/kysely-adapter-pglite.test.ts +66 -22
  135. package/src/adapters/kysely/kysely-adapter-sqlite.test.ts +156 -0
  136. package/src/adapters/kysely/kysely-adapter.ts +25 -2
  137. package/src/adapters/kysely/kysely-query-compiler.ts +3 -8
  138. package/src/adapters/kysely/kysely-query.ts +57 -37
  139. package/src/adapters/kysely/kysely-shared.ts +34 -0
  140. package/src/adapters/kysely/kysely-uow-compiler.test.ts +62 -74
  141. package/src/adapters/kysely/kysely-uow-compiler.ts +92 -24
  142. package/src/adapters/kysely/kysely-uow-executor.ts +26 -7
  143. package/src/adapters/kysely/kysely-uow-joins.test.ts +33 -50
  144. package/src/adapters/kysely/migration/execute-base.ts +1 -1
  145. package/src/db-fragment-definition-builder.test.ts +887 -0
  146. package/src/db-fragment-definition-builder.ts +506 -0
  147. package/src/db-fragment-instantiator.test.ts +467 -0
  148. package/src/db-fragment-integration.test.ts +408 -0
  149. package/src/fragments/internal-fragment.test.ts +160 -0
  150. package/src/fragments/internal-fragment.ts +85 -0
  151. package/src/migration-engine/generation-engine.test.ts +58 -15
  152. package/src/migration-engine/generation-engine.ts +78 -25
  153. package/src/mod.ts +35 -43
  154. package/src/query/cursor.test.ts +119 -0
  155. package/src/query/cursor.ts +17 -4
  156. package/src/query/execute-unit-of-work.test.ts +1310 -0
  157. package/src/query/execute-unit-of-work.ts +463 -0
  158. package/src/query/query.ts +4 -4
  159. package/src/query/result-transform.test.ts +129 -0
  160. package/src/query/result-transform.ts +4 -1
  161. package/src/query/retry-policy.test.ts +217 -0
  162. package/src/query/retry-policy.ts +141 -0
  163. package/src/query/unit-of-work-coordinator.test.ts +833 -0
  164. package/src/query/unit-of-work-types.test.ts +15 -2
  165. package/src/query/unit-of-work.test.ts +878 -200
  166. package/src/query/unit-of-work.ts +963 -321
  167. package/src/schema/serialize.ts +22 -11
  168. package/src/with-database.ts +140 -0
  169. package/tsdown.config.ts +1 -0
  170. package/dist/fragment.d.ts +0 -54
  171. package/dist/fragment.d.ts.map +0 -1
  172. package/dist/fragment.js +0 -92
  173. package/dist/fragment.js.map +0 -1
  174. package/dist/shared/settings-schema.js +0 -36
  175. package/dist/shared/settings-schema.js.map +0 -1
  176. package/src/fragment.test.ts +0 -341
  177. package/src/fragment.ts +0 -198
  178. package/src/shared/settings-schema.ts +0 -61
@@ -0,0 +1,833 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { schema, idColumn, type FragnoId, referenceColumn } from "../schema/create";
3
+ import {
4
+ type UOWCompiler,
5
+ type UOWDecoder,
6
+ createUnitOfWork,
7
+ type RetrievalOperation,
8
+ type MutationOperation,
9
+ type CompiledMutation,
10
+ } from "./unit-of-work";
11
+ import type { AnySchema } from "../schema/create";
12
+
13
+ // Mock compiler that tracks operations
14
+ function createMockCompiler(): UOWCompiler<string> {
15
+ return {
16
+ compileRetrievalOperation: (op: RetrievalOperation<AnySchema>) => {
17
+ return `SELECT from ${op.table.ormName}`;
18
+ },
19
+ compileMutationOperation: (op: MutationOperation<AnySchema>) => {
20
+ return {
21
+ query: `${op.type.toUpperCase()} ${op.table}`,
22
+ expectedAffectedRows: op.type === "create" ? null : op.type === "check" ? null : 1,
23
+ expectedReturnedRows: op.type === "check" ? 1 : null,
24
+ };
25
+ },
26
+ };
27
+ }
28
+
29
+ // Mock executor that tracks execution
30
+ function createMockExecutor() {
31
+ const executionLog: string[] = [];
32
+
33
+ return {
34
+ executeRetrievalPhase: async (queries: string[]) => {
35
+ executionLog.push(`RETRIEVAL: ${queries.length} queries`);
36
+ // Return mock results for each query
37
+ return queries.map(() => [{ id: "mock-id", name: "Mock User" }]);
38
+ },
39
+ executeMutationPhase: async (mutations: CompiledMutation<string>[]) => {
40
+ executionLog.push(`MUTATION: ${mutations.length} mutations`);
41
+ return {
42
+ success: true,
43
+ createdInternalIds: mutations.map(() => BigInt(Math.floor(Math.random() * 1000))),
44
+ };
45
+ },
46
+ getLog: () => executionLog,
47
+ clearLog: () => {
48
+ executionLog.length = 0;
49
+ },
50
+ };
51
+ }
52
+
53
+ function createMockDecoder(): UOWDecoder {
54
+ return (rawResults, operations) => {
55
+ if (rawResults.length !== operations.length) {
56
+ throw new Error("rawResults and operations must have the same length");
57
+ }
58
+ return rawResults;
59
+ };
60
+ }
61
+
62
+ describe("UOW Coordinator - Parent-Child Execution", () => {
63
+ it("should allow child UOWs to add operations and parent to execute them", async () => {
64
+ const testSchema = schema((s) =>
65
+ s.addTable("users", (t) =>
66
+ t.addColumn("id", idColumn()).addColumn("name", "string").addColumn("email", "string"),
67
+ ),
68
+ );
69
+
70
+ const executor = createMockExecutor();
71
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
72
+
73
+ // Simulate service method 1: adds retrieval operation via child UOW
74
+ const serviceMethod1 = () => {
75
+ const childUow = parentUow.restrict();
76
+ childUow.forSchema(testSchema).find("users", (b) => b.whereIndex("primary"));
77
+ };
78
+
79
+ // Simulate service method 2: adds mutation operation via child UOW
80
+ const serviceMethod2 = () => {
81
+ const childUow = parentUow.restrict();
82
+ childUow.forSchema(testSchema).create("users", { name: "Alice", email: "alice@example.com" });
83
+ };
84
+
85
+ // Call both service methods
86
+ serviceMethod1();
87
+ serviceMethod2();
88
+
89
+ // Parent should see both operations
90
+ expect(parentUow.getRetrievalOperations()).toHaveLength(1);
91
+ expect(parentUow.getMutationOperations()).toHaveLength(1);
92
+
93
+ // Execute retrieval phase
94
+ const results = await parentUow.executeRetrieve();
95
+ expect(results).toHaveLength(1);
96
+
97
+ // Execute mutation phase
98
+ const mutationResult = await parentUow.executeMutations();
99
+ expect(mutationResult.success).toBe(true);
100
+
101
+ // Verify execution happened
102
+ const log = executor.getLog();
103
+ expect(log).toEqual(["RETRIEVAL: 1 queries", "MUTATION: 1 mutations"]);
104
+ });
105
+
106
+ it("should handle nested service calls that await phase promises without deadlock", async () => {
107
+ const testSchema = schema((s) =>
108
+ s
109
+ .addTable("users", (t) =>
110
+ t.addColumn("id", idColumn()).addColumn("name", "string").addColumn("email", "string"),
111
+ )
112
+ .addTable("posts", (t) =>
113
+ t
114
+ .addColumn("id", idColumn())
115
+ .addColumn("userId", "string")
116
+ .addColumn("title", "string")
117
+ .addColumn("content", "string")
118
+ .createIndex("idx_user", ["userId"]),
119
+ ),
120
+ );
121
+
122
+ const executor = createMockExecutor();
123
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
124
+
125
+ // Service A: Get user by ID, awaits retrieval phase
126
+ const getUserById = async (userId: string) => {
127
+ const childUow = parentUow.restrict();
128
+ const typedUow = childUow
129
+ .forSchema(testSchema)
130
+ .find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId)));
131
+
132
+ // Await retrieval phase - should not deadlock!
133
+ const [users] = await typedUow.retrievalPhase;
134
+ return users?.[0] ?? null;
135
+ };
136
+
137
+ // Service B: Get posts by user ID, awaits retrieval phase
138
+ const getPostsByUserId = async (userId: string) => {
139
+ const childUow = parentUow.restrict();
140
+ const typedUow = childUow
141
+ .forSchema(testSchema)
142
+ .find("posts", (b) => b.whereIndex("idx_user", (eb) => eb("userId", "=", userId)));
143
+
144
+ // Await retrieval phase - should not deadlock!
145
+ const [posts] = await typedUow.retrievalPhase;
146
+ return posts;
147
+ };
148
+
149
+ // Handler: Orchestrates multiple service calls that each await phase promises
150
+ const handler = async () => {
151
+ // Both services add retrieval operations and return promises that await retrievalPhase
152
+ const userPromise = getUserById("user-123");
153
+ const postsPromise = getPostsByUserId("user-123");
154
+
155
+ // Execute retrieval phase - this should resolve both service promises
156
+ await parentUow.executeRetrieve();
157
+
158
+ // Now we can await the service results
159
+ const user = await userPromise;
160
+ const posts = await postsPromise;
161
+
162
+ return { user, posts };
163
+ };
164
+
165
+ // Execute handler
166
+ const result = await handler();
167
+
168
+ // Verify results
169
+ expect(result.user).toEqual({ id: "mock-id", name: "Mock User" });
170
+ expect(result.posts).toEqual([{ id: "mock-id", name: "Mock User" }]);
171
+
172
+ // Verify both retrieval operations were registered
173
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
174
+
175
+ // Verify execution happened
176
+ const log = executor.getLog();
177
+ expect(log).toEqual(["RETRIEVAL: 2 queries"]);
178
+ });
179
+
180
+ it("should handle retrieval-to-mutation flow with service composition", async () => {
181
+ const testSchema = schema((s) =>
182
+ s
183
+ .addTable("users", (t) =>
184
+ t
185
+ .addColumn("id", idColumn())
186
+ .addColumn("name", "string")
187
+ .addColumn("email", "string")
188
+ .addColumn("status", "string"),
189
+ )
190
+ .addTable("orders", (t) =>
191
+ t
192
+ .addColumn("id", idColumn())
193
+ .addColumn("userId", "string")
194
+ .addColumn("total", "integer")
195
+ .addColumn("status", "string")
196
+ .createIndex("idx_user", ["userId"]),
197
+ )
198
+ .addTable("payments", (t) =>
199
+ t
200
+ .addColumn("id", idColumn())
201
+ .addColumn("orderId", "string")
202
+ .addColumn("amount", "integer")
203
+ .createIndex("idx_order", ["orderId"]),
204
+ ),
205
+ );
206
+
207
+ const executor = createMockExecutor();
208
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
209
+
210
+ // Service A: Check if user exists
211
+ const validateUser = async (userId: string) => {
212
+ const childUow = parentUow.restrict();
213
+ const typedUow = childUow
214
+ .forSchema(testSchema)
215
+ .find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId)));
216
+
217
+ const [users] = await typedUow.retrievalPhase;
218
+ const user = users?.[0];
219
+
220
+ if (!user) {
221
+ throw new Error("User not found");
222
+ }
223
+
224
+ return user;
225
+ };
226
+
227
+ // Service B: Get user's pending orders
228
+ const getPendingOrders = async (userId: string) => {
229
+ const childUow = parentUow.restrict();
230
+ const typedUow = childUow
231
+ .forSchema(testSchema)
232
+ .find("orders", (b) => b.whereIndex("idx_user", (eb) => eb("userId", "=", userId)));
233
+
234
+ const [orders] = await typedUow.retrievalPhase;
235
+ return orders;
236
+ };
237
+
238
+ // Service C: Create order and payment (uses validation results)
239
+ const createOrderWithPayment = (userId: string, amount: number) => {
240
+ const childUow = parentUow.restrict();
241
+ const typedUow = childUow.forSchema(testSchema);
242
+
243
+ // Create order
244
+ const orderId = typedUow.create("orders", {
245
+ userId,
246
+ total: amount,
247
+ status: "pending",
248
+ });
249
+
250
+ // Create payment for the order
251
+ typedUow.create("payments", {
252
+ orderId: orderId.externalId,
253
+ amount,
254
+ });
255
+
256
+ // Return the ID immediately - don't await mutation phase here
257
+ // The handler will execute the mutation phase
258
+ return orderId;
259
+ };
260
+
261
+ // Handler: Orchestrates validation, retrieval, and mutations
262
+ // Pattern: validate → retrieve data → use data to drive mutations
263
+ const handler = async () => {
264
+ // Phase 1: Retrieval - validate user and get existing orders
265
+ const userPromise = validateUser("user-123");
266
+ const ordersPromise = getPendingOrders("user-123");
267
+
268
+ // Execute retrieval phase - resolves all service promises
269
+ await parentUow.executeRetrieve();
270
+
271
+ const user = await userPromise;
272
+ const existingOrders = await ordersPromise;
273
+
274
+ // Business logic: Only allow order if user has < 5 pending orders
275
+ if (existingOrders.length >= 5) {
276
+ throw new Error("Too many pending orders");
277
+ }
278
+
279
+ // Phase 2: Mutation - create new order based on retrieval results
280
+ const orderId = createOrderWithPayment("user-123", 9999);
281
+
282
+ // Execute mutation phase
283
+ await parentUow.executeMutations();
284
+
285
+ return { user, orderId, existingOrderCount: existingOrders.length };
286
+ };
287
+
288
+ // Execute handler
289
+ const result = await handler();
290
+
291
+ // Verify results
292
+ expect(result.user).toEqual({ id: "mock-id", name: "Mock User" });
293
+ expect(result.orderId.externalId).toBeTruthy();
294
+ expect(result.existingOrderCount).toBe(1);
295
+
296
+ // Verify operations were registered
297
+ // 1 user validation + 1 orders query = 2 retrieval operations
298
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
299
+ // 1 order create + 1 payment create = 2 mutation operations
300
+ expect(parentUow.getMutationOperations()).toHaveLength(2);
301
+
302
+ // Verify execution order
303
+ const log = executor.getLog();
304
+ expect(log).toEqual(["RETRIEVAL: 2 queries", "MUTATION: 2 mutations"]);
305
+ });
306
+
307
+ it("should handle deeply nested child UOWs (3+ levels)", async () => {
308
+ const testSchema = schema((s) =>
309
+ s
310
+ .addTable("users", (t) =>
311
+ t.addColumn("id", idColumn()).addColumn("name", "string").addColumn("email", "string"),
312
+ )
313
+ .addTable("posts", (t) =>
314
+ t
315
+ .addColumn("id", idColumn())
316
+ .addColumn("userId", "string")
317
+ .addColumn("title", "string")
318
+ .createIndex("idx_user", ["userId"]),
319
+ )
320
+ .addTable("comments", (t) =>
321
+ t
322
+ .addColumn("id", idColumn())
323
+ .addColumn("postId", "string")
324
+ .addColumn("content", "string")
325
+ .createIndex("idx_post", ["postId"]),
326
+ ),
327
+ );
328
+
329
+ const executor = createMockExecutor();
330
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
331
+
332
+ // Level 1: Handler (root)
333
+ const handler = async () => {
334
+ // Level 2: Service A - orchestrates user operations
335
+ const getUserWithPosts = async (userId: string) => {
336
+ const childUow1 = parentUow.restrict();
337
+
338
+ // Level 3: Service B - gets just the user
339
+ const getUser = async () => {
340
+ const childUow2 = childUow1.restrict();
341
+ const typedUow = childUow2
342
+ .forSchema(testSchema)
343
+ .find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId)));
344
+
345
+ const [users] = await typedUow.retrievalPhase;
346
+ return users?.[0];
347
+ };
348
+
349
+ // Level 3: Service C - gets user's posts
350
+ const getUserPosts = async () => {
351
+ const childUow2 = childUow1.restrict();
352
+ const typedUow = childUow2
353
+ .forSchema(testSchema)
354
+ .find("posts", (b) => b.whereIndex("idx_user", (eb) => eb("userId", "=", userId)));
355
+
356
+ const [posts] = await typedUow.retrievalPhase;
357
+ return posts;
358
+ };
359
+
360
+ // Await both nested services
361
+ const userPromise = getUser();
362
+ const postsPromise = getUserPosts();
363
+
364
+ // Execute retrieval at this level - should resolve nested promises
365
+ await parentUow.executeRetrieve();
366
+
367
+ const user = await userPromise;
368
+ const posts = await postsPromise;
369
+
370
+ return { user, posts };
371
+ };
372
+
373
+ // Call service A and get results
374
+ const { user, posts } = await getUserWithPosts("user-123");
375
+
376
+ // Level 2: Service D - creates comments for the posts
377
+ const createComment = (postId: string, content: string) => {
378
+ const childUow1 = parentUow.restrict();
379
+
380
+ // Level 3: Service E - validates post exists (hypothetically)
381
+ // In real code this might query, but here we just create
382
+ const childUow2 = childUow1.restrict();
383
+ const typedUow2 = childUow2.forSchema(testSchema);
384
+
385
+ typedUow2.create("comments", { postId, content });
386
+ };
387
+
388
+ // Create comments for each post
389
+ if (posts && posts.length > 0) {
390
+ for (const post of posts) {
391
+ // Use string coercion since mock data returns string ids
392
+ createComment(String(post.id), "Great post!");
393
+ }
394
+ }
395
+
396
+ // Execute mutations
397
+ await parentUow.executeMutations();
398
+
399
+ return { user, postCount: posts?.length ?? 0 };
400
+ };
401
+
402
+ // Execute handler
403
+ const result = await handler();
404
+
405
+ // Verify results
406
+ expect(result.user).toBeDefined();
407
+ expect(result.postCount).toBe(1);
408
+
409
+ // Verify operations were registered at root level
410
+ // 1 user query + 1 posts query = 2 retrieval operations
411
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
412
+ // 1 comment create = 1 mutation operation
413
+ expect(parentUow.getMutationOperations()).toHaveLength(1);
414
+
415
+ // Verify execution happened in correct order
416
+ const log = executor.getLog();
417
+ expect(log).toEqual(["RETRIEVAL: 2 queries", "MUTATION: 1 mutations"]);
418
+ });
419
+
420
+ it("should handle sibling child UOWs at same nesting level", async () => {
421
+ const testSchema = schema((s) =>
422
+ s
423
+ .addTable("users", (t) =>
424
+ t
425
+ .addColumn("id", idColumn())
426
+ .addColumn("name", "string")
427
+ .addColumn("email", "string")
428
+ .createIndex("idx_email", ["email"]),
429
+ )
430
+ .addTable("products", (t) =>
431
+ t.addColumn("id", idColumn()).addColumn("name", "string").addColumn("price", "integer"),
432
+ )
433
+ .addTable("orders", (t) =>
434
+ t
435
+ .addColumn("id", idColumn())
436
+ .addColumn("userId", "string")
437
+ .addColumn("productId", "string")
438
+ .addColumn("quantity", "integer"),
439
+ ),
440
+ );
441
+
442
+ const executor = createMockExecutor();
443
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
444
+
445
+ // Service method that creates TWO sibling child UOWs
446
+ const processUserOrder = async (email: string) => {
447
+ // Sibling 1: Look up user by email
448
+ const findUserByEmail = () => {
449
+ const childUow = parentUow.restrict();
450
+ const typedUow = childUow
451
+ .forSchema(testSchema)
452
+ .find("users", (b) => b.whereIndex("idx_email", (eb) => eb("email", "=", email)));
453
+
454
+ return typedUow.retrievalPhase.then(([users]) => users?.[0] ?? null);
455
+ };
456
+
457
+ // Sibling 2: Look up product by scanning (for simplicity)
458
+ const findProductByName = () => {
459
+ const childUow = parentUow.restrict();
460
+ const typedUow = childUow
461
+ .forSchema(testSchema)
462
+ .find("products", (b) => b.whereIndex("primary"));
463
+
464
+ return typedUow.retrievalPhase.then(([products]) => products?.[0] ?? null);
465
+ };
466
+
467
+ // Create both sibling UOWs at the same time
468
+ const userPromise = findUserByEmail();
469
+ const productPromise = findProductByName();
470
+
471
+ // Execute retrieval - should resolve both siblings
472
+ await parentUow.executeRetrieve();
473
+
474
+ const user = await userPromise;
475
+ const product = await productPromise;
476
+
477
+ if (!user || !product) {
478
+ throw new Error("User or product not found");
479
+ }
480
+
481
+ // Sibling 3: Create order using the results
482
+ const createOrder = () => {
483
+ const childUow = parentUow.restrict();
484
+ const typedUow = childUow.forSchema(testSchema);
485
+
486
+ return typedUow.create("orders", {
487
+ userId: String(user.id),
488
+ productId: String(product.id),
489
+ quantity: 1,
490
+ });
491
+ };
492
+
493
+ // Sibling 4: Update user (hypothetically, just another mutation)
494
+ const updateUserEmail = () => {
495
+ const childUow = parentUow.restrict();
496
+ const typedUow = childUow.forSchema(testSchema);
497
+
498
+ typedUow.update("users", user.id, (b) =>
499
+ b.set({ email: `updated-${email}`, name: user.name }),
500
+ );
501
+ };
502
+
503
+ // Execute mutations from both siblings
504
+ createOrder();
505
+ updateUserEmail();
506
+
507
+ await parentUow.executeMutations();
508
+
509
+ return { user, product };
510
+ };
511
+
512
+ // Execute the service
513
+ const result = await processUserOrder("test@example.com");
514
+
515
+ // Verify results
516
+ expect(result.user).toBeDefined();
517
+ expect(result.product).toBeDefined();
518
+
519
+ // Verify operations registered from all siblings
520
+ // Sibling 1 (user lookup) + Sibling 2 (product lookup) = 2 retrieval operations
521
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
522
+ // Sibling 3 (order create) + Sibling 4 (user update) = 2 mutation operations
523
+ expect(parentUow.getMutationOperations()).toHaveLength(2);
524
+
525
+ // Verify execution order
526
+ const log = executor.getLog();
527
+ expect(log).toEqual(["RETRIEVAL: 2 queries", "MUTATION: 2 mutations"]);
528
+ });
529
+
530
+ it("should support transaction rollback pattern", async () => {
531
+ const testSchema = schema((s) =>
532
+ s
533
+ .addTable("accounts", (t) =>
534
+ t
535
+ .addColumn("id", idColumn())
536
+ .addColumn("userId", "string")
537
+ .addColumn("balance", "integer")
538
+ .createIndex("idx_user", ["userId"]),
539
+ )
540
+ .addTable("transactions", (t) =>
541
+ t
542
+ .addColumn("id", idColumn())
543
+ .addColumn("fromAccountId", referenceColumn())
544
+ .addColumn("toAccountId", referenceColumn())
545
+ .addColumn("amount", "integer"),
546
+ ),
547
+ );
548
+
549
+ // Create mock executor that returns account data with low balance
550
+ const executionLog: string[] = [];
551
+ const customExecutor = {
552
+ executeRetrievalPhase: async (queries: string[]) => {
553
+ executionLog.push(`RETRIEVAL: ${queries.length} queries`);
554
+ // Return mock account data with balance field set to low value
555
+ return queries.map(() => [{ id: "mock-id", userId: "user-1", balance: 100 }]);
556
+ },
557
+ executeMutationPhase: async (mutations: CompiledMutation<string>[]) => {
558
+ executionLog.push(`MUTATION: ${mutations.length} mutations`);
559
+ return {
560
+ success: true,
561
+ createdInternalIds: mutations.map(() => BigInt(Math.floor(Math.random() * 1000))),
562
+ };
563
+ },
564
+ };
565
+
566
+ const parentUow = createUnitOfWork(createMockCompiler(), customExecutor, createMockDecoder());
567
+
568
+ // Service: Get account balance
569
+ const getAccountBalance = async (userId: string) => {
570
+ const childUow = parentUow.restrict();
571
+ const typedUow = childUow
572
+ .forSchema(testSchema)
573
+ .find("accounts", (b) => b.whereIndex("idx_user", (eb) => eb("userId", "=", userId)));
574
+
575
+ const [accounts] = await typedUow.retrievalPhase;
576
+ return accounts?.[0] ?? null;
577
+ };
578
+
579
+ // Service: Create transfer (mutation)
580
+ const createTransfer = (fromAccountId: FragnoId, toAccountId: FragnoId, amount: number) => {
581
+ const childUow = parentUow.restrict();
582
+ const typedUow = childUow.forSchema(testSchema);
583
+
584
+ // Check that both accounts haven't changed since retrieval
585
+ typedUow.check("accounts", fromAccountId);
586
+ typedUow.check("accounts", toAccountId);
587
+
588
+ return typedUow.create("transactions", {
589
+ fromAccountId,
590
+ toAccountId,
591
+ amount,
592
+ });
593
+ };
594
+
595
+ // Handler: Attempt transfer but abort if insufficient funds
596
+ const attemptTransfer = async (fromUserId: string, toUserId: string, amount: number) => {
597
+ // Phase 1: Retrieval - get both accounts
598
+ const fromAccountPromise = getAccountBalance(fromUserId);
599
+ const toAccountPromise = getAccountBalance(toUserId);
600
+
601
+ // Execute retrieval phase
602
+ await parentUow.executeRetrieve();
603
+
604
+ const fromAccount = await fromAccountPromise;
605
+ const toAccount = await toAccountPromise;
606
+
607
+ // Validation: Check if accounts exist
608
+ if (!fromAccount || !toAccount) {
609
+ throw new Error("One or both accounts not found");
610
+ }
611
+
612
+ // Validation: Check if sufficient balance (this should fail in our test)
613
+ if (fromAccount.balance < amount) {
614
+ throw new Error("Insufficient funds");
615
+ }
616
+
617
+ // Phase 2: Mutation - would create transfer, but we never get here
618
+ createTransfer(fromAccount.id, toAccount.id, amount);
619
+ await parentUow.executeMutations();
620
+
621
+ return { success: true };
622
+ };
623
+
624
+ // Execute handler - expect it to throw due to insufficient funds
625
+ await expect(attemptTransfer("user-1", "user-2", 10000)).rejects.toThrow("Insufficient funds");
626
+
627
+ // Verify retrieval phase was executed
628
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
629
+
630
+ // Verify NO mutations were executed (rollback pattern)
631
+ expect(parentUow.getMutationOperations()).toHaveLength(0);
632
+
633
+ // Verify only retrieval phase was executed, no mutations
634
+ expect(executionLog).toEqual(["RETRIEVAL: 2 queries"]);
635
+ });
636
+
637
+ it("should handle errors thrown by service methods without unhandled rejections", async () => {
638
+ const testSchema = schema((s) =>
639
+ s
640
+ .addTable("users", (t) =>
641
+ t
642
+ .addColumn("id", idColumn())
643
+ .addColumn("name", "string")
644
+ .addColumn("email", "string")
645
+ .addColumn("status", "string"),
646
+ )
647
+ .addTable("posts", (t) =>
648
+ t
649
+ .addColumn("id", idColumn())
650
+ .addColumn("userId", "string")
651
+ .addColumn("title", "string")
652
+ .createIndex("idx_user", ["userId"]),
653
+ ),
654
+ );
655
+
656
+ const executor = createMockExecutor();
657
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
658
+
659
+ // Service A: Validates user and throws if not active
660
+ const validateActiveUser = async (userId: string) => {
661
+ const childUow = parentUow.restrict();
662
+ const typedUow = childUow
663
+ .forSchema(testSchema)
664
+ .find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId)));
665
+
666
+ const [users] = await typedUow.retrievalPhase;
667
+ const user = users?.[0];
668
+
669
+ if (!user) {
670
+ throw new Error("User not found");
671
+ }
672
+
673
+ // Mock check: assume user status is "inactive"
674
+ if (user.name === "Mock User") {
675
+ throw new Error("User is not active");
676
+ }
677
+
678
+ return user;
679
+ };
680
+
681
+ // Service B: Gets user posts (won't be reached due to validation error)
682
+ const getUserPosts = async (userId: string) => {
683
+ const childUow = parentUow.restrict();
684
+ const typedUow = childUow
685
+ .forSchema(testSchema)
686
+ .find("posts", (b) => b.whereIndex("idx_user", (eb) => eb("userId", "=", userId)));
687
+
688
+ const [posts] = await typedUow.retrievalPhase;
689
+ return posts;
690
+ };
691
+
692
+ // Handler: Orchestrates service calls that may throw
693
+ const handler = async () => {
694
+ // Both services add retrieval operations
695
+ const userPromise = validateActiveUser("user-123");
696
+ const postsPromise = getUserPosts("user-123");
697
+
698
+ // Execute retrieval phase - this resolves the retrievalPhase promises
699
+ await parentUow.executeRetrieve();
700
+
701
+ // Now await the service results - validateActiveUser will throw
702
+ const user = await userPromise; // This will throw "User is not active"
703
+ const posts = await postsPromise; // Won't be reached
704
+
705
+ return { user, posts };
706
+ };
707
+
708
+ // Execute handler and expect it to throw
709
+ await expect(handler()).rejects.toThrow("User is not active");
710
+
711
+ // Verify retrieval phase was executed (both operations were registered)
712
+ expect(parentUow.getRetrievalOperations()).toHaveLength(2);
713
+
714
+ // Verify execution happened
715
+ const log = executor.getLog();
716
+ expect(log).toEqual(["RETRIEVAL: 2 queries"]);
717
+
718
+ // No mutations should have been added since we threw during retrieval validation
719
+ expect(parentUow.getMutationOperations()).toHaveLength(0);
720
+
721
+ // Give Node.js event loop time to detect any unhandled rejections
722
+ await new Promise((resolve) => setTimeout(resolve, 10));
723
+
724
+ // If we got here without Node.js throwing an unhandled rejection, the test passes
725
+ });
726
+
727
+ it("should inherit nonce from parent to children for idempotent operations", () => {
728
+ const executor = createMockExecutor();
729
+ const parentUow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
730
+
731
+ // Parent UOW should have a nonce
732
+ const parentNonce = parentUow.nonce;
733
+ expect(parentNonce).toBeDefined();
734
+ expect(typeof parentNonce).toBe("string");
735
+ expect(parentNonce.length).toBeGreaterThan(0);
736
+
737
+ // Create first child
738
+ const child1 = parentUow.restrict();
739
+ expect(child1.nonce).toBe(parentNonce);
740
+
741
+ // Create second child (sibling to child1)
742
+ const child2 = parentUow.restrict();
743
+ expect(child2.nonce).toBe(parentNonce);
744
+
745
+ // Create nested child (child of child1)
746
+ const grandchild = child1.restrict();
747
+ expect(grandchild.nonce).toBe(parentNonce);
748
+
749
+ // All UOWs in the hierarchy should share the same nonce
750
+ expect(parentUow.nonce).toBe(child1.nonce);
751
+ expect(child1.nonce).toBe(child2.nonce);
752
+ expect(child2.nonce).toBe(grandchild.nonce);
753
+ });
754
+
755
+ it("should generate different nonces for separate UOW hierarchies", () => {
756
+ const executor = createMockExecutor();
757
+
758
+ // Create two separate parent UOWs
759
+ const parentUow1 = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
760
+ const parentUow2 = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
761
+
762
+ // They should have different nonces
763
+ expect(parentUow1.nonce).not.toBe(parentUow2.nonce);
764
+
765
+ // But children within each hierarchy should inherit their parent's nonce
766
+ const child1 = parentUow1.restrict();
767
+ const child2 = parentUow2.restrict();
768
+
769
+ expect(child1.nonce).toBe(parentUow1.nonce);
770
+ expect(child2.nonce).toBe(parentUow2.nonce);
771
+ expect(child1.nonce).not.toBe(child2.nonce);
772
+ });
773
+
774
+ it.skip("should not cause unhandled rejection when service method awaits retrievalPhase and executeRetrieve fails", async () => {
775
+ const testSchema = schema((s) =>
776
+ s.addTable("settings", (t) =>
777
+ t
778
+ .addColumn("id", idColumn())
779
+ .addColumn("key", "string")
780
+ .addColumn("value", "string")
781
+ .createIndex("unique_key", ["key"], { unique: true }),
782
+ ),
783
+ );
784
+
785
+ // Create executor that throws "table does not exist" error
786
+ const failingExecutor = {
787
+ executeRetrievalPhase: async () => {
788
+ throw new Error('relation "settings" does not exist');
789
+ },
790
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
791
+ };
792
+
793
+ const parentUow = createUnitOfWork(createMockCompiler(), failingExecutor, createMockDecoder());
794
+
795
+ // Service method that awaits retrievalPhase (simulating settingsService.get())
796
+ const getSettingValue = async (key: string) => {
797
+ const childUow = parentUow.restrict();
798
+ const typedUow = childUow
799
+ .forSchema(testSchema)
800
+ .find("settings", (b) => b.whereIndex("unique_key", (eb) => eb("key", "=", key)));
801
+
802
+ // This is the critical line - accessing retrievalPhase creates a new promise
803
+ // If not cached properly, this new promise won't have a catch handler attached
804
+ const [results] = await typedUow.retrievalPhase;
805
+ return results?.[0];
806
+ };
807
+
808
+ const deferred = Promise.withResolvers<string>();
809
+
810
+ // Handler that calls the service and handles the error
811
+ const handler = async () => {
812
+ try {
813
+ const settingPromise = getSettingValue("version");
814
+
815
+ await parentUow.executeRetrieve();
816
+
817
+ // Won't reach here
818
+ return await settingPromise;
819
+ } catch (error) {
820
+ // Error is caught here - this is expected behavior
821
+ expect(error).toBeInstanceOf(Error);
822
+ deferred.resolve((error as Error).message);
823
+ return null;
824
+ }
825
+ };
826
+
827
+ // Execute handler - should catch the error without unhandled rejection
828
+ const result = await handler();
829
+ expect(result).toBeNull();
830
+
831
+ expect(await deferred.promise).toContain('relation "settings" does not exist');
832
+ });
833
+ });