@fragno-dev/db 0.2.0 → 0.2.2

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 (62) hide show
  1. package/.turbo/turbo-build.log +34 -30
  2. package/CHANGELOG.md +49 -0
  3. package/dist/adapters/generic-sql/query/where-builder.js +1 -1
  4. package/dist/db-fragment-definition-builder.d.ts +31 -39
  5. package/dist/db-fragment-definition-builder.d.ts.map +1 -1
  6. package/dist/db-fragment-definition-builder.js +20 -16
  7. package/dist/db-fragment-definition-builder.js.map +1 -1
  8. package/dist/fragments/internal-fragment.d.ts +94 -8
  9. package/dist/fragments/internal-fragment.d.ts.map +1 -1
  10. package/dist/fragments/internal-fragment.js +56 -55
  11. package/dist/fragments/internal-fragment.js.map +1 -1
  12. package/dist/hooks/hooks.d.ts +5 -3
  13. package/dist/hooks/hooks.d.ts.map +1 -1
  14. package/dist/hooks/hooks.js +38 -37
  15. package/dist/hooks/hooks.js.map +1 -1
  16. package/dist/mod.d.ts +3 -3
  17. package/dist/mod.d.ts.map +1 -1
  18. package/dist/mod.js +4 -4
  19. package/dist/mod.js.map +1 -1
  20. package/dist/query/unit-of-work/execute-unit-of-work.d.ts +367 -80
  21. package/dist/query/unit-of-work/execute-unit-of-work.d.ts.map +1 -1
  22. package/dist/query/unit-of-work/execute-unit-of-work.js +448 -148
  23. package/dist/query/unit-of-work/execute-unit-of-work.js.map +1 -1
  24. package/dist/query/unit-of-work/unit-of-work.d.ts +35 -11
  25. package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
  26. package/dist/query/unit-of-work/unit-of-work.js +49 -19
  27. package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
  28. package/dist/query/value-decoding.js +1 -1
  29. package/dist/schema/create.d.ts +2 -3
  30. package/dist/schema/create.d.ts.map +1 -1
  31. package/dist/schema/create.js +2 -5
  32. package/dist/schema/create.js.map +1 -1
  33. package/dist/schema/generate-id.d.ts +20 -0
  34. package/dist/schema/generate-id.d.ts.map +1 -0
  35. package/dist/schema/generate-id.js +28 -0
  36. package/dist/schema/generate-id.js.map +1 -0
  37. package/dist/sql-driver/dialects/durable-object-dialect.d.ts.map +1 -1
  38. package/package.json +3 -3
  39. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +1 -0
  40. package/src/adapters/drizzle/drizzle-adapter-sqlite3.test.ts +41 -25
  41. package/src/adapters/generic-sql/test/generic-drizzle-adapter-sqlite3.test.ts +39 -25
  42. package/src/db-fragment-definition-builder.test.ts +58 -42
  43. package/src/db-fragment-definition-builder.ts +78 -88
  44. package/src/db-fragment-instantiator.test.ts +64 -88
  45. package/src/db-fragment-integration.test.ts +292 -142
  46. package/src/fragments/internal-fragment.test.ts +272 -266
  47. package/src/fragments/internal-fragment.ts +155 -122
  48. package/src/hooks/hooks.test.ts +268 -264
  49. package/src/hooks/hooks.ts +74 -63
  50. package/src/mod.ts +14 -4
  51. package/src/query/unit-of-work/execute-unit-of-work.test.ts +1582 -998
  52. package/src/query/unit-of-work/execute-unit-of-work.ts +1746 -343
  53. package/src/query/unit-of-work/tx-builder.test.ts +1041 -0
  54. package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +269 -21
  55. package/src/query/unit-of-work/unit-of-work.test.ts +64 -0
  56. package/src/query/unit-of-work/unit-of-work.ts +65 -30
  57. package/src/schema/create.ts +2 -5
  58. package/src/schema/generate-id.test.ts +57 -0
  59. package/src/schema/generate-id.ts +38 -0
  60. package/src/shared/config.ts +0 -10
  61. package/src/shared/connection-pool.ts +0 -24
  62. package/src/shared/prisma.ts +0 -45
@@ -1,21 +1,25 @@
1
- import { describe, it, expect, vi, assert, expectTypeOf } from "vitest";
1
+ import { describe, it, expect, expectTypeOf } from "vitest";
2
2
  import { schema, idColumn, FragnoId } from "../../schema/create";
3
3
  import {
4
4
  createUnitOfWork,
5
- type TypedUnitOfWork,
5
+ type IUnitOfWork,
6
6
  type UOWCompiler,
7
7
  type UOWDecoder,
8
8
  type UOWExecutor,
9
9
  } from "./unit-of-work";
10
- import { executeUnitOfWork, executeRestrictedUnitOfWork } from "./execute-unit-of-work";
10
+ import {
11
+ createServiceTxBuilder,
12
+ createHandlerTxBuilder,
13
+ isTxResult,
14
+ ConcurrencyConflictError,
15
+ } from "./execute-unit-of-work";
11
16
  import {
12
17
  ExponentialBackoffRetryPolicy,
13
18
  LinearBackoffRetryPolicy,
14
19
  NoRetryPolicy,
15
20
  } from "./retry-policy";
16
- import type { AwaitedPromisesInObject } from "./execute-unit-of-work";
21
+ import type { AwaitedPromisesInObject, TxResult } from "./execute-unit-of-work";
17
22
 
