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