18
- // Create test schema
19
23
  const testSchema = schema((s) =>
20
24
  s.addTable("users", (t) =>
21
25
  t
@@ -27,7 +31,6 @@ const testSchema = schema((s) =>
27
31
  ),
28
32
  );
29
33
 
30
- // Type tests for AwaitedPromisesInObject
31
34
  describe("AwaitedPromisesInObject type tests", () => {
32
35
  it("should unwrap promises in objects", () => {
33
36
  type Input = { a: Promise<string>; b: number };
@@ -125,1186 +128,1767 @@ function createMockDecoder(): UOWDecoder {
125
128
  };
126
129
  }
127
130
 
128
- // Helper to create a UOW factory that tracks how many times it's called
129
- function createMockUOWFactory(mutationResults: Array<{ success: boolean }>) {
130
- const callCount = { value: 0 };
131
- // Share callIndex across all UOW instances
132
- let callIndex = 0;
131
+ describe("Unified Tx API", () => {
132
+ describe("isTxResult", () => {
133
+ it("should return true for TxResult objects", () => {
134
+ const compiler = createMockCompiler();
135
+ const executor: UOWExecutor<unknown, unknown> = {
136
+ executeRetrievalPhase: async () => [],
137
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
138
+ };
139
+ const decoder = createMockDecoder();
140
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
141
+
142
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
143
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
144
+ .build();
133
145
 
134
- const factory = () => {
135
- callCount.value++;
146
+ expect(isTxResult(txResult)).toBe(true);
147
+ });
148
+
149
+ it("should return false for non-TxResult objects", () => {
150
+ expect(isTxResult(null)).toBe(false);
151
+ expect(isTxResult(undefined)).toBe(false);
152
+ expect(isTxResult({})).toBe(false);
153
+ expect(isTxResult({ _internal: {} })).toBe(false);
154
+ expect(isTxResult(Promise.resolve())).toBe(false);
155
+ });
156
+ });
136
157
 
137
- // Create executor that uses shared callIndex
138
- const executor: UOWExecutor<unknown, unknown> = {
139
- executeRetrievalPhase: async () => {
140
- return [
158
+ describe("createServiceTx", () => {
159
+ it("should create a TxResult with retrieve callback", () => {
160
+ const compiler = createMockCompiler();
161
+ const executor: UOWExecutor<unknown, unknown> = {
162
+ executeRetrievalPhase: async () => [
141
163
  [
142
164
  {
143
- id: FragnoId.fromExternal("user-1", 1),
165
+ id: FragnoId.fromExternal("1", 1),
144
166
  email: "test@example.com",
145
- name: "Test User",
167
+ name: "Test",
146
168
  balance: 100,
147
169
  },
148
170
  ],
149
- ];
150
- },
151
- executeMutationPhase: async () => {
152
- const result = mutationResults[callIndex] || { success: false };
153
- callIndex++;
154
- return { ...result, createdInternalIds: [] };
155
- },
156
- };
157
-
158
- return createUnitOfWork(createMockCompiler(), executor, createMockDecoder()).forSchema(
159
- testSchema,
160
- );
161
- };
162
- return { factory, callCount };
163
- }
164
-
165
- describe("executeUnitOfWork", () => {
166
- describe("validation", () => {
167
- it("should throw error when neither retrieve nor mutate is provided", async () => {
168
- const { factory } = createMockUOWFactory([{ success: true }]);
169
-
170
- await expect(executeUnitOfWork({}, { createUnitOfWork: factory })).rejects.toThrow(
171
- "At least one of 'retrieve' or 'mutate' callbacks must be provided",
172
- );
173
- });
174
- });
171
+ ],
172
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
173
+ };
174
+ const decoder = createMockDecoder();
175
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
175
176
 
176
- describe("success scenarios", () => {
177
- it("should succeed on first attempt without retries", async () => {
178
- const { factory } = createMockUOWFactory([{ success: true }]);
179
- const onSuccess = vi.fn();
177
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
178
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
179
+ .build();
180
180
 
181
- const result = await executeUnitOfWork(
182
- {
183
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
184
- mutate: (uow, [users]) => {
185
- const newBalance = users[0].balance + 100;
186
- uow.update("users", users[0].id, (b) => b.set({ balance: newBalance }).check());
187
- return { newBalance };
188
- },
189
- onSuccess,
190
- },
191
- { createUnitOfWork: factory },
192
- );
193
-
194
- assert(result.success);
195
- expect(result.mutationResult).toEqual({ newBalance: 200 });
196
- expect(onSuccess).toHaveBeenCalledExactlyOnceWith({
197
- results: expect.any(Array),
198
- mutationResult: { newBalance: 200 },
199
- createdIds: [],
200
- nonce: expect.any(String),
201
- });
181
+ expect(isTxResult(txResult)).toBe(true);
182
+ expect(txResult._internal.schema).toBe(testSchema);
183
+ expect(txResult._internal.callbacks.retrieve).toBeDefined();
202
184
  });
203
- });
204
185
 
205
- describe("retry scenarios", () => {
206
- it("should retry on conflict with eventual success", async () => {
207
- const { factory, callCount } = createMockUOWFactory([
208
- { success: false },
209
- { success: false },
210
- { success: true },
211
- ]);
186
+ it("should create a TxResult with transformRetrieve callback", () => {
187
+ const compiler = createMockCompiler();
188
+ const executor: UOWExecutor<unknown, unknown> = {
189
+ executeRetrievalPhase: async () => [
190
+ [
191
+ {
192
+ id: FragnoId.fromExternal("1", 1),
193
+ email: "test@example.com",
194
+ name: "Test",
195
+ balance: 100,
196
+ },
197
+ ],
198
+ ],
199
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
200
+ };
201
+ const decoder = createMockDecoder();
202
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
212
203
 
213
- const result = await executeUnitOfWork(
214
- {
215
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
216
- mutate: async (uow, [users]) => {
217
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }).check());
218
- },
219
- },
220
- {
221
- createUnitOfWork: factory,
222
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
223
- },
224
- );
204
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
205
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
206
+ .transformRetrieve(([users]) => users[0] ?? null)
207
+ .build();
225
208
 
226
- expect(result.success).toBe(true);
227
- expect(callCount.value).toBe(3); // Initial + 2 retries
209
+ expect(isTxResult(txResult)).toBe(true);
210
+ expect(txResult._internal.callbacks.retrieveSuccess).toBeDefined();
228
211
  });
229
212
 
230
- it("should fail when max retries exceeded", async () => {
231
- const { factory, callCount } = createMockUOWFactory([
232
- { success: false },
233
- { success: false },
234
- { success: false },
235
- { success: false },
236
- ]);
237
-
238
- const result = await executeUnitOfWork(
239
- {
240
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
241
- mutate: async (uow, [users]) => {
242
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
243
- },
244
- },
245
- {
246
- createUnitOfWork: factory,
247
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 2, initialDelayMs: 1 }),
248
- },
249
- );
213
+ it("should create a TxResult with serviceCalls", () => {
214
+ const compiler = createMockCompiler();
215
+ const executor: UOWExecutor<unknown, unknown> = {
216
+ executeRetrievalPhase: async () => [
217
+ [
218
+ {
219
+ id: FragnoId.fromExternal("1", 1),
220
+ email: "test@example.com",
221
+ name: "Test",
222
+ balance: 100,
223
+ },
224
+ ],
225
+ ],
226
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
227
+ };
228
+ const decoder = createMockDecoder();
229
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
230
+
231
+ // Create a dependency TxResult
232
+ const depTxResult = createServiceTxBuilder(testSchema, baseUow)
233
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
234
+ .transformRetrieve(([users]) => users[0] ?? null)
235
+ .build();
236
+
237
+ // Create a TxResult that depends on it
238
+ const txResult = createServiceTxBuilder(testSchema, baseUow)
239
+ .withServiceCalls(() => [depTxResult])
240
+ .mutate(({ uow, serviceIntermediateResult: [user] }) => {
241
+ if (!user) {
242
+ throw new Error("User not found");
243
+ }
244
+ return uow.create("users", { email: "new@example.com", name: "New", balance: 0 });
245
+ })
246
+ .build();
250
247
 
251
- assert(!result.success);
252
- expect(result.reason).toBe("conflict");
253
- expect(callCount.value).toBe(3); // Initial + 2 retries
248
+ expect(isTxResult(txResult)).toBe(true);
249
+ expect(txResult._internal.serviceCalls).toHaveLength(1);
254
250
  });
255
251
 
256
- it("should create fresh UOW on each retry attempt", async () => {
257
- const { factory, callCount } = createMockUOWFactory([
258
- { success: false },
259
- { success: false },
260
- { success: true },
261
- ]);
252
+ it("should type mutateResult as non-undefined when success AND mutate are provided", () => {
253
+ const compiler = createMockCompiler();
254
+ const executor: UOWExecutor<unknown, unknown> = {
255
+ executeRetrievalPhase: async () => [],
256
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
257
+ };
258
+ const decoder = createMockDecoder();
259
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
260
+
261
+ // When BOTH mutate AND success are provided, mutateResult should NOT be undefined
262
+ createServiceTxBuilder(testSchema, baseUow)
263
+ .mutate(({ uow }) => {
264
+ uow.create("users", { email: "test@example.com", name: "Test", balance: 0 });
265
+ return { created: true as const, code: "ABC123" };
266
+ })
267
+ .transform(({ mutateResult }) => {
268
+ // Key type assertion: mutateResult is NOT undefined when mutate callback IS provided
269
+ expectTypeOf(mutateResult).toEqualTypeOf<{ created: true; code: string }>();
270
+ // Should be able to access properties without null check
271
+ return { success: true, code: mutateResult.code };
272
+ })
273
+ .build();
274
+ });
262
275
 
263
- await executeUnitOfWork(
264
- {
265
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
266
- mutate: async (uow, [users]) => {
267
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
268
- },
269
- },
270
- {
271
- createUnitOfWork: factory,
272
- retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 3, delayMs: 1 }),
273
- },
274
- );
276
+ it("should type mutateResult as undefined when success is provided but mutate is NOT", () => {
277
+ const compiler = createMockCompiler();
278
+ const executor: UOWExecutor<unknown, unknown> = {
279
+ executeRetrievalPhase: async () => [
280
+ [
281
+ {
282
+ id: FragnoId.fromExternal("1", 1),
283
+ email: "test@example.com",
284
+ name: "Test",
285
+ balance: 100,
286
+ },
287
+ ],
288
+ ],
289
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
290
+ };
291
+ const decoder = createMockDecoder();
292
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
293
+
294
+ // When success is provided but mutate is NOT, mutateResult should be undefined
295
+ createServiceTxBuilder(testSchema, baseUow)
296
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
297
+ .transformRetrieve(([users]) => users[0] ?? null)
298
+ // NO mutate callback
299
+ .transform(({ mutateResult, retrieveResult }) => {
300
+ // Key type assertion: mutateResult IS undefined when no mutate callback
301
+ expectTypeOf(mutateResult).toEqualTypeOf<undefined>();
302
+ // retrieveResult should still be properly typed (can be null from ?? null)
303
+ if (retrieveResult !== null) {
304
+ expectTypeOf(retrieveResult.email).toEqualTypeOf<string>();
305
+ }
306
+ return { user: retrieveResult };
307
+ })
308
+ .build();
309
+ });
275
310
 
276
- expect(callCount.value).toBe(3); // Each attempt creates a new UOW
311
+ it("should type retrieveResult as TRetrieveResults when retrieve is provided but retrieveSuccess is NOT", () => {
312
+ const compiler = createMockCompiler();
313
+ const executor: UOWExecutor<unknown, unknown> = {
314
+ executeRetrievalPhase: async () => [
315
+ [
316
+ {
317
+ id: FragnoId.fromExternal("1", 1),
318
+ email: "test@example.com",
319
+ name: "Test",
320
+ balance: 100,
321
+ },
322
+ ],
323
+ ],
324
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
325
+ };
326
+ const decoder = createMockDecoder();
327
+ const baseUow = createUnitOfWork(compiler, executor, decoder);
328
+
329
+ // When retrieve IS provided but retrieveSuccess is NOT, retrieveResult should be TRetrieveResults
330
+ // (the raw array from the retrieve callback), NOT unknown
331
+ createServiceTxBuilder(testSchema, baseUow)
332
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
333
+ // NO transformRetrieve callback - this is the key scenario
334
+ .mutate(({ uow, retrieveResult }) => {
335
+ // Key type assertion: retrieveResult should be the raw array type, NOT unknown
336
+ // The retrieve callback returns TypedUnitOfWork with [users[]] as the results type
337
+ expectTypeOf(retrieveResult).toEqualTypeOf<
338
+ [{ id: FragnoId; email: string; name: string; balance: number }[]]
339
+ >();
340
+
341
+ // Should be able to access the array without type errors
342
+ const users = retrieveResult[0];
343
+ expectTypeOf(users).toEqualTypeOf<
344
+ { id: FragnoId; email: string; name: string; balance: number }[]
345
+ >();
346
+
347
+ if (users.length > 0) {
348
+ const user = users[0];
349
+ uow.update("users", user.id, (b) => b.set({ balance: user.balance + 100 }));
350
+ }
351
+ return { processed: true };
352
+ })
353
+ .build();
277
354
  });
278
355
  });
279
356
 
280
- describe("AbortSignal handling", () => {
281
- it("should abort when signal is aborted before execution", async () => {
282
- const { factory } = createMockUOWFactory([{ success: false }]);
283
- const controller = new AbortController();
284
- controller.abort();
285
-
286
- const result = await executeUnitOfWork(
357
+ describe("executeTx", () => {
358
+ it("should execute a simple retrieve-only transaction", async () => {
359
+ const compiler = createMockCompiler();
360
+ const mockUsers = [
287
361
  {
288
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
289
- mutate: async (uow, [users]) => {
290
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
291
- },
362
+ id: FragnoId.fromExternal("1", 1),
363
+ email: "alice@example.com",
364
+ name: "Alice",
365
+ balance: 100,
292
366
  },
293
367
  {
294
- createUnitOfWork: factory,
295
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
296
- signal: controller.signal,
368
+ id: FragnoId.fromExternal("2", 1),
369
+ email: "bob@example.com",
370
+ name: "Bob",
371
+ balance: 200,
297
372
  },
298
- );
299
-
300
- assert(!result.success);
301
- expect(result.reason).toBe("aborted");
373
+ ];
374
+ const executor: UOWExecutor<unknown, unknown> = {
375
+ executeRetrievalPhase: async () => [mockUsers],
376
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
377
+ };
378
+ const decoder = createMockDecoder();
379
+
380
+ const [users] = await createHandlerTxBuilder({
381
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
382
+ })
383
+ .retrieve(({ forSchema }) =>
384
+ forSchema(testSchema).find("users", (b) => b.whereIndex("idx_email")),
385
+ )
386
+ .execute();
387
+
388
+ expect(users).toHaveLength(2);
389
+ expect(users[0].email).toBe("alice@example.com");
390
+ expect(users[1].name).toBe("Bob");
302
391
  });
303
392
 
304
- it("should abort when signal is aborted during retry", async () => {
305
- const { factory } = createMockUOWFactory([{ success: false }, { success: false }]);
306
- const controller = new AbortController();
307
-
308
- // Abort after first attempt
309
- setTimeout(() => controller.abort(), 50);
393
+ it("should execute a simple mutate-only transaction", async () => {
394
+ const compiler = createMockCompiler();
395
+ const executor: UOWExecutor<unknown, unknown> = {
396
+ executeRetrievalPhase: async () => [],
397
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
398
+ };
399
+ const decoder = createMockDecoder();
310
400
 
311
- const result = await executeUnitOfWork(
312
- {
313
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
314
- mutate: async (uow, [users]) => {
315
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
316
- },
317
- },
318
- {
319
- createUnitOfWork: factory,
320
- retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 5, delayMs: 100 }),
321
- signal: controller.signal,
322
- },
323
- );
401
+ const result = await createHandlerTxBuilder({
402
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
403
+ })
404
+ .mutate(({ forSchema }) => {
405
+ const userId = forSchema(testSchema).create("users", {
406
+ email: "test@example.com",
407
+ name: "Test",
408
+ balance: 100,
409
+ });
410
+ return { userId };
411
+ })
412
+ .execute();
324
413
 
325
- assert(!result.success);
326
- expect(result.reason).toBe("aborted");
414
+ expect(result.userId).toBeInstanceOf(FragnoId);
327
415
  });
328
- });
329
416
 
330
- describe("onSuccess callback", () => {
331
- it("should pass mutation result to onSuccess callback", async () => {
332
- const { factory } = createMockUOWFactory([{ success: true }]);
333
- const onSuccess = vi.fn();
417
+ it("should execute a transaction with serviceCalls as retrieve source", async () => {
418
+ const compiler = createMockCompiler();
419
+ const mockUser = {
420
+ id: FragnoId.fromExternal("1", 1),
421
+ email: "test@example.com",
422
+ name: "Test",
423
+ balance: 100,
424
+ };
425
+ const executor: UOWExecutor<unknown, unknown> = {
426
+ executeRetrievalPhase: async () => [[mockUser]],
427
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
428
+ };
429
+ const decoder = createMockDecoder();
334
430
 
335
- await executeUnitOfWork(
336
- {
337
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
338
- mutate: async () => {
339
- return { updatedCount: 5 };
340
- },
341
- onSuccess,
342
- },
343
- { createUnitOfWork: factory },
344
- );
345
-
346
- expect(onSuccess).toHaveBeenCalledTimes(1);
347
- expect(onSuccess).toHaveBeenCalledWith({
348
- results: expect.any(Array),
349
- mutationResult: { updatedCount: 5 },
350
- createdIds: [],
351
- nonce: expect.any(String),
352
- });
353
- });
431
+ let currentUow: IUnitOfWork | null = null;
354
432
 
355
- it("should only execute onSuccess callback on success", async () => {
356
- const { factory } = createMockUOWFactory([{ success: false }]);
357
- const onSuccess = vi.fn();
433
+ // Service that retrieves
434
+ const getUserById = () => {
435
+ return createServiceTxBuilder(testSchema, currentUow!)
436
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
437
+ .transformRetrieve(([users]) => users[0] ?? null)
438
+ .build();
439
+ };
358
440
 
359
- const result = await executeUnitOfWork(
360
- {
361
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
362
- mutate: async (uow, [users]) => {
363
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
364
- },
365
- onSuccess,
441
+ const result = await createHandlerTxBuilder({
442
+ createUnitOfWork: () => {
443
+ currentUow = createUnitOfWork(compiler, executor, decoder);
444
+ return currentUow;
366
445
  },
367
- {
368
- createUnitOfWork: factory,
369
- retryPolicy: new NoRetryPolicy(),
370
- },
371
- );
446
+ })
447
+ .withServiceCalls(() => [getUserById()])
448
+ .transform(({ serviceResult: [user] }) => user)
449
+ .execute();
372
450
 
373
- assert(!result.success);
374
- expect(result.reason).toBe("conflict");
375
- expect(onSuccess).not.toHaveBeenCalled();
451
+ expect(result).toEqual(mockUser);
376
452
  });
377
453
 
378
- it("should execute onSuccess only once even after retries", async () => {
379
- const { factory } = createMockUOWFactory([
380
- { success: false },
381
- { success: false },
382
- { success: true },
383
- ]);
384
- const onSuccess = vi.fn();
385
-
386
- await executeUnitOfWork(
387
- {
388
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
389
- mutate: async (uow, [users]) => {
390
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
391
- },
392
- onSuccess,
393
- },
394
- {
395
- createUnitOfWork: factory,
396
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3, initialDelayMs: 1 }),
397
- },
398
- );
454
+ it("should execute a transaction with mutate callback using serviceCalls", async () => {
455
+ const compiler = createMockCompiler();
456
+ const mockUser = {
457
+ id: FragnoId.fromExternal("1", 1),
458
+ email: "test@example.com",
459
+ name: "Test",
460
+ balance: 100,
461
+ };
462
+ const executor: UOWExecutor<unknown, unknown> = {
463
+ executeRetrievalPhase: async () => [[mockUser]],
464
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
465
+ };
466
+ const decoder = createMockDecoder();
399
467
 
400
- expect(onSuccess).toHaveBeenCalledTimes(1);
401
- });
468
+ let currentUow: IUnitOfWork | null = null;
402
469
 
403
- it("should handle async onSuccess callback", async () => {
404
- const { factory } = createMockUOWFactory([{ success: true }]);
405
- const onSuccess = vi.fn(async () => {
406
- await new Promise((resolve) => setTimeout(resolve, 10));
407
- });
470
+ // Service that retrieves
471
+ const getUserById = () => {
472
+ return createServiceTxBuilder(testSchema, currentUow!)
473
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
474
+ .transformRetrieve(([users]) => users[0] ?? null)
475
+ .build();
476
+ };
408
477
 
409
- await executeUnitOfWork(
410
- {
411
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
412
- mutate: async (uow, [users]) => {
413
- uow.update("users", users[0].id, (b) => b.set({ balance: 200 }));
414
- },
415
- onSuccess,
478
+ const result = await createHandlerTxBuilder({
479
+ createUnitOfWork: () => {
480
+ currentUow = createUnitOfWork(compiler, executor, decoder);
481
+ return currentUow;
416
482
  },
417
- { createUnitOfWork: factory },
418
- );
483
+ })
484
+ .withServiceCalls(() => [getUserById()])
485
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
486
+ if (!user) {
487
+ return { ok: false as const };
488
+ }
489
+ const newUserId = forSchema(testSchema).create("users", {
490
+ email: "new@example.com",
491
+ name: "New User",
492
+ balance: 0,
493
+ });
494
+ return { ok: true as const, newUserId };
495
+ })
496
+ .execute();
419
497
 
420
- expect(onSuccess).toHaveBeenCalledTimes(1);
498
+ expect(result.ok).toBe(true);
499
+ if (result.ok) {
500
+ expect(result.newUserId).toBeInstanceOf(FragnoId);
501
+ }
421
502
  });
422
- });
423
-
424
- describe("error handling", () => {
425
- it("should return error result when retrieve callback throws", async () => {
426
- const { factory } = createMockUOWFactory([{ success: true }]);
427
- const testError = new Error("Retrieve failed");
428
503
 
429
- const result = await executeUnitOfWork(
430
- {
431
- retrieve: () => {
432
- throw testError;
433
- },
434
- mutate: async () => {},
435
- },
436
- { createUnitOfWork: factory },
437
- );
504
+ it("should execute a transaction with transform callback", async () => {
505
+ const compiler = createMockCompiler();
506
+ const mockUser = {
507
+ id: FragnoId.fromExternal("1", 1),
508
+ email: "test@example.com",
509
+ name: "Test",
510
+ balance: 100,
511
+ };
512
+ const executor: UOWExecutor<unknown, unknown> = {
513
+ executeRetrievalPhase: async () => [[mockUser]],
514
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
515
+ };
516
+ const decoder = createMockDecoder();
438
517
 
439
- assert(!result.success);
440
- assert(result.reason === "error");
441
- expect(result.error).toBe(testError);
442
- });
518
+ let currentUow: IUnitOfWork | null = null;
443
519
 
444
- it("should return error result when mutate callback throws", async () => {
445
- const { factory } = createMockUOWFactory([{ success: true }]);
446
- const testError = new Error("Mutate failed");
520
+ // Service that retrieves
521
+ const getUserById = () => {
522
+ return createServiceTxBuilder(testSchema, currentUow!)
523
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
524
+ .transformRetrieve(([users]) => users[0] ?? null)
525
+ .build();
526
+ };
447
527
 
448
- const result = await executeUnitOfWork(
449
- {
450
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
451
- mutate: async () => {
452
- throw testError;
453
- },
528
+ const result = await createHandlerTxBuilder({
529
+ createUnitOfWork: () => {
530
+ currentUow = createUnitOfWork(compiler, executor, decoder);
531
+ return currentUow;
454
532
  },
455
- { createUnitOfWork: factory },
456
- );
533
+ })
534
+ .withServiceCalls(() => [getUserById()])
535
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
536
+ if (!user) {
537
+ return { created: false as const };
538
+ }
539
+ const newUserId = forSchema(testSchema).create("users", {
540
+ email: "new@example.com",
541
+ name: "New User",
542
+ balance: 0,
543
+ });
544
+ return { created: true as const, newUserId };
545
+ })
546
+ .transform(({ serviceResult: [user], mutateResult }) => {
547
+ return {
548
+ originalUser: user,
549
+ mutationResult: mutateResult,
550
+ summary: "Transaction completed",
551
+ };
552
+ })
553
+ .execute();
457
554
 
458
- assert(!result.success);
459
- assert(result.reason === "error");
460
- expect(result.error).toBe(testError);
555
+ expect(result.summary).toBe("Transaction completed");
556
+ expect(result.originalUser).toEqual(mockUser);
557
+ expect(result.mutationResult?.created).toBe(true);
461
558
  });
462
559
 
463
- it("should return error result when onSuccess callback throws", async () => {
464
- const { factory } = createMockUOWFactory([{ success: true }]);
465
- const testError = new Error("onSuccess failed");
560
+ it("should execute a transaction with serviceCalls (service composition)", async () => {
561
+ const compiler = createMockCompiler();
562
+ const mockUser = {
563
+ id: FragnoId.fromExternal("1", 1),
564
+ email: "test@example.com",
565
+ name: "Test",
566
+ balance: 100,
567
+ };
568
+ const executor: UOWExecutor<unknown, unknown> = {
569
+ executeRetrievalPhase: async () => [[mockUser]],
570
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
571
+ };
572
+ const decoder = createMockDecoder();
573
+
574
+ let currentUow: IUnitOfWork | null = null;
575
+
576
+ // Simulate a service method that returns a TxResult
577
+ const getUserById = (userId: string) => {
578
+ return createServiceTxBuilder(testSchema, currentUow!)
579
+ .retrieve((uow) =>
580
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
581
+ )
582
+ .transformRetrieve(([users]) => users[0] ?? null)
583
+ .build();
584
+ };
466
585
 
467
- const result = await executeUnitOfWork(
468
- {
469
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
470
- mutate: async () => {},
471
- onSuccess: async () => {
472
- throw testError;
473
- },
586
+ const result = await createHandlerTxBuilder({
587
+ createUnitOfWork: () => {
588
+ currentUow = createUnitOfWork(compiler, executor, decoder);
589
+ return currentUow;
474
590
  },
475
- { createUnitOfWork: factory },
476
- );
477
-
478
- assert(!result.success);
479
- assert(result.reason === "error");
480
- expect(result.error).toBe(testError);
591
+ })
592
+ .withServiceCalls(() => [getUserById("1")])
593
+ .mutate(({ forSchema, serviceIntermediateResult: [user] }) => {
594
+ if (!user) {
595
+ return { ok: false as const };
596
+ }
597
+ const orderId = forSchema(testSchema).create("users", {
598
+ email: "order@example.com",
599
+ name: "Order",
600
+ balance: 0,
601
+ });
602
+ return { ok: true as const, orderId, forUser: user.email };
603
+ })
604
+ .execute();
605
+
606
+ expect(result.ok).toBe(true);
607
+ if (result.ok) {
608
+ expect(result.forUser).toBe("test@example.com");
609
+ expect(result.orderId).toBeInstanceOf(FragnoId);
610
+ }
481
611
  });
482
612
 
483
- it("should capture non-Error thrown values", async () => {
484
- const { factory } = createMockUOWFactory([{ success: true }]);
485
-
486
- const result = await executeUnitOfWork(
487
- {
488
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
489
- mutate: async () => {
490
- throw "string error";
491
- },
492
- },
493
- { createUnitOfWork: factory },
494
- );
495
-
496
- assert(!result.success);
497
- assert(result.reason === "error");
498
- expect(result.error).toBe("string error");
613
+ it("should type check serviceCalls with undefined (optional service pattern)", async () => {
614
+ const compiler = createMockCompiler();
615
+ const executor: UOWExecutor<unknown, unknown> = {
616
+ executeRetrievalPhase: async () => [],
617
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
618
+ };
619
+ const decoder = createMockDecoder();
620
+
621
+ // Simulate optional service pattern: optionalService?.method()
622
+ // When optionalService is undefined, this evaluates to undefined
623
+ // Use type assertion to prevent TypeScript from narrowing to literal undefined
624
+ const optionalService = undefined as
625
+ | { getUser: () => TxResult<{ name: string }, { name: string }> }
626
+ | undefined;
627
+
628
+ // This test demonstrates that serviceCalls can contain TxResult | undefined
629
+ // This is useful for optional service patterns like: optionalService?.method()
630
+ const result = await createHandlerTxBuilder({
631
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
632
+ })
633
+ .withServiceCalls(() => [optionalService?.getUser()])
634
+ .mutate(({ forSchema, serviceIntermediateResult: [maybeUser] }) => {
635
+ // maybeUser is typed as { name: string } | undefined
636
+ // This demonstrates the optional chaining pattern works correctly
637
+ expectTypeOf(maybeUser).toEqualTypeOf<{ name: string } | undefined>();
638
+ if (!maybeUser) {
639
+ const userId = forSchema(testSchema).create("users", {
640
+ email: "fallback@example.com",
641
+ name: "Fallback User",
642
+ balance: 0,
643
+ });
644
+ return { hadUser: false as const, userId };
645
+ }
646
+ return { hadUser: true as const, userName: maybeUser.name };
647
+ })
648
+ .execute();
649
+
650
+ // Since optionalService was undefined, maybeUser was undefined, so we hit the fallback path
651
+ expect(result.hadUser).toBe(false);
652
+ if (!result.hadUser) {
653
+ expect(result.userId).toBeInstanceOf(FragnoId);
654
+ }
499
655
  });
500
- });
501
-
502
- describe("retrieval results", () => {
503
- it("should pass retrieval results to mutation phase", async () => {
504
- const { factory } = createMockUOWFactory([{ success: true }]);
505
- const mutationPhase = vi.fn(async (_uow: unknown, _results: unknown) => {});
506
656
 
507
- await executeUnitOfWork(
508
- {
509
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
510
- mutate: mutationPhase,
511
- },
512
- { createUnitOfWork: factory },
513
- );
514
-
515
- expect(mutationPhase).toHaveBeenCalledTimes(1);
516
- const call = mutationPhase.mock.calls[0];
517
- assert(call);
518
- const [_uow, results] = call;
519
- expect(results).toBeInstanceOf(Array);
520
- expect(results as unknown[]).toHaveLength(1);
521
- expect((results as unknown[])[0]).toBeInstanceOf(Array);
522
- });
657
+ it("should handle serviceCalls with mix of TxResult and undefined", async () => {
658
+ const compiler = createMockCompiler();
659
+ const executor: UOWExecutor<unknown, unknown> = {
660
+ executeRetrievalPhase: async () => [],
661
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(1)] }),
662
+ };
663
+ const decoder = createMockDecoder();
664
+
665
+ let currentUow: IUnitOfWork | null = null;
666
+
667
+ // Type for the created data returned by the defined service
668
+ type CreatedData = { userId: FragnoId; generatedCode: string };
669
+ // Type for the extra data that would be returned by the optional service
670
+ type ExtraData = { extraCode: string; timestamp: number };
671
+
672
+ // One defined service with mutation that returns data
673
+ const definedService = {
674
+ createUserAndReturnCode: (): TxResult<CreatedData, CreatedData> =>
675
+ createServiceTxBuilder(testSchema, currentUow!)
676
+ .mutate(({ uow }): CreatedData => {
677
+ const userId = uow.create("users", {
678
+ email: "created@example.com",
679
+ name: "Created User",
680
+ balance: 0,
681
+ });
682
+ // Return arbitrary data from the mutation
683
+ return { userId, generatedCode: "ABC123" };
684
+ })
685
+ .build(),
686
+ };
523
687
 
524
- it("should return retrieval results in the result object", async () => {
525
- const { factory } = createMockUOWFactory([{ success: true }]);
688
+ // Optional service that would also return mutation data
689
+ const optionalService = undefined as
690
+ | {
691
+ generateExtra: () => TxResult<ExtraData, ExtraData>;
692
+ }
693
+ | undefined;
526
694
 
527
- const result = await executeUnitOfWork(
528
- {
529
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
530
- mutate: async () => {},
695
+ const result = await createHandlerTxBuilder({
696
+ createUnitOfWork: () => {
697
+ currentUow = createUnitOfWork(compiler, executor, decoder);
698
+ return currentUow;
531
699
  },
532
- { createUnitOfWork: factory },
533
- );
700
+ })
701
+ .withServiceCalls(
702
+ () =>
703
+ [definedService.createUserAndReturnCode(), optionalService?.generateExtra()] as const,
704
+ )
705
+ .mutate(({ forSchema, serviceIntermediateResult }) => {
706
+ // serviceIntermediateResult contains the mutation results from service calls
707
+ // (since service calls have no retrieveSuccess, the mutate result becomes the retrieve result for dependents)
708
+ const [createdData, maybeExtra] = serviceIntermediateResult;
709
+
710
+ // Type checks: createdData should have userId and generatedCode
711
+ // maybeExtra should be ExtraData | undefined
712
+ expectTypeOf(createdData).toEqualTypeOf<CreatedData>();
713
+ expectTypeOf(maybeExtra).toEqualTypeOf<ExtraData | undefined>();
714
+
715
+ forSchema(testSchema).create("users", {
716
+ email: "handler@example.com",
717
+ name: "Handler User",
718
+ balance: 0,
719
+ });
534
720
 
535
- assert(result.success);
536
- expect(result.results).toBeInstanceOf(Array);
537
- expect(result.results).toHaveLength(1);
538
- });
539
- });
721
+ return {
722
+ depCode: createdData.generatedCode,
723
+ hadExtra: maybeExtra !== undefined,
724
+ };
725
+ })
726
+ .transform(({ serviceResult, serviceIntermediateResult, mutateResult }) => {
727
+ // Verify serviceResult types - these are the FINAL results from serviceCalls
728
+ const [finalCreatedData, maybeFinalExtra] = serviceResult;
540
729
 
541
- describe("promise awaiting in mutation result", () => {
542
- it("should await promises in mutation result object (1 level deep)", async () => {
543
- const { factory } = createMockUOWFactory([{ success: true }]);
730
+ // Type check: serviceResult should have same structure
731
+ // The final result is CreatedData (since there's no transform callback on the dep)
732
+ expectTypeOf(finalCreatedData).toEqualTypeOf<CreatedData>();
733
+ expectTypeOf(maybeFinalExtra).toEqualTypeOf<ExtraData | undefined>();
544
734
 
545
- const result = await executeUnitOfWork(
546
- {
547
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
548
- mutate: async () => {
549
- return {
550
- userId: Promise.resolve("user-123"),
551
- count: Promise.resolve(42),
552
- data: "plain-value",
553
- };
554
- },
555
- },
556
- { createUnitOfWork: factory },
557
- );
558
-
559
- assert(result.success);
560
- expect(result.mutationResult).toEqual({
561
- userId: "user-123",
562
- count: 42,
563
- data: "plain-value",
564
- });
565
- });
735
+ // serviceIntermediateResult should still be accessible in transform
736
+ const [_retrieveData, maybeRetrieveExtra] = serviceIntermediateResult;
737
+ expectTypeOf(maybeRetrieveExtra).toEqualTypeOf<ExtraData | undefined>();
566
738
 
567
- it("should await promises in mutation result array", async () => {
568
- const { factory } = createMockUOWFactory([{ success: true }]);
739
+ return {
740
+ ...mutateResult,
741
+ finalDepUserId: finalCreatedData.userId,
742
+ finalDepCode: finalCreatedData.generatedCode,
743
+ extraWasUndefined: maybeFinalExtra === undefined,
744
+ };
745
+ })
746
+ .execute();
747
+
748
+ // Verify runtime behavior
749
+ expect(result.depCode).toBe("ABC123");
750
+ expect(result.hadExtra).toBe(false);
751
+ expect(result.finalDepCode).toBe("ABC123");
752
+ expect(result.extraWasUndefined).toBe(true);
753
+ expect(result.finalDepUserId).toBeInstanceOf(FragnoId);
754
+ });
569
755
 
570
- const result = await executeUnitOfWork(
571
- {
572
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
573
- mutate: async () => {
574
- return [Promise.resolve("a"), Promise.resolve("b"), "c"];
575
- },
756
+ it("should retry on concurrency conflict", async () => {
757
+ const compiler = createMockCompiler();
758
+ let mutationAttempts = 0;
759
+ const executor: UOWExecutor<unknown, unknown> = {
760
+ executeRetrievalPhase: async () => [],
761
+ executeMutationPhase: async () => {
762
+ mutationAttempts++;
763
+ if (mutationAttempts < 3) {
764
+ return { success: false };
765
+ }
766
+ return { success: true, createdInternalIds: [] };
576
767
  },
577
- { createUnitOfWork: factory },
578
- );
579
-
580
- assert(result.success);
581
- expect(result.mutationResult).toEqual(["a", "b", "c"]);
768
+ };
769
+ const decoder = createMockDecoder();
770
+
771
+ const result = await createHandlerTxBuilder({
772
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
773
+ retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
774
+ })
775
+ .mutate(({ forSchema }) => {
776
+ forSchema(testSchema).create("users", {
777
+ email: "test@example.com",
778
+ name: "Test",
779
+ balance: 0,
780
+ });
781
+ return { createdAt: Date.now() };
782
+ })
783
+ .execute();
784
+
785
+ // Verify we retried the correct number of times
786
+ expect(mutationAttempts).toBe(3);
787
+ // Verify we got a result
788
+ expect(result.createdAt).toBeGreaterThan(0);
582
789
  });
583
790
 
584
- it("should await direct promise mutation result", async () => {
585
- const { factory } = createMockUOWFactory([{ success: true }]);
586
-
587
- const result = await executeUnitOfWork(
588
- {
589
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
590
- mutate: async () => {
591
- return Promise.resolve({ value: "resolved" });
592
- },
593
- },
594
- { createUnitOfWork: factory },
595
- );
791
+ it("should throw ConcurrencyConflictError when retries are exhausted", async () => {
792
+ const compiler = createMockCompiler();
793
+ const executor: UOWExecutor<unknown, unknown> = {
794
+ executeRetrievalPhase: async () => [],
795
+ executeMutationPhase: async () => ({ success: false }),
796
+ };
797
+ const decoder = createMockDecoder();
596
798
 
597
- assert(result.success);
598
- expect(result.mutationResult).toEqual({ value: "resolved" });
799
+ await expect(
800
+ createHandlerTxBuilder({
801
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
802
+ retryPolicy: new NoRetryPolicy(),
803
+ })
804
+ .mutate(() => ({ done: true }))
805
+ .execute(),
806
+ ).rejects.toThrow(ConcurrencyConflictError);
599
807
  });
600
808
 
601
- it("should NOT await nested promises (only 1 level deep)", async () => {
602
- const { factory } = createMockUOWFactory([{ success: true }]);
809
+ it("should abort when signal is aborted", async () => {
810
+ const compiler = createMockCompiler();
811
+ const executor: UOWExecutor<unknown, unknown> = {
812
+ executeRetrievalPhase: async () => [],
813
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
814
+ };
815
+ const decoder = createMockDecoder();
603
816
 
604
- const result = await executeUnitOfWork(
605
- {
606
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
607
- mutate: async () => {
608
- return {
609
- nested: { promise: Promise.resolve("still-a-promise") },
610
- };
611
- },
612
- },
613
- { createUnitOfWork: factory },
614
- );
817
+ const controller = new AbortController();
818
+ controller.abort();
615
819
 
616
- assert(result.success);
617
- // The nested promise should still be a promise
618
- expect(result.mutationResult.nested.promise).toBeInstanceOf(Promise);
820
+ await expect(
821
+ createHandlerTxBuilder({
822
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
823
+ signal: controller.signal,
824
+ })
825
+ .mutate(() => ({ done: true }))
826
+ .execute(),
827
+ ).rejects.toThrow("Transaction execution aborted");
619
828
  });
620
829
 
621
- it("should handle mixed types in mutation result", async () => {
622
- const { factory } = createMockUOWFactory([{ success: true }]);
623
-
624
- const result = await executeUnitOfWork(
625
- {
626
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
627
- mutate: async () => {
628
- return {
629
- promise: Promise.resolve(100),
630
- number: 42,
631
- string: "test",
632
- null: null,
633
- undefined: undefined,
634
- nested: { value: "nested" },
635
- };
636
- },
830
+ it("should create fresh UOW on each retry attempt", async () => {
831
+ const compiler = createMockCompiler();
832
+ let callCount = 0;
833
+ let mutationAttempts = 0;
834
+ const executor: UOWExecutor<unknown, unknown> = {
835
+ executeRetrievalPhase: async () => [],
836
+ executeMutationPhase: async () => {
837
+ mutationAttempts++;
838
+ if (mutationAttempts < 3) {
839
+ return { success: false };
840
+ }
841
+ return { success: true, createdInternalIds: [] };
637
842
  },
638
- { createUnitOfWork: factory },
639
- );
640
-
641
- assert(result.success);
642
- expect(result.mutationResult).toEqual({
643
- promise: 100,
644
- number: 42,
645
- string: "test",
646
- null: null,
647
- undefined: undefined,
648
- nested: { value: "nested" },
649
- });
650
- });
651
-
652
- it("should pass awaited mutation result to onSuccess callback", async () => {
653
- const { factory } = createMockUOWFactory([{ success: true }]);
654
- const onSuccess = vi.fn();
843
+ };
844
+ const decoder = createMockDecoder();
655
845
 
656
- await executeUnitOfWork(
657
- {
658
- retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
659
- mutate: async () => {
660
- return {
661
- userId: Promise.resolve("user-456"),
662
- status: Promise.resolve("active"),
663
- };
664
- },
665
- onSuccess,
846
+ await createHandlerTxBuilder({
847
+ createUnitOfWork: () => {
848
+ callCount++;
849
+ return createUnitOfWork(compiler, executor, decoder);
666
850
  },
667
- { createUnitOfWork: factory },
668
- );
669
-
670
- expect(onSuccess).toHaveBeenCalledExactlyOnceWith({
671
- results: expect.any(Array),
672
- mutationResult: {
673
- userId: "user-456",
674
- status: "active",
675
- },
676
- createdIds: [],
677
- nonce: expect.any(String),
678
- });
679
- });
680
- });
681
- });
682
-
683
- describe("executeRestrictedUnitOfWork", () => {
684
- describe("basic success cases", () => {
685
- it("should execute a simple mutation-only workflow", async () => {
686
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
687
-
688
- const result = await executeRestrictedUnitOfWork(
689
- async ({ forSchema, executeMutate }) => {
690
- const uow = forSchema(testSchema);
691
- const userId = uow.create("users", {
692
- id: "user-1",
851
+ retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 3, delayMs: 1 }),
852
+ })
853
+ .mutate(({ forSchema }) => {
854
+ forSchema(testSchema).create("users", {
693
855
  email: "test@example.com",
694
- name: "Test User",
695
- balance: 100,
856
+ name: "Test",
857
+ balance: 0,
696
858
  });
859
+ })
860
+ .execute();
697
861
 
698
- await executeMutate();
699
-
700
- return { userId: userId.externalId };
701
- },
702
- { createUnitOfWork: factory },
703
- );
704
-
705
- expect(result).toEqual({ userId: "user-1" });
706
- expect(callCount.value).toBe(1);
862
+ // Verify factory was called for each attempt (initial + 2 retries)
863
+ expect(callCount).toBe(3);
707
864
  });
708
865
 
709
- it("should execute retrieval and mutation phases", async () => {
710
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
711
-
712
- const result = await executeRestrictedUnitOfWork(
713
- async ({ forSchema, executeRetrieve, executeMutate }) => {
714
- const uow = forSchema(testSchema).find("users", (b) => b.whereIndex("primary"));
715
- await executeRetrieve();
716
- const [[user]] = await uow.retrievalPhase;
866
+ it("should abort when signal is aborted during retry delay", async () => {
867
+ const compiler = createMockCompiler();
868
+ const executor: UOWExecutor<unknown, unknown> = {
869
+ executeRetrievalPhase: async () => [],
870
+ executeMutationPhase: async () => ({ success: false }),
871
+ };
872
+ const decoder = createMockDecoder();
717
873
 
718
- uow.update("users", user.id, (b) => b.set({ balance: user.balance + 50 }).check());
719
- await executeMutate();
874
+ const controller = new AbortController();
720
875
 
721
- return { newBalance: user.balance + 50 };
722
- },
723
- { createUnitOfWork: factory },
724
- );
876
+ // Abort after first attempt during retry delay
877
+ setTimeout(() => controller.abort(), 50);
725
878
 
726
- expect(result).toEqual({ newBalance: 150 });
727
- expect(callCount.value).toBe(1);
879
+ await expect(
880
+ createHandlerTxBuilder({
881
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
882
+ retryPolicy: new LinearBackoffRetryPolicy({ maxRetries: 5, delayMs: 100 }),
883
+ signal: controller.signal,
884
+ })
885
+ .mutate(() => ({ done: true }))
886
+ .execute(),
887
+ ).rejects.toThrow("Transaction execution aborted");
728
888
  });
729
889
 
730
- it("should return callback result directly", async () => {
731
- const { factory } = createMockUOWFactory([{ success: true }]);
890
+ it("should pass serviceResult to transform callback with final results", async () => {
891
+ const compiler = createMockCompiler();
892
+ const mockUser = {
893
+ id: FragnoId.fromExternal("1", 1),
894
+ email: "test@example.com",
895
+ name: "Test",
896
+ balance: 100,
897
+ };
898
+ const executor: UOWExecutor<unknown, unknown> = {
899
+ executeRetrievalPhase: async () => [[mockUser]],
900
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [BigInt(2)] }),
901
+ };
902
+ const decoder = createMockDecoder();
903
+
904
+ let currentUow: IUnitOfWork | null = null;
905
+
906
+ // Service that retrieves and mutates
907
+ const getUserAndUpdateBalance = (userId: string) => {
908
+ return createServiceTxBuilder(testSchema, currentUow!)
909
+ .retrieve((uow) =>
910
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
911
+ )
912
+ .transformRetrieve(([users]) => users[0] ?? null)
913
+ .mutate(({ uow, retrieveResult: user }) => {
914
+ expect(user).toEqual(mockUser);
915
+ expectTypeOf(user).toEqualTypeOf<typeof mockUser>();
916
+ if (!user) {
917
+ return { updated: false as const };
918
+ }
919
+ uow.update("users", user.id, (b) => b.set({ balance: user.balance + 100 }).check());
920
+ return { updated: true as const, newBalance: user.balance + 100 };
921
+ })
922
+ .build();
923
+ };
732
924
 
733
- const result = await executeRestrictedUnitOfWork(
734
- async () => {
735
- return { data: "test", count: 42, nested: { value: true } };
925
+ const result = await createHandlerTxBuilder({
926
+ createUnitOfWork: () => {
927
+ currentUow = createUnitOfWork(compiler, executor, decoder);
928
+ return currentUow;
736
929
  },
737
- { createUnitOfWork: factory },
738
- );
930
+ })
931
+ .withServiceCalls(() => [getUserAndUpdateBalance("1")])
932
+ .transform(
933
+ ({ serviceResult: [depResult], serviceIntermediateResult: [depRetrieveResult] }) => {
934
+ expect(depResult).toEqual({
935
+ updated: true,
936
+ newBalance: 200,
937
+ });
938
+
939
+ expect(depRetrieveResult).toEqual(mockUser);
940
+
941
+ const dep = depResult;
942
+ return {
943
+ depUpdated: dep.updated,
944
+ depNewBalance: dep.updated ? dep.newBalance : null,
945
+ };
946
+ },
947
+ )
948
+ .execute();
739
949
 
740
- expect(result).toEqual({ data: "test", count: 42, nested: { value: true } });
950
+ expect(result.depUpdated).toBe(true);
951
+ expect(result.depNewBalance).toBe(200);
741
952
  });
742
953
  });
743
954
 
744
- describe("retry behavior", () => {
745
- it("should retry on conflict and eventually succeed", async () => {
746
- const { factory, callCount } = createMockUOWFactory([
747
- { success: false }, // First attempt fails
748
- { success: false }, // Second attempt fails
749
- { success: true }, // Third attempt succeeds
750
- ]);
751
-
752
- const callbackExecutions = { count: 0 };
753
-
754
- const result = await executeRestrictedUnitOfWork(
755
- async ({ forSchema, executeMutate }) => {
756
- callbackExecutions.count++;
757
- const uow = forSchema(testSchema);
758
-
759
- uow.create("users", {
760
- id: "user-1",
761
- email: "test@example.com",
762
- name: "Test User",
763
- balance: 100,
764
- });
955
+ describe("return type priority", () => {
956
+ it("should return transform result when transform callback is provided", async () => {
957
+ const compiler = createMockCompiler();
958
+ const executor: UOWExecutor<unknown, unknown> = {
959
+ executeRetrievalPhase: async () => [],
960
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
961
+ };
962
+ const decoder = createMockDecoder();
963
+
964
+ const result = await createHandlerTxBuilder({
965
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
966
+ })
967
+ .retrieve(() => {
968
+ // Empty retrieve - defaults to empty array
969
+ })
970
+ .transformRetrieve((ctx) => {
971
+ expectTypeOf(ctx).toEqualTypeOf<unknown[]>();
972
+
973
+ return "retrieveSuccess result" as const;
974
+ })
975
+ .mutate((ctx) => {
976
+ expectTypeOf(ctx.retrieveResult).toEqualTypeOf<"retrieveSuccess result">();
977
+
978
+ return "mutate result" as const;
979
+ })
980
+ .transform((ctx) => {
981
+ expectTypeOf(ctx.retrieveResult).toEqualTypeOf<"retrieveSuccess result">();
982
+ // mutateResult is NOT | undefined because mutate callback IS provided
983
+ expectTypeOf(ctx.mutateResult).toEqualTypeOf<"mutate result">();
984
+ // serviceResult and serviceIntermediateResult are empty tuples since no service calls callback
985
+ expectTypeOf(ctx.serviceResult).toEqualTypeOf<readonly []>();
986
+ expectTypeOf(ctx.serviceIntermediateResult).toEqualTypeOf<readonly []>();
987
+
988
+ return "success result" as const;
989
+ })
990
+ .execute();
991
+
992
+ expect(result).toBe("success result");
993
+ });
765
994
 
766
- await executeMutate();
995
+ it("should return mutate result when no transform callback", async () => {
996
+ const compiler = createMockCompiler();
997
+ const executor: UOWExecutor<unknown, unknown> = {
998
+ executeRetrievalPhase: async () => [],
999
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1000
+ };
1001
+ const decoder = createMockDecoder();
767
1002
 
768
- return { attempt: callbackExecutions.count };
769
- },
770
- { createUnitOfWork: factory },
771
- );
1003
+ const result = await createHandlerTxBuilder({
1004
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1005
+ })
1006
+ .transformRetrieve(() => "retrieveSuccess result")
1007
+ .mutate(() => "mutate result")
1008
+ .execute();
772
1009
 
773
- expect(result.attempt).toBe(3);
774
- expect(callCount.value).toBe(3);
775
- expect(callbackExecutions.count).toBe(3);
1010
+ expect(result).toBe("mutate result");
776
1011
  });
777
1012
 
778
- it("should throw error when retries are exhausted", async () => {
779
- const { factory, callCount } = createMockUOWFactory([
780
- { success: false }, // First attempt fails
781
- { success: false }, // Second attempt fails
782
- { success: false }, // Third attempt fails
783
- { success: false }, // Fourth attempt fails (exceeds default maxRetries: 3)
784
- ]);
1013
+ it("should return transformRetrieve result when no mutate or transform", async () => {
1014
+ const compiler = createMockCompiler();
1015
+ const executor: UOWExecutor<unknown, unknown> = {
1016
+ executeRetrievalPhase: async () => [],
1017
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1018
+ };
1019
+ const decoder = createMockDecoder();
785
1020
 
786
- await expect(
787
- executeRestrictedUnitOfWork(
788
- async ({ executeMutate }) => {
789
- await executeMutate();
790
- return { hello: "world" };
791
- },
792
- { createUnitOfWork: factory },
793
- ),
794
- ).rejects.toThrow("Unit of Work execution failed: optimistic concurrency conflict");
1021
+ const result = await createHandlerTxBuilder({
1022
+ createUnitOfWork: () => createUnitOfWork(compiler, executor, decoder),
1023
+ })
1024
+ .transformRetrieve(() => "retrieveSuccess result")
1025
+ .execute();
795
1026
 
796
- // Default policy has maxRetries: 5, so we make 6 attempts (initial + 5 retries)
797
- expect(callCount.value).toBe(6);
1027
+ expect(result).toBe("retrieveSuccess result");
798
1028
  });
799
1029
 
800
- it("should respect custom retry policy", async () => {
801
- const { factory, callCount } = createMockUOWFactory([
802
- { success: false },
803
- { success: false },
804
- { success: false },
805
- { success: false },
806
- { success: false },
807
- { success: true },
808
- ]);
809
-
810
- const result = await executeRestrictedUnitOfWork(
811
- async ({ executeMutate }) => {
812
- await executeMutate();
813
- return { done: true };
814
- },
815
- {
816
- createUnitOfWork: factory,
817
- retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5, initialDelayMs: 1 }),
818
- },
819
- );
1030
+ it("should return serviceCalls final results when no local callbacks", async () => {
1031
+ const compiler = createMockCompiler();
1032
+ const mockUser = {
1033
+ id: FragnoId.fromExternal("1", 1),
1034
+ email: "test@example.com",
1035
+ name: "Test",
1036
+ balance: 100,
1037
+ };
1038
+ const executor: UOWExecutor<unknown, unknown> = {
1039
+ executeRetrievalPhase: async () => [[mockUser]],
1040
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1041
+ };
1042
+ const decoder = createMockDecoder();
820
1043
 
821
- expect(result).toEqual({ done: true });
822
- expect(callCount.value).toBe(6); // Initial + 5 retries
823
- });
1044
+ let currentUow: IUnitOfWork | null = null;
824
1045
 
825
- it("should use default ExponentialBackoffRetryPolicy with small delays", async () => {
826
- const { factory } = createMockUOWFactory([{ success: false }, { success: true }]);
1046
+ // Service that just retrieves
1047
+ const getUser = () => {
1048
+ return createServiceTxBuilder(testSchema, currentUow!)
1049
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1050
+ .transformRetrieve(([users]) => users[0] ?? null)
1051
+ .build();
1052
+ };
827
1053
 
828
- const startTime = Date.now();
829
- await executeRestrictedUnitOfWork(
830
- async ({ executeMutate }) => {
831
- await executeMutate();
832
- return {};
1054
+ // executeTx with only serviceCalls - should return serviceCalls' final results
1055
+ const result = await createHandlerTxBuilder({
1056
+ createUnitOfWork: () => {
1057
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1058
+ return currentUow;
833
1059
  },
834
- { createUnitOfWork: factory },
835
- );
836
- const elapsed = Date.now() - startTime;
1060
+ })
1061
+ .withServiceCalls(() => [getUser()])
1062
+ .execute();
837
1063
 
838
- // Default policy has initialDelayMs: 10, maxDelayMs: 100
839
- // First retry delay should be around 10ms
840
- expect(elapsed).toBeLessThan(200); // Allow some margin
1064
+ expect(result).toEqual([mockUser]);
841
1065
  });
842
1066
  });
843
1067
 
844
- describe("error handling", () => {
845
- it("should throw error from callback immediately without retry", async () => {
846
- const { factory, callCount } = createMockUOWFactory([{ success: true }]);
847
-
848
- await expect(
849
- executeRestrictedUnitOfWork(
850
- async () => {
851
- throw new Error("Callback error");
852
- },
853
- { createUnitOfWork: factory },
854
- ),
855
- ).rejects.toThrow("Callback error");
856
-
857
- // Should NOT retry non-conflict errors
858
- expect(callCount.value).toBe(1); // Only initial attempt
859
- });
860
-
861
- it("should throw callback error directly", async () => {
862
- const { factory } = createMockUOWFactory([{ success: true }]);
863
- const originalError = new Error("Original error");
864
-
865
- try {
866
- await executeRestrictedUnitOfWork(
867
- async () => {
868
- throw originalError;
869
- },
870
- {
871
- createUnitOfWork: factory,
872
- retryPolicy: new NoRetryPolicy(), // Don't retry
873
- },
1068
+ describe("serviceResult vs serviceIntermediateResult", () => {
1069
+ it("serviceIntermediateResult in mutate should contain transformRetrieve results", async () => {
1070
+ const compiler = createMockCompiler();
1071
+ const mockUser = {
1072
+ id: FragnoId.fromExternal("1", 1),
1073
+ email: "test@example.com",
1074
+ name: "Test",
1075
+ balance: 100,
1076
+ };
1077
+ const executor: UOWExecutor<unknown, unknown> = {
1078
+ executeRetrievalPhase: async () => [[mockUser]],
1079
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1080
+ };
1081
+ const decoder = createMockDecoder();
1082
+
1083
+ let currentUow: IUnitOfWork | null = null;
1084
+ let capturedServiceIntermediateResult: unknown[] = [];
1085
+
1086
+ const getUserById = () => {
1087
+ return (
1088
+ createServiceTxBuilder(testSchema, currentUow!)
1089
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1090
+ // This transformRetrieve transforms the result
1091
+ .transformRetrieve(([users]) => ({ transformed: true, user: users[0] }))
1092
+ .build()
874
1093
  );
875
- expect.fail("Should have thrown");
876
- } catch (error) {
877
- // Error should be thrown directly, not wrapped
878
- expect(error).toBe(originalError);
879
- }
880
- });
881
- });
882
-
883
- describe("abort signal", () => {
884
- it("should throw when aborted before execution", async () => {
885
- const { factory } = createMockUOWFactory([{ success: true }]);
886
- const controller = new AbortController();
887
- controller.abort();
888
-
889
- await expect(
890
- executeRestrictedUnitOfWork(
891
- async () => {
892
- return {};
893
- },
894
- { createUnitOfWork: factory, signal: controller.signal },
895
- ),
896
- ).rejects.toThrow("Unit of Work execution aborted");
897
- });
898
-
899
- it("should stop retrying when aborted during retry", async () => {
900
- const { factory, callCount } = createMockUOWFactory([
901
- { success: false },
902
- { success: false },
903
- { success: true },
904
- ]);
905
- const controller = new AbortController();
1094
+ };
906
1095
 
907
- const promise = executeRestrictedUnitOfWork(
908
- async ({ executeMutate }) => {
909
- if (callCount.value === 2) {
910
- controller.abort();
911
- }
912
- await executeMutate();
913
- return {};
1096
+ await createHandlerTxBuilder({
1097
+ createUnitOfWork: () => {
1098
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1099
+ return currentUow;
914
1100
  },
915
- { createUnitOfWork: factory, signal: controller.signal },
916
- );
1101
+ })
1102
+ .withServiceCalls(() => [getUserById()])
1103
+ .mutate(({ serviceIntermediateResult }) => {
1104
+ // Should receive the transformed (transformRetrieve) result
1105
+ capturedServiceIntermediateResult = [...serviceIntermediateResult];
1106
+ return { done: true };
1107
+ })
1108
+ .execute();
917
1109
 
918
- await expect(promise).rejects.toThrow("Unit of Work execution aborted");
919
- expect(callCount.value).toBeLessThanOrEqual(2);
1110
+ expect(capturedServiceIntermediateResult[0]).toEqual({ transformed: true, user: mockUser });
920
1111
  });
921
- });
922
1112
 
923
- describe("restricted UOW interface", () => {
924
- it("should provide access to forSchema", async () => {
925
- const { factory } = createMockUOWFactory([{ success: true }]);
926
-
927
- await executeRestrictedUnitOfWork(
928
- async ({ forSchema }) => {
929
- const uow = forSchema(testSchema);
930
- expect(uow).toBeDefined();
931
- expect(uow.schema).toBe(testSchema);
932
- return {};
933
- },
934
- { createUnitOfWork: factory },
935
- );
936
- });
937
-
938
- it("should allow creating entities via forSchema", async () => {
939
- const { factory } = createMockUOWFactory([{ success: true }]);
940
-
941
- const result = await executeRestrictedUnitOfWork(
942
- async ({ forSchema, executeRetrieve, executeMutate }) => {
943
- const uow = forSchema(testSchema);
944
- await executeRetrieve();
945
-
946
- const userId = uow.create("users", {
947
- id: "user-123",
948
- email: "test@example.com",
949
- name: "Test",
950
- balance: 0,
951
- });
952
-
953
- await executeMutate();
1113
+ it("serviceResult in transform should contain final (mutate) results", async () => {
1114
+ const compiler = createMockCompiler();
1115
+ const mockUser = {
1116
+ id: FragnoId.fromExternal("1", 1),
1117
+ email: "test@example.com",
1118
+ name: "Test",
1119
+ balance: 100,
1120
+ };
1121
+ const executor: UOWExecutor<unknown, unknown> = {
1122
+ executeRetrievalPhase: async () => [[mockUser]],
1123
+ executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1124
+ };
1125
+ const decoder = createMockDecoder();
1126
+
1127
+ let currentUow: IUnitOfWork | null = null;
1128
+ let capturedServiceResult: unknown[] = [];
1129
+
1130
+ const getUserById = () => {
1131
+ return (
1132
+ createServiceTxBuilder(testSchema, currentUow!)
1133
+ .retrieve((uow) => uow.find("users", (b) => b.whereIndex("idx_email")))
1134
+ .transformRetrieve(([users]) => users[0])
1135
+ // This mutate returns a different result
1136
+ .mutate(({ retrieveResult: user }) => ({ mutated: true, userId: user?.id }))
1137
+ .build()
1138
+ );
1139
+ };
954
1140
 
955
- return { userId };
1141
+ await createHandlerTxBuilder({
1142
+ createUnitOfWork: () => {
1143
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1144
+ return currentUow;
956
1145
  },
957
- { createUnitOfWork: factory },
958
- );
1146
+ })
1147
+ .withServiceCalls(() => [getUserById()])
1148
+ .transform(({ serviceResult }) => {
1149
+ // Should receive the mutate result (final), not transformRetrieve result
1150
+ capturedServiceResult = [...serviceResult];
1151
+ return { done: true };
1152
+ })
1153
+ .execute();
959
1154
 
960
- expect(result.userId).toBeInstanceOf(FragnoId);
961
- expect(result.userId.externalId).toBe("user-123");
1155
+ expect(capturedServiceResult[0]).toEqual({ mutated: true, userId: mockUser.id });
962
1156
  });
963
- });
964
1157
 
965
- describe("promise awaiting in callback result", () => {
966
- it("should await promises in result object (1 level deep)", async () => {
967
- const { factory } = createMockUOWFactory([{ success: true }]);
1158
+ it("serviceResult in transform should contain mutateResult for mutate-only serviceCalls (via processTxResultAfterMutate)", async () => {
1159
+ // This test exercises the buggy code path in processTxResultAfterMutate:
1160
+ // When a nested TxResult has a transform callback and its serviceCall is mutate-only,
1161
+ // the transform callback should receive the mutateResult in
1162
+ // serviceResult, NOT the empty array.
1163
+ //
1164
+ // The key is that the nested service itself (wrapperService) has a transform callback,
1165
+ // so processTxResultAfterMutate is called for it.
1166
+ const compiler = createMockCompiler();
1167
+ const executor: UOWExecutor<unknown, unknown> = {
1168
+ executeRetrievalPhase: async () => [],
1169
+ executeMutationPhase: async () => ({
1170
+ success: true,
1171
+ createdInternalIds: [BigInt(1)],
1172
+ }),
1173
+ };
1174
+ const decoder = createMockDecoder();
1175
+
1176
+ let currentUow: IUnitOfWork | null = null;
1177
+ let capturedServiceIntermediateResultInNestedTransform: unknown[] = [];
1178
+
1179
+ // Mutate-only service - no retrieve or transformRetrieve callbacks
1180
+ const createItem = () => {
1181
+ return (
1182
+ createServiceTxBuilder(testSchema, currentUow!)
1183
+ // NO retrieve or transformRetrieve - this is a mutate-only dep
1184
+ .mutate(({ uow }) => {
1185
+ const itemId = uow.create("users", {
1186
+ email: "new-item@example.com",
1187
+ name: "New Item",
1188
+ balance: 0,
1189
+ });
1190
+ return { created: true, itemId };
1191
+ })
1192
+ .build()
1193
+ );
1194
+ };
1195
+
1196
+ // Wrapper service that has a transform callback and uses createItem as a dep
1197
+ // This forces processTxResultAfterMutate to be called for this TxResult
1198
+ const wrapperService = () => {
1199
+ return (
1200
+ createServiceTxBuilder(testSchema, currentUow!)
1201
+ .withServiceCalls(() => [createItem()] as const)
1202
+ // NO mutate callback - just pass through
1203
+ // The transform callback is the key: it makes processTxResultAfterMutate get called
1204
+ .transform(({ serviceResult, serviceIntermediateResult }) => {
1205
+ capturedServiceIntermediateResultInNestedTransform = [...serviceIntermediateResult];
1206
+ // serviceResult should equal serviceIntermediateResult since dep has no transform callback
1207
+ expect(serviceResult[0]).toEqual(serviceIntermediateResult[0]);
1208
+ return { wrapped: true, innerResult: serviceIntermediateResult[0] };
1209
+ })
1210
+ .build()
1211
+ );
1212
+ };
968
1213
 
969
- const result = await executeRestrictedUnitOfWork(
970
- async ({ executeMutate }) => {
971
- await executeMutate();
972
- return {
973
- userId: Promise.resolve("user-123"),
974
- profileId: Promise.resolve("profile-456"),
975
- status: "completed",
976
- };
1214
+ const result = await createHandlerTxBuilder({
1215
+ createUnitOfWork: () => {
1216
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1217
+ return currentUow;
977
1218
  },
978
- { createUnitOfWork: factory },
979
- );
1219
+ })
1220
+ .withServiceCalls(() => [wrapperService()] as const)
1221
+ .transform(({ serviceResult: [wrapperResult] }) => wrapperResult)
1222
+ .execute();
1223
+
1224
+ // The wrapper service's transform callback should have received the mutateResult, NOT empty array
1225
+ expect(capturedServiceIntermediateResultInNestedTransform[0]).toEqual({
1226
+ created: true,
1227
+ itemId: expect.any(FragnoId),
1228
+ });
1229
+ // Verify it's not the empty array sentinel
1230
+ expect(capturedServiceIntermediateResultInNestedTransform[0]).not.toEqual([]);
980
1231
 
1232
+ // And the handler should get the wrapped result
981
1233
  expect(result).toEqual({
982
- userId: "user-123",
983
- profileId: "profile-456",
984
- status: "completed",
1234
+ wrapped: true,
1235
+ innerResult: { created: true, itemId: expect.any(FragnoId) },
985
1236
  });
986
1237
  });
1238
+ });
987
1239
 
988
- it("should await promises in result array", async () => {
989
- const { factory } = createMockUOWFactory([{ success: true }]);
990
-
991
- const result = await executeRestrictedUnitOfWork(
992
- async ({ executeMutate }) => {
993
- await executeMutate();
994
- return [Promise.resolve(1), Promise.resolve(2), 3];
1240
+ describe("nested TxResult serviceCalls (service composition)", () => {
1241
+ it("should collect nested serviceCalls in dependency order", async () => {
1242
+ // Simpler test to verify collectAllTxResults works correctly
1243
+ const compiler = createMockCompiler();
1244
+ const mockUser = {
1245
+ id: FragnoId.fromExternal("user-1", 1),
1246
+ email: "test@example.com",
1247
+ name: "Test User",
1248
+ balance: 100,
1249
+ };
1250
+ let retrievePhaseExecuted = false;
1251
+ const executor: UOWExecutor<unknown, unknown> = {
1252
+ executeRetrievalPhase: async () => {
1253
+ retrievePhaseExecuted = true;
1254
+ return [[mockUser]];
995
1255
  },
996
- { createUnitOfWork: factory },
997
- );
998
-
999
- expect(result).toEqual([1, 2, 3]);
1000
- });
1256
+ executeMutationPhase: async () => ({
1257
+ success: true,
1258
+ createdInternalIds: [],
1259
+ }),
1260
+ };
1261
+ const decoder = createMockDecoder();
1262
+
1263
+ let currentUow: IUnitOfWork | null = null;
1264
+ let getUserByIdCalled = false;
1265
+ let validateUserCalled = false;
1266
+
1267
+ // Simple service that retrieves - no nested serviceCalls
1268
+ const getUserById = (userId: string) => {
1269
+ getUserByIdCalled = true;
1270
+ if (!currentUow) {
1271
+ throw new Error("currentUow is null in getUserById!");
1272
+ }
1273
+ return createServiceTxBuilder(testSchema, currentUow)
1274
+ .retrieve((uow) =>
1275
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1276
+ )
1277
+ .transformRetrieve(([users]) => users[0] ?? null)
1278
+ .build();
1279
+ };
1001
1280
 
1002
- it("should await direct promise result", async () => {
1003
- const { factory } = createMockUOWFactory([{ success: true }]);
1281
+ // Service with serviceCalls - depends on getUserById
1282
+ const validateUser = (userId: string) => {
1283
+ validateUserCalled = true;
1284
+ if (!currentUow) {
1285
+ throw new Error("currentUow is null in validateUser!");
1286
+ }
1287
+ return (
1288
+ createServiceTxBuilder(testSchema, currentUow)
1289
+ .withServiceCalls(() => [getUserById(userId)] as const)
1290
+ // mutate callback receives serviceIntermediateResult
1291
+ .mutate(({ serviceIntermediateResult: [user] }) => {
1292
+ return { valid: user !== null, user };
1293
+ })
1294
+ .build()
1295
+ );
1296
+ };
1004
1297
 
1005
- const result = await executeRestrictedUnitOfWork(
1006
- async ({ executeMutate }) => {
1007
- await executeMutate();
1008
- return Promise.resolve({ data: "test" });
1298
+ // Handler calls executeTx with serviceCalls containing validateUser
1299
+ // This tests 2-level nesting: handler -> validateUser -> getUserById
1300
+ const result = await createHandlerTxBuilder({
1301
+ createUnitOfWork: () => {
1302
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1303
+ return currentUow;
1009
1304
  },
1010
- { createUnitOfWork: factory },
1011
- );
1012
-
1013
- expect(result).toEqual({ data: "test" });
1014
- });
1015
-
1016
- it("should NOT await nested promises (only 1 level deep)", async () => {
1017
- const { factory } = createMockUOWFactory([{ success: true }]);
1018
-
1019
- const result = await executeRestrictedUnitOfWork(
1020
- async ({ executeMutate }) => {
1021
- await executeMutate();
1305
+ })
1306
+ .withServiceCalls(() => [validateUser("user-1")] as const)
1307
+ .transform(({ serviceResult: [validationResult] }) => {
1022
1308
  return {
1023
- nested: {
1024
- promise: Promise.resolve("still-a-promise"),
1025
- },
1309
+ isValid: validationResult.valid,
1310
+ userName: validationResult.user?.name,
1026
1311
  };
1027
- },
1028
- { createUnitOfWork: factory },
1029
- );
1030
-
1031
- // The nested promise should still be a promise
1032
- expect(result.nested.promise).toBeInstanceOf(Promise);
1033
- });
1312
+ })
1313
+ .execute();
1314
+
1315
+ // Verify services were called
1316
+ expect(getUserByIdCalled).toBe(true);
1317
+ expect(validateUserCalled).toBe(true);
1318
+ expect(retrievePhaseExecuted).toBe(true);
1319
+ expect(result.isValid).toBe(true);
1320
+ expect(result.userName).toBe("Test User");
1321
+ }, 500);
1322
+
1323
+ it("should handle a TxResult with serviceCalls that returns another TxResult", async () => {
1324
+ // This test reproduces the integration test scenario where:
1325
+ // - orderService.createOrderWithValidation has serviceCalls: () => [userService.getUserById(...)]
1326
+ // - handler has serviceCalls: () => [orderService.createOrderWithValidation(...)]
1327
+
1328
+ const compiler = createMockCompiler();
1329
+ const mockUser = {
1330
+ id: FragnoId.fromExternal("user-1", 1),
1331
+ email: "test@example.com",
1332
+ name: "Test User",
1333
+ balance: 100,
1334
+ };
1335
+ const executor: UOWExecutor<unknown, unknown> = {
1336
+ executeRetrievalPhase: async () => [[mockUser]],
1337
+ executeMutationPhase: async () => ({
1338
+ success: true,
1339
+ createdInternalIds: [BigInt(1)],
1340
+ }),
1341
+ };
1342
+ const decoder = createMockDecoder();
1343
+
1344
+ let currentUow: IUnitOfWork | null = null;
1345
+
1346
+ // Simulates userService.getUserById - returns a TxResult that retrieves a user
1347
+ const getUserById = (userId: string) => {
1348
+ return createServiceTxBuilder(testSchema, currentUow!)
1349
+ .retrieve((uow) =>
1350
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1351
+ )
1352
+ .transformRetrieve(([users]) => users[0] ?? null)
1353
+ .build();
1354
+ };
1034
1355
 
1035
- it("should handle mixed types in result", async () => {
1036
- const { factory } = createMockUOWFactory([{ success: true }]);
1356
+ // Simulates orderService.createOrderWithValidation - has serviceCalls on getUserById
1357
+ const createOrderWithValidation = (userId: string, productName: string) => {
1358
+ return createServiceTxBuilder(testSchema, currentUow!)
1359
+ .withServiceCalls(() => [getUserById(userId)] as const)
1360
+ .mutate(({ uow, serviceIntermediateResult: [user] }) => {
1361
+ if (!user) {
1362
+ throw new Error("User not found");
1363
+ }
1364
+ // Create an order (simulated by creating a user for simplicity)
1365
+ const orderId = uow.create("users", {
1366
+ email: `order-${productName}@example.com`,
1367
+ name: productName,
1368
+ balance: 0,
1369
+ });
1370
+ return { orderId, forUser: user.email };
1371
+ })
1372
+ .build();
1373
+ };
1037
1374
 
1038
- const result = await executeRestrictedUnitOfWork(
1039
- async ({ executeMutate }) => {
1040
- await executeMutate();
1375
+ // Handler calls executeTx with serviceCalls containing the order service
1376
+ const result = await createHandlerTxBuilder({
1377
+ createUnitOfWork: () => {
1378
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1379
+ return currentUow;
1380
+ },
1381
+ })
1382
+ .withServiceCalls(() => [createOrderWithValidation("user-1", "TypeScript Book")] as const)
1383
+ .transform(({ serviceResult: [orderResult] }) => {
1041
1384
  return {
1042
- promise: Promise.resolve("resolved"),
1043
- number: 42,
1044
- string: "test",
1045
- boolean: true,
1046
- null: null,
1047
- undefined: undefined,
1048
- object: { nested: "value" },
1385
+ orderId: orderResult.orderId,
1386
+ forUser: orderResult.forUser,
1387
+ completed: true,
1049
1388
  };
1050
- },
1051
- { createUnitOfWork: factory },
1052
- );
1389
+ })
1390
+ .execute();
1391
+
1392
+ expect(result.completed).toBe(true);
1393
+ expect(result.forUser).toBe("test@example.com");
1394
+ expect(result.orderId).toBeInstanceOf(FragnoId);
1395
+ }, 500); // Set 500ms timeout to catch deadlock
1396
+
1397
+ it("should handle deeply nested TxResult serviceCalls (3 levels)", async () => {
1398
+ const compiler = createMockCompiler();
1399
+ const mockUser = {
1400
+ id: FragnoId.fromExternal("user-1", 1),
1401
+ email: "test@example.com",
1402
+ name: "Test User",
1403
+ balance: 100,
1404
+ };
1405
+ const executor: UOWExecutor<unknown, unknown> = {
1406
+ executeRetrievalPhase: async () => [[mockUser]],
1407
+ executeMutationPhase: async () => ({
1408
+ success: true,
1409
+ createdInternalIds: [BigInt(1)],
1410
+ }),
1411
+ };
1412
+ const decoder = createMockDecoder();
1413
+
1414
+ let currentUow: IUnitOfWork | null = null;
1415
+
1416
+ // Level 3: Basic user retrieval
1417
+ const getUserById = (userId: string) => {
1418
+ return createServiceTxBuilder(testSchema, currentUow!)
1419
+ .retrieve((uow) =>
1420
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1421
+ )
1422
+ .transformRetrieve(([users]) => users[0] ?? null)
1423
+ .build();
1424
+ };
1053
1425
 
1054
- expect(result).toEqual({
1055
- promise: "resolved",
1056
- number: 42,
1057
- string: "test",
1058
- boolean: true,
1059
- null: null,
1060
- undefined: undefined,
1061
- object: { nested: "value" },
1062
- });
1063
- });
1426
+ // Level 2: Depends on getUserById
1427
+ const validateUser = (userId: string) => {
1428
+ return createServiceTxBuilder(testSchema, currentUow!)
1429
+ .withServiceCalls(() => [getUserById(userId)] as const)
1430
+ .mutate(({ serviceIntermediateResult: [user] }) => {
1431
+ if (!user) {
1432
+ return { valid: false as const, reason: "User not found" };
1433
+ }
1434
+ return { valid: true as const, user };
1435
+ })
1436
+ .build();
1437
+ };
1064
1438
 
1065
- it("should await promises even after retries", async () => {
1066
- const { factory, callCount } = createMockUOWFactory([{ success: false }, { success: true }]);
1439
+ // Level 1: Depends on validateUser
1440
+ const createOrder = (userId: string, productName: string) => {
1441
+ return createServiceTxBuilder(testSchema, currentUow!)
1442
+ .withServiceCalls(() => [validateUser(userId)] as const)
1443
+ .mutate(({ uow, serviceIntermediateResult: [validation] }) => {
1444
+ if (!validation.valid) {
1445
+ throw new Error(validation.reason);
1446
+ }
1447
+ const orderId = uow.create("users", {
1448
+ email: `order-${productName}@example.com`,
1449
+ name: productName,
1450
+ balance: 0,
1451
+ });
1452
+ return { orderId, forUser: validation.user.email };
1453
+ })
1454
+ .build();
1455
+ };
1067
1456
 
1068
- const result = await executeRestrictedUnitOfWork(
1069
- async ({ executeMutate }) => {
1070
- await executeMutate();
1071
- return {
1072
- attempt: callCount.value,
1073
- data: Promise.resolve("final-result"),
1074
- };
1457
+ // Handler: Depends on createOrder (3 levels deep)
1458
+ const result = await createHandlerTxBuilder({
1459
+ createUnitOfWork: () => {
1460
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1461
+ return currentUow;
1075
1462
  },
1076
- { createUnitOfWork: factory },
1077
- );
1463
+ })
1464
+ .withServiceCalls(() => [createOrder("user-1", "Advanced TypeScript")] as const)
1465
+ .transform(({ serviceResult: [orderResult] }) => ({
1466
+ orderId: orderResult.orderId,
1467
+ forUser: orderResult.forUser,
1468
+ completed: true,
1469
+ }))
1470
+ .execute();
1471
+
1472
+ expect(result.completed).toBe(true);
1473
+ expect(result.forUser).toBe("test@example.com");
1474
+ expect(result.orderId).toBeInstanceOf(FragnoId);
1475
+ }, 500); // Set 500ms timeout to catch deadlock
1476
+ });
1078
1477
 
1079
- expect(result).toEqual({
1080
- attempt: 2,
1081
- data: "final-result",
1082
- });
1083
- });
1478
+ describe("triggerHook in serviceTx", () => {
1479
+ it("should record triggered hooks on the base UOW when using createServiceTx with retrieve and mutate", async () => {
1480
+ const compiler = createMockCompiler();
1481
+ // Return empty array to simulate no existing user, so the hook will be triggered
1482
+ const executor: UOWExecutor<unknown, unknown> = {
1483
+ executeRetrievalPhase: async () => [[]],
1484
+ executeMutationPhase: async () => ({
1485
+ success: true,
1486
+ createdInternalIds: [],
1487
+ }),
1488
+ };
1489
+ const decoder = createMockDecoder();
1084
1490
 
1085
- it("should handle complex objects with multiple promises at top level", async () => {
1086
- const { factory } = createMockUOWFactory([{ success: true }]);
1491
+ let currentUow: IUnitOfWork | null = null;
1087
1492
 
1088
- const result = await executeRestrictedUnitOfWork(
1089
- async ({ executeMutate }) => {
1090
- await executeMutate();
1091
- return {
1092
- userId: Promise.resolve("user-1"),
1093
- email: Promise.resolve("test@example.com"),
1094
- count: Promise.resolve(100),
1095
- active: Promise.resolve(true),
1096
- metadata: {
1097
- timestamp: Date.now(),
1098
- version: 1,
1099
- },
1100
- };
1493
+ // Define hooks type for this test
1494
+ type TestHooks = {
1495
+ onSubscribe: (payload: { email: string }) => void;
1496
+ };
1497
+
1498
+ const hooks: TestHooks = {
1499
+ onSubscribe: (payload: { email: string }) => {
1500
+ console.log(`onSubscribe: ${payload.email}`);
1101
1501
  },
1102
- { createUnitOfWork: factory },
1103
- );
1104
-
1105
- expect(typeof result.userId).toBe("string");
1106
- expect(result.userId).toBe("user-1");
1107
- expect(typeof result.email).toBe("string");
1108
- expect(result.email).toBe("test@example.com");
1109
- expect(typeof result.count).toBe("number");
1110
- expect(result.count).toBe(100);
1111
- expect(typeof result.active).toBe("boolean");
1112
- expect(result.active).toBe(true);
1113
- expect(typeof result.metadata.timestamp).toBe("number");
1114
- expect(result.metadata.version).toBe(1);
1115
- });
1502
+ };
1116
1503
 
1117
- it("should handle empty object result", async () => {
1118
- const { factory } = createMockUOWFactory([{ success: true }]);
1504
+ // Service that retrieves a user and triggers a hook in mutate
1505
+ const subscribeUser = (email: string) => {
1506
+ return createServiceTxBuilder(testSchema, currentUow!, hooks)
1507
+ .retrieve((uow) =>
1508
+ uow.find("users", (b) => b.whereIndex("idx_email", (eb) => eb("email", "=", email))),
1509
+ )
1510
+ .transformRetrieve(([users]) => users[0] ?? null)
1511
+ .mutate(({ uow, retrieveResult: existingUser }) => {
1512
+ if (existingUser) {
1513
+ return { subscribed: false, email };
1514
+ }
1515
+ // Trigger hook when subscribing a new user
1516
+ uow.triggerHook("onSubscribe", { email });
1517
+ return { subscribed: true, email };
1518
+ })
1519
+ .build();
1520
+ };
1119
1521
 
1120
- const result = await executeRestrictedUnitOfWork(
1121
- async ({ executeMutate }) => {
1122
- await executeMutate();
1123
- return {};
1522
+ // Execute the transaction
1523
+ await createHandlerTxBuilder({
1524
+ createUnitOfWork: () => {
1525
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1526
+ return currentUow;
1124
1527
  },
1125
- { createUnitOfWork: factory },
1126
- );
1127
-
1128
- expect(result).toEqual({});
1528
+ })
1529
+ .withServiceCalls(() => [subscribeUser("new@example.com")] as const)
1530
+ .transform(({ serviceResult: [result] }) => result)
1531
+ .execute();
1532
+
1533
+ // Verify that the hook was triggered and recorded on the base UOW
1534
+ const triggeredHooks = currentUow!.getTriggeredHooks();
1535
+ expect(triggeredHooks).toHaveLength(1);
1536
+ expect(triggeredHooks[0]).toMatchObject({
1537
+ hookName: "onSubscribe",
1538
+ payload: { email: "new@example.com" },
1539
+ });
1129
1540
  });
1130
1541
 
1131
- it("should handle primitive result types", async () => {
1132
- const { factory } = createMockUOWFactory([{ success: true }]);
1542
+ it("should record triggered hooks when service has only retrieve (no retrieveSuccess) and mutate", async () => {
1543
+ const compiler = createMockCompiler();
1544
+ const mockUser = {
1545
+ id: FragnoId.fromExternal("user-1", 1),
1546
+ email: "test@example.com",
1547
+ name: "Test User",
1548
+ balance: 100,
1549
+ };
1550
+ const executor: UOWExecutor<unknown, unknown> = {
1551
+ executeRetrievalPhase: async () => [[mockUser]],
1552
+ executeMutationPhase: async () => ({
1553
+ success: true,
1554
+ createdInternalIds: [],
1555
+ }),
1556
+ };
1557
+ const decoder = createMockDecoder();
1133
1558
 
1134
- const stringResult = await executeRestrictedUnitOfWork(
1135
- async ({ executeMutate }) => {
1136
- await executeMutate();
1137
- return "test-string";
1138
- },
1139
- { createUnitOfWork: factory },
1140
- );
1559
+ let currentUow: IUnitOfWork | null = null;
1141
1560
 
1142
- expect(stringResult).toBe("test-string");
1561
+ // Define hooks type for this test
1562
+ type TestHooks = {
1563
+ onUserUpdated: (payload: { userId: string }) => void;
1564
+ };
1143
1565
 
1144
- const { factory: factory2 } = createMockUOWFactory([{ success: true }]);
1145
- const numberResult = await executeRestrictedUnitOfWork(
1146
- async ({ executeMutate }) => {
1147
- await executeMutate();
1148
- return 42;
1566
+ const hooks: TestHooks = {
1567
+ onUserUpdated: (payload: { userId: string }) => {
1568
+ console.log(`onUserUpdated: ${payload.userId}`);
1149
1569
  },
1150
- { createUnitOfWork: factory2 },
1151
- );
1570
+ };
1571
+
1572
+ // Service that uses raw retrieve results (no transformRetrieve) and triggers hook
1573
+ const updateUser = (userId: string) => {
1574
+ return (
1575
+ createServiceTxBuilder(testSchema, currentUow!, hooks)
1576
+ .retrieve((uow) =>
1577
+ uow.find("users", (b) => b.whereIndex("primary", (eb) => eb("id", "=", userId))),
1578
+ )
1579
+ // NO transformRetrieve - mutate receives raw [users[]] array
1580
+ .mutate(({ uow, retrieveResult: [users] }) => {
1581
+ const user = users[0];
1582
+ if (!user) {
1583
+ return { updated: false };
1584
+ }
1585
+ uow.triggerHook("onUserUpdated", { userId: user.id.toString() });
1586
+ return { updated: true };
1587
+ })
1588
+ .build()
1589
+ );
1590
+ };
1152
1591
 
1153
- expect(numberResult).toBe(42);
1592
+ await createHandlerTxBuilder({
1593
+ createUnitOfWork: () => {
1594
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1595
+ return currentUow;
1596
+ },
1597
+ })
1598
+ .withServiceCalls(() => [updateUser("user-1")] as const)
1599
+ .transform(({ serviceResult: [result] }) => result)
1600
+ .execute();
1601
+
1602
+ // Verify hook was triggered
1603
+ const triggeredHooks = currentUow!.getTriggeredHooks();
1604
+ expect(triggeredHooks).toHaveLength(1);
1605
+ expect(triggeredHooks[0]).toMatchObject({
1606
+ hookName: "onUserUpdated",
1607
+ payload: { userId: expect.any(String) },
1608
+ });
1154
1609
  });
1155
1610
  });
1156
1611
 
1157
- describe("tuple return types", () => {
1158
- it("should await promises in tuple and preserve tuple structure", async () => {
1159
- const { factory } = createMockUOWFactory([{ success: true }]);
1160
-
1161
- const result = await executeRestrictedUnitOfWork(
1162
- async ({ executeMutate }) => {
1163
- await executeMutate();
1164
- // Return a tuple with promises
1165
- return [Promise.resolve("user-123"), Promise.resolve(42)] as const;
1166
- },
1167
- { createUnitOfWork: factory },
1168
- );
1612
+ describe("error handling in createServiceTx", () => {
1613
+ it("should not cause unhandled rejection when retrieve callback throws synchronously in serviceCalls", async () => {
1614
+ // This test verifies that when a service's retrieve callback throws synchronously,
1615
+ // the error is properly propagated without causing an unhandled rejection warning.
1616
+ // Without the fix (adding retrievePhase.catch(() => {}) before rejecting and throwing),
1617
+ // this test would cause an "Unhandled Rejection" warning from Vitest.
1618
+
1619
+ const compiler = createMockCompiler();
1620
+ const executor: UOWExecutor<unknown, unknown> = {
1621
+ executeRetrievalPhase: async () => [],
1622
+ executeMutationPhase: async () => ({
1623
+ success: true,
1624
+ createdInternalIds: [],
1625
+ }),
1626
+ };
1627
+ const decoder = createMockDecoder();
1169
1628
 
1170
- // Runtime behavior: promises should be awaited
1171
- expect(result).toEqual(["user-123", 42]);
1172
- expect(result[0]).toBe("user-123");
1173
- expect(result[1]).toBe(42);
1174
- });
1629
+ let currentUow: IUnitOfWork | null = null;
1175
1630
 
1176
- it("should handle tuple with mixed promise and non-promise values", async () => {
1177
- const { factory } = createMockUOWFactory([{ success: true }]);
1631
+ const syncError = new Error("Retrieve callback threw synchronously");
1178
1632
 
1179
- const result = await executeRestrictedUnitOfWork(
1180
- async ({ executeMutate }) => {
1181
- await executeMutate();
1182
- // Tuple with mixed types
1183
- return [Promise.resolve("first"), "second", Promise.resolve(3)] as const;
1184
- },
1185
- { createUnitOfWork: factory },
1186
- );
1633
+ // Service that throws synchronously in retrieve callback
1634
+ const failingService = () => {
1635
+ return createServiceTxBuilder(testSchema, currentUow!)
1636
+ .retrieve(() => {
1637
+ throw syncError;
1638
+ })
1639
+ .build();
1640
+ };
1187
1641
 
1188
- expect(result).toEqual(["first", "second", 3]);
1189
- expect(result[0]).toBe("first");
1190
- expect(result[1]).toBe("second");
1191
- expect(result[2]).toBe(3);
1642
+ // Execute with serviceCalls that contain the failing service
1643
+ // The error should be properly caught and re-thrown without unhandled rejection
1644
+ await expect(
1645
+ createHandlerTxBuilder({
1646
+ createUnitOfWork: () => {
1647
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1648
+ return currentUow;
1649
+ },
1650
+ })
1651
+ .withServiceCalls(() => [failingService()] as const)
1652
+ .transform(({ serviceResult: [result] }) => result)
1653
+ .execute(),
1654
+ ).rejects.toThrow("Retrieve callback threw synchronously");
1192
1655
  });
1193
1656
 
1194
- it("should handle Promise.all pattern with tuple", async () => {
1195
- const { factory } = createMockUOWFactory([{ success: true }]);
1196
-
1197
- const result = await executeRestrictedUnitOfWork(
1198
- async ({ executeMutate }) => {
1199
- await executeMutate();
1200
- // Simulate the pattern from db-fragment-integration.test.ts
1201
- const userPromise = Promise.resolve({ id: "user-1", name: "John" });
1202
- const ordersPromise = Promise.resolve([
1203
- { id: "order-1", total: 100 },
1204
- { id: "order-2", total: 200 },
1205
- ]);
1206
- return await Promise.all([userPromise, ordersPromise]);
1207
- },
1208
- { createUnitOfWork: factory },
1209
- );
1210
-
1211
- // Runtime behavior
1212
- expect(result).toHaveLength(2);
1213
- expect(result[0]).toEqual({ id: "user-1", name: "John" });
1214
- expect(result[1]).toEqual([
1215
- { id: "order-1", total: 100 },
1216
- { id: "order-2", total: 200 },
1217
- ]);
1218
-
1219
- // Type check: result should be [{ id: string; name: string }, { id: string; total: number }[]]
1220
- // But with current implementation, it's incorrectly typed as an array union
1221
- const [user, orders] = result;
1222
- expect(user).toBeDefined();
1223
- expect(orders).toBeDefined();
1224
- });
1657
+ it("should not cause unhandled rejection when serviceCalls callback throws synchronously", async () => {
1658
+ // This test verifies that when a service's serviceCalls callback throws synchronously,
1659
+ // the error is properly propagated without causing an unhandled rejection warning.
1660
+
1661
+ const compiler = createMockCompiler();
1662
+ const executor: UOWExecutor<unknown, unknown> = {
1663
+ executeRetrievalPhase: async () => [],
1664
+ executeMutationPhase: async () => ({
1665
+ success: true,
1666
+ createdInternalIds: [],
1667
+ }),
1668
+ };
1669
+ const decoder = createMockDecoder();
1225
1670
 
1226
- it("should handle array (not tuple) with promises", async () => {
1227
- const { factory } = createMockUOWFactory([{ success: true }]);
1671
+ let currentUow: IUnitOfWork | null = null;
1228
1672
 
1229
- const result = await executeRestrictedUnitOfWork(
1230
- async ({ executeMutate }) => {
1231
- await executeMutate();
1232
- // Regular array (not a tuple)
1233
- const items = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)];
1234
- return items;
1235
- },
1236
- { createUnitOfWork: factory },
1237
- );
1673
+ const syncError = new Error("Deps callback threw synchronously");
1674
+
1675
+ // Service that throws synchronously in serviceCalls callback
1676
+ const failingService = () => {
1677
+ return createServiceTxBuilder(testSchema, currentUow!)
1678
+ .withServiceCalls(() => {
1679
+ throw syncError;
1680
+ })
1681
+ .mutate(() => ({ done: true }))
1682
+ .build();
1683
+ };
1238
1684
 
1239
- expect(result).toEqual([1, 2, 3]);
1240
- expect(result).toHaveLength(3);
1685
+ // Execute with serviceCalls that contain the failing service
1686
+ await expect(
1687
+ createHandlerTxBuilder({
1688
+ createUnitOfWork: () => {
1689
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1690
+ return currentUow;
1691
+ },
1692
+ })
1693
+ .withServiceCalls(() => [failingService()] as const)
1694
+ .transform(({ serviceResult: [result] }) => result)
1695
+ .execute(),
1696
+ ).rejects.toThrow("Deps callback threw synchronously");
1241
1697
  });
1242
1698
  });
1243
1699
 
1244
- describe("unhandled rejection handling", () => {
1245
- it("should not cause unhandled rejection when service method awaits retrievalPhase and executeRetrieve fails", async () => {
1246
- const settingsSchema = schema((s) =>
1247
- s.addTable("settings", (t) =>
1248
- t
1249
- .addColumn("id", idColumn())
1250
- .addColumn("key", "string")
1251
- .addColumn("value", "string")
1252
- .createIndex("unique_key", ["key"], { unique: true }),
1253
- ),
1254
- );
1255
-
1256
- // Create executor that throws "table does not exist" error
1257
- const failingExecutor: UOWExecutor<unknown, unknown> = {
1700
+ describe("mutate-only service type inference", () => {
1701
+ it("should correctly type serviceIntermediateResult when dependent service only has mutate (no retrieve)", async () => {
1702
+ // This test verifies that when a service has ONLY a mutate callback (no retrieve),
1703
+ // the mutate result is correctly typed as the serviceIntermediateResult for dependent services.
1704
+ //
1705
+ // Execution order:
1706
+ // 1. generateOTP's retrieve phase runs (empty - no retrieve callback)
1707
+ // 2. generateOTP's mutate runs → returns { otpId, code }
1708
+ // 3. sendOTPEmail's mutate runs → serviceIntermediateResult[0] is { otpId, code, userId }
1709
+ //
1710
+ // Without the InferBuilderRetrieveSuccessResult fix, serviceIntermediateResult[0] would be
1711
+ // typed as `[]` (empty tuple) even though at runtime it's the mutate result.
1712
+
1713
+ const compiler = createMockCompiler();
1714
+ const executor: UOWExecutor<unknown, unknown> = {
1258
1715
  executeRetrievalPhase: async () => {
1259
- throw new Error('relation "settings" does not exist');
1716
+ return [];
1717
+ },
1718
+ executeMutationPhase: async () => {
1719
+ return {
1720
+ success: true,
1721
+ createdInternalIds: [],
1722
+ };
1260
1723
  },
1261
- executeMutationPhase: async () => ({ success: true, createdInternalIds: [] }),
1724
+ };
1725
+ const decoder = createMockDecoder();
1726
+
1727
+ let currentUow: IUnitOfWork | null = null;
1728
+
1729
+ // Capture the runtime value of serviceResult to verify it contains the mutate result
1730
+ let capturedOtpResult: unknown = null;
1731
+
1732
+ // Mutate-only service - simulates OTP generation
1733
+ // No .retrieve() - this service doesn't need to read anything first
1734
+ const generateOTP = (userId: string) => {
1735
+ return createServiceTxBuilder(testSchema, currentUow!)
1736
+ .mutate(({ uow }) => {
1737
+ const otpId = uow.create("users", {
1738
+ email: `otp-${userId}@example.com`,
1739
+ name: "OTP Record",
1740
+ balance: 0,
1741
+ });
1742
+ // Return data that dependents need - this becomes serviceResult for them
1743
+ return { otpId, code: "ABC123", userId };
1744
+ })
1745
+ .build();
1262
1746
  };
1263
1747
 
1264
- const factory = () =>
1265
- createUnitOfWork(createMockCompiler(), failingExecutor, createMockDecoder());
1748
+ // Service that depends on generateOTP
1749
+ // The key test: serviceIntermediateResult[0] should be typed as { otpId, code, userId }
1750
+ const sendOTPEmail = (userId: string, email: string) => {
1751
+ return createServiceTxBuilder(testSchema, currentUow!)
1752
+ .withServiceCalls(() => [generateOTP(userId)] as const)
1753
+ .mutate(({ uow, serviceIntermediateResult: [otpResult] }) => {
1754
+ // RUNTIME CAPTURE: Store the actual runtime value for verification
1755
+ capturedOtpResult = otpResult;
1756
+
1757
+ // TYPE TEST: Without the fix, this would require a manual cast.
1758
+ // With the fix, TypeScript knows otpResult is { otpId: FragnoId, code: string, userId: string }
1759
+ expectTypeOf(otpResult).toEqualTypeOf<{
1760
+ otpId: FragnoId;
1761
+ code: string;
1762
+ userId: string;
1763
+ }>();
1764
+
1765
+ // Access properties without type errors - proves the type inference works
1766
+ const message = `Your OTP code is: ${otpResult.code}`;
1767
+
1768
+ uow.create("users", {
1769
+ email,
1770
+ name: message,
1771
+ balance: 0,
1772
+ });
1773
+
1774
+ return { sent: true, forUser: otpResult.userId };
1775
+ })
1776
+ .build();
1777
+ };
1266
1778
 
1267
- const deferred = Promise.withResolvers<string>();
1779
+ const result = await createHandlerTxBuilder({
1780
+ createUnitOfWork: () => {
1781
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1782
+ return currentUow;
1783
+ },
1784
+ })
1785
+ .withServiceCalls(() => [sendOTPEmail("user-1", "test@example.com")] as const)
1786
+ .transform(({ serviceResult: [emailResult] }) => emailResult)
1787
+ .execute();
1788
+
1789
+ expect(result.sent).toBe(true);
1790
+ expect(result.forUser).toBe("user-1");
1791
+
1792
+ // RUNTIME VERIFICATION: Verify the actual runtime value of serviceIntermediateResult
1793
+ // This proves generateOTP's mutate result is actually passed as serviceIntermediateResult
1794
+ expect(capturedOtpResult).not.toBeNull();
1795
+ expect(capturedOtpResult).toMatchObject({
1796
+ code: "ABC123",
1797
+ userId: "user-1",
1798
+ });
1799
+ expect((capturedOtpResult as { otpId: FragnoId }).otpId).toBeInstanceOf(FragnoId);
1800
+ });
1268
1801
 
1269
- // Service method that awaits retrievalPhase (simulating settingsService.get())
1270
- const getSettingValue = async (typedUow: TypedUnitOfWork<typeof settingsSchema>) => {
1271
- const uow = typedUow.find("settings", (b) =>
1272
- b.whereIndex("unique_key", (eb) => eb("key", "=", "version")),
1273
- );
1274
- const [results] = await uow.retrievalPhase;
1275
- return results?.[0];
1802
+ it("should correctly type serviceIntermediateResult with multiple mutate-only service calls", async () => {
1803
+ // Test with multiple mutate-only dependencies to verify tuple typing works
1804
+
1805
+ const compiler = createMockCompiler();
1806
+ const executor: UOWExecutor<unknown, unknown> = {
1807
+ executeRetrievalPhase: async () => [],
1808
+ executeMutationPhase: async () => ({
1809
+ success: true,
1810
+ createdInternalIds: [],
1811
+ }),
1812
+ };
1813
+ const decoder = createMockDecoder();
1814
+
1815
+ let currentUow: IUnitOfWork | null = null;
1816
+
1817
+ // Capture runtime values for verification
1818
+ let capturedAuditResult: unknown = null;
1819
+ let capturedCounterResult: unknown = null;
1820
+
1821
+ // First mutate-only service
1822
+ const createAuditLog = (action: string) => {
1823
+ return createServiceTxBuilder(testSchema, currentUow!)
1824
+ .mutate(({ uow }) => {
1825
+ const logId = uow.create("users", {
1826
+ email: `audit-${action}@example.com`,
1827
+ name: action,
1828
+ balance: 0,
1829
+ });
1830
+ return { logId, action, timestamp: 1234567890 };
1831
+ })
1832
+ .build();
1276
1833
  };
1277
1834
 
1278
- // Execute with executeRestrictedUnitOfWork
1279
- try {
1280
- await executeRestrictedUnitOfWork(
1281
- async ({ forSchema, executeRetrieve }) => {
1282
- const uow = forSchema(settingsSchema);
1835
+ // Second mutate-only service
1836
+ const incrementCounter = (name: string) => {
1837
+ return createServiceTxBuilder(testSchema, currentUow!)
1838
+ .mutate(() => {
1839
+ // Simulates incrementing a counter - returns the new value
1840
+ return { counterName: name, newValue: 42 };
1841
+ })
1842
+ .build();
1843
+ };
1283
1844
 
1284
- const settingPromise = getSettingValue(uow);
1845
+ const result = await createHandlerTxBuilder({
1846
+ createUnitOfWork: () => {
1847
+ currentUow = createUnitOfWork(compiler, executor, decoder);
1848
+ return currentUow;
1849
+ },
1850
+ })
1851
+ .withServiceCalls(
1852
+ () => [createAuditLog("user_login"), incrementCounter("login_count")] as const,
1853
+ )
1854
+ .mutate(({ serviceIntermediateResult: [auditResult, counterResult] }) => {
1855
+ // RUNTIME CAPTURE: Store the actual runtime values
1856
+ capturedAuditResult = auditResult;
1857
+ capturedCounterResult = counterResult;
1858
+
1859
+ // TYPE TESTS: Both should be correctly typed from their mutate results
1860
+ expectTypeOf(auditResult).toEqualTypeOf<{
1861
+ logId: FragnoId;
1862
+ action: string;
1863
+ timestamp: number;
1864
+ }>();
1865
+ expectTypeOf(counterResult).toEqualTypeOf<{
1866
+ counterName: string;
1867
+ newValue: number;
1868
+ }>();
1869
+
1870
+ // Access properties - proves type inference works
1871
+ return {
1872
+ auditAction: auditResult.action,
1873
+ loginCount: counterResult.newValue,
1874
+ };
1875
+ })
1876
+ .execute();
1285
1877
 
1286
- // Execute retrieval - this will fail
1287
- await executeRetrieve();
1878
+ expect(result.auditAction).toBe("user_login");
1879
+ expect(result.loginCount).toBe(42);
1288
1880
 
1289
- // Won't reach here
1290
- return await settingPromise;
1291
- },
1292
- {
1293
- createUnitOfWork: factory,
1294
- retryPolicy: new NoRetryPolicy(),
1295
- },
1296
- );
1297
- expect.fail("Should have thrown an error");
1298
- } catch (error) {
1299
- // The error should be thrown directly (not wrapped) since it's not a concurrency conflict
1300
- expect(error).toBeInstanceOf(Error);
1301
- expect((error as Error).message).toContain('relation "settings" does not exist');
1302
- deferred.resolve((error as Error).message);
1303
- }
1881
+ // RUNTIME VERIFICATION: Verify the actual runtime values
1882
+ expect(capturedAuditResult).toMatchObject({
1883
+ action: "user_login",
1884
+ timestamp: 1234567890,
1885
+ });
1886
+ expect((capturedAuditResult as { logId: FragnoId }).logId).toBeInstanceOf(FragnoId);
1304
1887
 
1305
- // Verify no unhandled rejection occurred
1306
- // If the test completes without throwing, the promise rejection was properly handled
1307
- expect(await deferred.promise).toContain('relation "settings" does not exist');
1888
+ expect(capturedCounterResult).toEqual({
1889
+ counterName: "login_count",
1890
+ newValue: 42,
1891
+ });
1308
1892
  });
1309
1893
  });
1310
1894
  });