@fragno-dev/db 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +34 -30
  2. package/CHANGELOG.md +49 -0
  3. package/dist/adapters/generic-sql/query/where-builder.js +1 -1
  4. package/dist/db-fragment-definition-builder.d.ts +31 -39
  5. package/dist/db-fragment-definition-builder.d.ts.map +1 -1
  6. package/dist/db-fragment-definition-builder.js +20 -16
  7. package/dist/db-fragment-definition-builder.js.map +1 -1
  8. package/dist/fragments/internal-fragment.d.ts +94 -8
  9. package/dist/fragments/internal-fragment.d.ts.map +1 -1
  10. package/dist/fragments/internal-fragment.js +56 -55
  11. package/dist/fragments/internal-fragment.js.map +1 -1
  12. package/dist/hooks/hooks.d.ts +5 -3
  13. package/dist/hooks/hooks.d.ts.map +1 -1
  14. package/dist/hooks/hooks.js +38 -37
  15. package/dist/hooks/hooks.js.map +1 -1
  16. package/dist/mod.d.ts +3 -3
  17. package/dist/mod.d.ts.map +1 -1
  18. package/dist/mod.js +4 -4
  19. package/dist/mod.js.map +1 -1
  20. package/dist/query/unit-of-work/execute-unit-of-work.d.ts +367 -80
  21. package/dist/query/unit-of-work/execute-unit-of-work.d.ts.map +1 -1
  22. package/dist/query/unit-of-work/execute-unit-of-work.js +448 -148
  23. package/dist/query/unit-of-work/execute-unit-of-work.js.map +1 -1
  24. package/dist/query/unit-of-work/unit-of-work.d.ts +35 -11
  25. package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
  26. package/dist/query/unit-of-work/unit-of-work.js +49 -19
  27. package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
  28. package/dist/query/value-decoding.js +1 -1
  29. package/dist/schema/create.d.ts +2 -3
  30. package/dist/schema/create.d.ts.map +1 -1
  31. package/dist/schema/create.js +2 -5
  32. package/dist/schema/create.js.map +1 -1
  33. package/dist/schema/generate-id.d.ts +20 -0
  34. package/dist/schema/generate-id.d.ts.map +1 -0
  35. package/dist/schema/generate-id.js +28 -0
  36. package/dist/schema/generate-id.js.map +1 -0
  37. package/dist/sql-driver/dialects/durable-object-dialect.d.ts.map +1 -1
  38. package/package.json +3 -3
  39. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +1 -0
  40. package/src/adapters/drizzle/drizzle-adapter-sqlite3.test.ts +41 -25
  41. package/src/adapters/generic-sql/test/generic-drizzle-adapter-sqlite3.test.ts +39 -25
  42. package/src/db-fragment-definition-builder.test.ts +58 -42
  43. package/src/db-fragment-definition-builder.ts +78 -88
  44. package/src/db-fragment-instantiator.test.ts +64 -88
  45. package/src/db-fragment-integration.test.ts +292 -142
  46. package/src/fragments/internal-fragment.test.ts +272 -266
  47. package/src/fragments/internal-fragment.ts +155 -122
  48. package/src/hooks/hooks.test.ts +268 -264
  49. package/src/hooks/hooks.ts +74 -63
  50. package/src/mod.ts +14 -4
  51. package/src/query/unit-of-work/execute-unit-of-work.test.ts +1582 -998
  52. package/src/query/unit-of-work/execute-unit-of-work.ts +1746 -343
  53. package/src/query/unit-of-work/tx-builder.test.ts +1041 -0
  54. package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +269 -21
  55. package/src/query/unit-of-work/unit-of-work.test.ts +64 -0
  56. package/src/query/unit-of-work/unit-of-work.ts +65 -30
  57. package/src/schema/create.ts +2 -5
  58. package/src/schema/generate-id.test.ts +57 -0
  59. package/src/schema/generate-id.ts +38 -0
  60. package/src/shared/config.ts +0 -10
  61. package/src/shared/connection-pool.ts +0 -24
  62. package/src/shared/prisma.ts +0 -45
@@ -1,333 +1,481 @@
1
1
  import type { AnySchema } from "../../schema/create";
2
2
  import type { TypedUnitOfWork, IUnitOfWork } from "./unit-of-work";
3
3
  import type { HooksMap } from "../../hooks/hooks";
4
- import { NoRetryPolicy, ExponentialBackoffRetryPolicy, type RetryPolicy } from "./retry-policy";
5
- import type { FragnoId } from "../../schema/create";
4
+ import { ExponentialBackoffRetryPolicy, type RetryPolicy } from "./retry-policy";
6
5
 
7
6
  /**
8
- * Error thrown when a Unit of Work execution fails due to optimistic concurrency conflict.
9
- * This error triggers automatic retry behavior in executeRestrictedUnitOfWork.
7
+ * Symbol to identify TxResult objects
10
8
  */
11
- export class ConcurrencyConflictError extends Error {
12
- constructor(message = "Optimistic concurrency conflict detected") {
13
- super(message);
14
- this.name = "ConcurrencyConflictError";
15
- }
9
+ const TX_RESULT_BRAND = Symbol("TxResult");
10
+
11
+ /**
12
+ * Check if a value is a TxResult
13
+ */
14
+ export function isTxResult(value: unknown): value is TxResult<unknown> {
15
+ return (
16
+ value !== null &&
17
+ typeof value === "object" &&
18
+ TX_RESULT_BRAND in value &&
19
+ (value as Record<symbol, boolean>)[TX_RESULT_BRAND] === true
20
+ );
16
21
  }
17
22
 
18
23
  /**
19
- * Type utility that unwraps promises 1 level deep in objects, arrays, or direct promises
20
- * Handles tuples, arrays, objects, and direct promises
24
+ * Extract the retrieve success result type from a TxResult.
25
+ * If the TxResult has retrieveSuccess, returns its return type.
26
+ * Otherwise returns the raw retrieve results type.
27
+ * Handles undefined (for optional service patterns like optionalService?.method()).
21
28
  */
22
- export type AwaitedPromisesInObject<T> =
23
- // First check if it's a Promise
24
- T extends Promise<infer U>
25
- ? Awaited<U>
26
- : // Check for arrays with known length (tuples) - preserves tuple structure
27
- T extends readonly [unknown, ...unknown[]]
28
- ? { [K in keyof T]: AwaitedPromisesInObject<T[K]> }
29
- : T extends [unknown, ...unknown[]]
30
- ? { [K in keyof T]: AwaitedPromisesInObject<T[K]> }
31
- : // Check for regular arrays (unknown length)
32
- T extends (infer U)[]
33
- ? Awaited<U>[]
34
- : T extends readonly (infer U)[]
35
- ? readonly Awaited<U>[]
36
- : // Check for objects
37
- T extends Record<string, unknown>
38
- ? {
39
- [K in keyof T]: T[K] extends Promise<infer U> ? Awaited<U> : T[K];
40
- }
41
- : // Otherwise return as-is
42
- T;
29
+ export type ExtractTxRetrieveSuccessResult<T> = T extends undefined
30
+ ? undefined
31
+ : T extends TxResult<unknown, infer R>
32
+ ? R
33
+ : never;
43
34
 
44
35
  /**
45
- * Await promises in an object 1 level deep
36
+ * Extract the final result type from a TxResult.
37
+ * Handles undefined (for optional service patterns like optionalService?.method()).
46
38
  */
47
- async function awaitPromisesInObject<T>(obj: T): Promise<AwaitedPromisesInObject<T>> {
48
- if (obj === null || obj === undefined) {
49
- return obj as AwaitedPromisesInObject<T>;
50
- }
39
+ export type ExtractTxFinalResult<T> = T extends undefined
40
+ ? undefined
41
+ : T extends TxResult<infer R, infer _>
42
+ ? R
43
+ : Awaited<T>;
51
44
 
52
- if (typeof obj !== "object") {
53
- return obj as AwaitedPromisesInObject<T>;
54
- }
45
+ /**
46
+ * Map over service calls array to extract retrieve success results from each service call.
47
+ * Preserves tuple structure while extracting the retrieve success result type from each element.
48
+ */
49
+ export type ExtractServiceRetrieveResults<T extends readonly unknown[]> = {
50
+ [K in keyof T]: ExtractTxRetrieveSuccessResult<T[K]>;
51
+ };
55
52
 
56
- // Check if it's a Promise
57
- if (obj instanceof Promise) {
58
- return (await obj) as AwaitedPromisesInObject<T>;
59
- }
53
+ /**
54
+ * Map over service calls array to extract final results from each service call.
55
+ * Preserves tuple structure while extracting the final result type from each element.
56
+ */
57
+ export type ExtractServiceFinalResults<T extends readonly unknown[]> = {
58
+ [K in keyof T]: ExtractTxFinalResult<T[K]>;
59
+ };
60
60
 
61
- // Check if it's an array
62
- if (Array.isArray(obj)) {
63
- const awaited = await Promise.all(
64
- obj.map((item) => (item instanceof Promise ? item : Promise.resolve(item))),
65
- );
66
- return awaited as AwaitedPromisesInObject<T>;
67
- }
61
+ /**
62
+ * Context passed to mutate callback for service methods
63
+ */
64
+ export interface ServiceTxMutateContext<
65
+ TSchema extends AnySchema,
66
+ TRetrieveSuccessResult,
67
+ TServiceRetrieveResults extends readonly unknown[],
68
+ THooks extends HooksMap,
69
+ > {
70
+ /** Unit of work for scheduling mutations */
71
+ uow: TypedUnitOfWork<TSchema, [], unknown, THooks>;
72
+ /** Result from retrieveSuccess callback (or raw retrieve results if no retrieveSuccess) */
73
+ retrieveResult: TRetrieveSuccessResult;
74
+ /** Array of retrieve success results from service calls (intermediate results, not final) */
75
+ serviceIntermediateResult: TServiceRetrieveResults;
76
+ }
68
77
 
69
- // It's a plain object - await promises in each property
70
- const result = {} as T;
71
- const entries = Object.entries(obj as Record<string, unknown>);
72
- const awaitedEntries = await Promise.all(
73
- entries.map(async ([key, value]) => {
74
- const awaitedValue = value instanceof Promise ? await value : value;
75
- return [key, awaitedValue] as const;
76
- }),
77
- );
78
+ /**
79
+ * Context passed to handler-level callbacks
80
+ */
81
+ export interface HandlerTxContext<THooks extends HooksMap> {
82
+ /** Get a typed Unit of Work for the given schema */
83
+ forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
84
+ schema: S,
85
+ hooks?: H,
86
+ ) => TypedUnitOfWork<S, [], unknown, H>;
87
+ /** Unique key for this transaction attempt (for idempotency/deduplication) */
88
+ idempotencyKey: string;
89
+ /** Current attempt number (0-based) */
90
+ currentAttempt: number;
91
+ }
78
92
 
79
- for (const [key, value] of awaitedEntries) {
80
- (result as Record<string, unknown>)[key] = value;
81
- }
93
+ /**
94
+ * Context passed to handler mutate callback
95
+ */
96
+ export interface HandlerTxMutateContext<
97
+ TRetrieveSuccessResult,
98
+ TServiceRetrieveResults extends readonly unknown[],
99
+ THooks extends HooksMap,
100
+ > extends HandlerTxContext<THooks> {
101
+ /** Result from retrieveSuccess callback (or raw retrieve results if no retrieveSuccess) */
102
+ retrieveResult: TRetrieveSuccessResult;
103
+ /** Array of retrieve success results from service calls (intermediate results, not final) */
104
+ serviceIntermediateResult: TServiceRetrieveResults;
105
+ }
82
106
 
83
- return result as AwaitedPromisesInObject<T>;
107
+ /**
108
+ * Context passed to success callback when mutate IS provided
109
+ */
110
+ export interface TxSuccessContextWithMutate<
111
+ TRetrieveSuccessResult,
112
+ TMutateResult,
113
+ TServiceFinalResults extends readonly unknown[],
114
+ TServiceRetrieveResults extends readonly unknown[],
115
+ > {
116
+ /** Result from retrieveSuccess callback (or raw retrieve results if no retrieveSuccess) */
117
+ retrieveResult: TRetrieveSuccessResult;
118
+ /** Result from mutate callback */
119
+ mutateResult: TMutateResult;
120
+ /** Array of final results from service calls */
121
+ serviceResult: TServiceFinalResults;
122
+ /** Array of retrieve success results from service calls (same as what mutate receives) */
123
+ serviceIntermediateResult: TServiceRetrieveResults;
84
124
  }
85
125
 
86
126
  /**
87
- * Result of executing a Unit of Work with retry support
88
- * Promises in mutationResult are unwrapped 1 level deep
127
+ * Context passed to success callback when mutate is NOT provided
89
128
  */
90
- export type ExecuteUnitOfWorkResult<TRetrievalResults, TMutationResult> =
91
- | {
92
- success: true;
93
- results: TRetrievalResults;
94
- mutationResult: AwaitedPromisesInObject<TMutationResult>;
95
- createdIds: FragnoId[];
96
- nonce: string;
97
- }
98
- | {
99
- success: false;
100
- reason: "conflict";
101
- }
102
- | {
103
- success: false;
104
- reason: "aborted";
105
- }
106
- | {
107
- success: false;
108
- reason: "error";
109
- error: unknown;
110
- };
129
+ export interface TxSuccessContextWithoutMutate<
130
+ TRetrieveSuccessResult,
131
+ TServiceFinalResults extends readonly unknown[],
132
+ TServiceRetrieveResults extends readonly unknown[],
133
+ > {
134
+ /** Result from retrieveSuccess callback (or raw retrieve results if no retrieveSuccess) */
135
+ retrieveResult: TRetrieveSuccessResult;
136
+ /** No mutate callback was provided */
137
+ mutateResult: undefined;
138
+ /** Array of final results from service calls */
139
+ serviceResult: TServiceFinalResults;
140
+ /** Array of retrieve success results from service calls (same as what mutate receives) */
141
+ serviceIntermediateResult: TServiceRetrieveResults;
142
+ }
143
+
144
+ /**
145
+ * Context passed to success callback.
146
+ * Union of TxSuccessContextWithMutate and TxSuccessContextWithoutMutate to handle
147
+ * both cases in a single callback signature.
148
+ */
149
+ export type TxSuccessContext<
150
+ TRetrieveSuccessResult,
151
+ TMutateResult,
152
+ TServiceFinalResults extends readonly unknown[],
153
+ TServiceRetrieveResults extends readonly unknown[] = readonly unknown[],
154
+ > =
155
+ | TxSuccessContextWithMutate<
156
+ TRetrieveSuccessResult,
157
+ TMutateResult,
158
+ TServiceFinalResults,
159
+ TServiceRetrieveResults
160
+ >
161
+ | TxSuccessContextWithoutMutate<
162
+ TRetrieveSuccessResult,
163
+ TServiceFinalResults,
164
+ TServiceRetrieveResults
165
+ >;
111
166
 
112
167
  /**
113
- * Callbacks for executing a Unit of Work
168
+ * Callbacks for service-level TxResult.
169
+ *
170
+ * Return type priority:
171
+ * 1. If success exists: ReturnType<success>
172
+ * 2. Else if mutate exists: ReturnType<mutate>
173
+ * 3. Else if retrieveSuccess exists: ReturnType<retrieveSuccess>
174
+ * 4. Else if retrieve exists: TRetrieveResults
175
+ * 5. Else: serviceResult array type
114
176
  */
115
- export interface ExecuteUnitOfWorkCallbacks<
177
+ export interface ServiceTxCallbacks<
116
178
  TSchema extends AnySchema,
117
- TRetrievalResults extends unknown[],
118
- TMutationResult,
119
- TRawInput,
179
+ TRetrieveResults extends unknown[],
180
+ TRetrieveSuccessResult,
181
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
182
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
183
+ TMutateResult,
184
+ TSuccessResult,
185
+ THooks extends HooksMap,
120
186
  > {
121
187
  /**
122
- * Retrieval phase callback - adds retrieval operations to the UOW
188
+ * Service calls - other TxResults to execute first.
189
+ */
190
+ serviceCalls?: () => TServiceCalls;
191
+
192
+ /**
193
+ * Retrieval phase callback - schedules retrieval operations.
123
194
  */
124
195
  retrieve?: (
125
- uow: TypedUnitOfWork<TSchema, [], TRawInput>,
126
- ) => TypedUnitOfWork<TSchema, TRetrievalResults, TRawInput>;
196
+ uow: TypedUnitOfWork<TSchema, [], unknown, THooks>,
197
+ ) => TypedUnitOfWork<TSchema, TRetrieveResults, unknown, THooks>;
127
198
 
128
199
  /**
129
- * Mutation phase callback - receives UOW and retrieval results, adds mutation operations
200
+ * Transform retrieve results before passing to mutate.
201
+ */
202
+ retrieveSuccess?: (
203
+ retrieveResult: TRetrieveResults,
204
+ serviceResult: ExtractServiceRetrieveResults<TServiceCalls>,
205
+ ) => TRetrieveSuccessResult;
206
+
207
+ /**
208
+ * Mutation phase callback - schedules mutations based on retrieve results.
130
209
  */
131
210
  mutate?: (
132
- uow: TypedUnitOfWork<TSchema, TRetrievalResults, TRawInput>,
133
- results: TRetrievalResults,
134
- ) => TMutationResult | Promise<TMutationResult>;
211
+ ctx: ServiceTxMutateContext<
212
+ TSchema,
213
+ TRetrieveSuccessResult,
214
+ ExtractServiceRetrieveResults<TServiceCalls>,
215
+ THooks
216
+ >,
217
+ ) => TMutateResult;
135
218
 
136
219
  /**
137
- * Success callback - invoked after successful execution
138
- * Promises in mutationResult are already unwrapped 1 level deep
220
+ * Success callback - final transformation after mutations complete.
139
221
  */
140
- onSuccess?: (result: {
141
- results: TRetrievalResults;
142
- mutationResult: AwaitedPromisesInObject<TMutationResult>;
143
- createdIds: FragnoId[];
144
- nonce: string;
145
- }) => void | Promise<void>;
222
+ success?: (
223
+ ctx: TxSuccessContext<
224
+ TRetrieveSuccessResult,
225
+ TMutateResult,
226
+ ExtractServiceFinalResults<TServiceCalls>,
227
+ ExtractServiceRetrieveResults<TServiceCalls>
228
+ >,
229
+ ) => TSuccessResult;
146
230
  }
147
231
 
148
232
  /**
149
- * Options for executing a Unit of Work
233
+ * Callbacks for handler-level executeTx.
234
+ * Uses context-based callbacks that provide forSchema() method.
150
235
  */
151
- export interface ExecuteUnitOfWorkOptions<TSchema extends AnySchema, TRawInput> {
236
+ export interface HandlerTxCallbacks<
237
+ TRetrieveResults extends unknown[],
238
+ TRetrieveSuccessResult,
239
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
240
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
241
+ TMutateResult,
242
+ TSuccessResult,
243
+ THooks extends HooksMap,
244
+ > {
152
245
  /**
153
- * Factory function that creates or resets a UOW instance for each attempt
246
+ * Service calls - other TxResults to execute first.
154
247
  */
155
- createUnitOfWork: () => TypedUnitOfWork<TSchema, [], TRawInput>;
248
+ serviceCalls?: () => TServiceCalls;
156
249
 
157
250
  /**
158
- * Retry policy for handling optimistic concurrency conflicts
251
+ * Retrieval phase callback - schedules retrieval operations using context.forSchema().
252
+ * Return a TypedUnitOfWork to get typed results, or void for no retrieval.
159
253
  */
160
- retryPolicy?: RetryPolicy;
254
+ retrieve?: (
255
+ context: HandlerTxContext<THooks>,
256
+ ) => TypedUnitOfWork<AnySchema, TRetrieveResults, unknown, HooksMap> | void;
161
257
 
162
258
  /**
163
- * Abort signal to cancel execution
259
+ * Transform retrieve results before passing to mutate.
164
260
  */
165
- signal?: AbortSignal;
261
+ retrieveSuccess?: (
262
+ retrieveResult: TRetrieveResults,
263
+ serviceResult: ExtractServiceRetrieveResults<TServiceCalls>,
264
+ ) => TRetrieveSuccessResult;
265
+
266
+ /**
267
+ * Mutation phase callback - schedules mutations based on retrieve results.
268
+ */
269
+ mutate?: (
270
+ ctx: HandlerTxMutateContext<
271
+ TRetrieveSuccessResult,
272
+ ExtractServiceRetrieveResults<TServiceCalls>,
273
+ THooks
274
+ >,
275
+ ) => TMutateResult;
276
+
277
+ /**
278
+ * Success callback - final transformation after mutations complete.
279
+ */
280
+ success?: (
281
+ ctx: TxSuccessContext<
282
+ TRetrieveSuccessResult,
283
+ TMutateResult,
284
+ ExtractServiceFinalResults<TServiceCalls>,
285
+ ExtractServiceRetrieveResults<TServiceCalls>
286
+ >,
287
+ ) => TSuccessResult;
166
288
  }
167
289
 
168
290
  /**
169
- * Create a bound version of executeUnitOfWork with a pre-configured UOW factory.
170
- * This is useful for handler contexts where the factory is already known.
171
- *
172
- * @param createUnitOfWork - Factory function that creates a fresh UOW instance
173
- * @returns A bound executeUnitOfWork function that doesn't require the factory parameter
174
- *
175
- * @example
176
- * ```ts
177
- * const boundExecute = createExecuteUnitOfWork(() => db.createUnitOfWork());
178
- * const result = await boundExecute({
179
- * retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
180
- * mutate: (uow, [users]) => {
181
- * uow.update("users", users[0].id, (b) => b.set({ balance: newBalance }));
182
- * }
183
- * });
184
- * ```
291
+ * Internal structure storing TxResult callbacks and state.
185
292
  */
186
- export function createExecuteUnitOfWork<TSchema extends AnySchema, TRawInput>(
187
- createUnitOfWork: () => TypedUnitOfWork<TSchema, [], TRawInput>,
188
- ) {
189
- return async function <TRetrievalResults extends unknown[], TMutationResult = void>(
190
- callbacks: ExecuteUnitOfWorkCallbacks<TSchema, TRetrievalResults, TMutationResult, TRawInput>,
191
- options?: Omit<ExecuteUnitOfWorkOptions<TSchema, TRawInput>, "createUnitOfWork">,
192
- ): Promise<ExecuteUnitOfWorkResult<TRetrievalResults, TMutationResult>> {
193
- return executeUnitOfWork(callbacks, { ...options, createUnitOfWork });
194
- };
293
+ interface TxResultInternal<
294
+ TSchema extends AnySchema,
295
+ TRetrieveResults extends unknown[],
296
+ TRetrieveSuccessResult,
297
+ TServiceCalls extends readonly (TxResult<unknown> | undefined)[],
298
+ TMutateResult,
299
+ TSuccessResult,
300
+ THooks extends HooksMap,
301
+ > {
302
+ schema: TSchema | undefined;
303
+ callbacks: ServiceTxCallbacks<
304
+ TSchema,
305
+ TRetrieveResults,
306
+ TRetrieveSuccessResult,
307
+ TServiceCalls,
308
+ TMutateResult,
309
+ TSuccessResult,
310
+ THooks
311
+ >;
312
+ /** The typed UOW created during retrieve callback */
313
+ typedUow: TypedUnitOfWork<TSchema, TRetrieveResults, unknown, THooks> | undefined;
314
+ /** The restricted UOW for signaling (used when typedUow is undefined) */
315
+ restrictedUow: IUnitOfWork;
316
+ /** Promise that resolves when retrieve phase is complete */
317
+ retrievePhase: Promise<TRetrieveResults>;
318
+ /** Resolve function for retrievePhase */
319
+ resolveRetrievePhase: (results: TRetrieveResults) => void;
320
+ /** Reject function for retrievePhase */
321
+ rejectRetrievePhase: (error: unknown) => void;
322
+ /** Computed retrieve success result (set after retrieveSuccess runs) */
323
+ retrieveSuccessResult: TRetrieveSuccessResult | undefined;
324
+ /** Computed mutate result (set after mutate runs) */
325
+ mutateResult: TMutateResult | undefined;
326
+ /** Computed final result (set after success runs or defaults) */
327
+ finalResult: TSuccessResult | undefined;
328
+ /** Service calls resolved */
329
+ serviceCalls: TServiceCalls | undefined;
195
330
  }
196
331
 
197
332
  /**
198
- * Execute a Unit of Work with automatic retry support for optimistic concurrency conflicts.
333
+ * TxResult represents a transaction definition (not yet executed).
334
+ * It describes the work to be done: retrieve operations, transformations, and mutations.
199
335
  *
200
- * This function orchestrates the two-phase execution (retrieval + mutation) with retry logic.
201
- * It creates fresh UOW instances for each attempt.
336
+ * Service methods return TxResult objects, and the handler's executeTx function
337
+ * orchestrates their execution with retry support.
202
338
  *
203
- * @param callbacks - Object containing retrieve, mutate, and onSuccess callbacks
204
- * @param options - Configuration including UOW factory, retry policy, and abort signal
205
- * @returns Promise resolving to the execution result
206
- *
207
- * @example
208
- * ```ts
209
- * const result = await executeUnitOfWork(
210
- * {
211
- * retrieve: (uow) => uow.find("users", (b) => b.whereIndex("primary")),
212
- * mutate: (uow, [users]) => {
213
- * const user = users[0];
214
- * uow.update("users", user.id, (b) => b.set({ balance: newBalance }));
215
- * },
216
- * onSuccess: async ({ results, mutationResult }) => {
217
- * console.log("Update successful!");
218
- * }
219
- * },
220
- * {
221
- * createUnitOfWork: () => queryEngine.createUnitOfWork(),
222
- * retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 3 })
223
- * }
224
- * );
225
- * ```
339
+ * @template TResult - The final result type (determined by return type priority)
340
+ * @template TRetrieveSuccessResult - The retrieve success result type (what serviceCalls receive).
341
+ * Defaults to TResult, meaning serviceCalls receive the same type as the final result.
226
342
  */
227
- export async function executeUnitOfWork<
343
+ export interface TxResult<TResult, TRetrieveSuccessResult = TResult> {
344
+ /** Brand to identify TxResult objects */
345
+ readonly [TX_RESULT_BRAND]: true;
346
+
347
+ /** Internal structure - do not access directly */
348
+ readonly _internal: TxResultInternal<
349
+ AnySchema,
350
+ unknown[],
351
+ TRetrieveSuccessResult,
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ readonly TxResult<any, any>[],
354
+ unknown,
355
+ TResult,
356
+ HooksMap
357
+ >;
358
+ }
359
+
360
+ /**
361
+ * Create a TxResult for service context.
362
+ * Schedules retrieve operations on the baseUow and returns a TxResult with callbacks stored.
363
+ * @internal Used by ServiceTxBuilder.build()
364
+ */
365
+ function createServiceTx<
228
366
  TSchema extends AnySchema,
229
- TRetrievalResults extends unknown[],
230
- TMutationResult = void,
231
- TRawInput = unknown,
367
+ TRetrieveResults extends unknown[],
368
+ TRetrieveSuccessResult,
369
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
370
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
371
+ TMutateResult,
372
+ TSuccessResult,
373
+ THooks extends HooksMap = {},
232
374
  >(
233
- callbacks: ExecuteUnitOfWorkCallbacks<TSchema, TRetrievalResults, TMutationResult, TRawInput>,
234
- options: ExecuteUnitOfWorkOptions<TSchema, TRawInput>,
235
- ): Promise<ExecuteUnitOfWorkResult<TRetrievalResults, TMutationResult>> {
236
- // Validate that at least one of retrieve or mutate is provided
237
- if (!callbacks.retrieve && !callbacks.mutate) {
238
- throw new Error("At least one of 'retrieve' or 'mutate' callbacks must be provided");
239
- }
375
+ schema: TSchema | undefined,
376
+ callbacks: ServiceTxCallbacks<
377
+ TSchema,
378
+ TRetrieveResults,
379
+ TRetrieveSuccessResult,
380
+ TServiceCalls,
381
+ TMutateResult,
382
+ TSuccessResult,
383
+ THooks
384
+ >,
385
+ baseUow: IUnitOfWork,
386
+ ): TxResult<unknown, unknown> {
387
+ // Create deferred promise for retrieve phase
388
+ const {
389
+ promise: retrievePhase,
390
+ resolve: resolveRetrievePhase,
391
+ reject: rejectRetrievePhase,
392
+ } = Promise.withResolvers<TRetrieveResults>();
240
393
 
241
- const retryPolicy = options.retryPolicy ?? new NoRetryPolicy();
242
- const signal = options.signal;
243
- let attempt = 0;
394
+ // Get a restricted view that signals readiness
395
+ const restrictedUow = baseUow.restrict({ readyFor: "none" });
244
396
 
245
- while (true) {
246
- // Check if aborted before starting attempt
247
- if (signal?.aborted) {
248
- return { success: false, reason: "aborted" };
397
+ // Call serviceCalls factory if provided - this invokes other services which schedule their operations
398
+ let serviceCalls: TServiceCalls | undefined;
399
+ try {
400
+ if (callbacks.serviceCalls) {
401
+ serviceCalls = callbacks.serviceCalls();
249
402
  }
403
+ } catch (error) {
404
+ restrictedUow.signalReadyForRetrieval();
405
+ restrictedUow.signalReadyForMutation();
406
+ retrievePhase.catch(() => {});
407
+ rejectRetrievePhase(error);
408
+ throw error;
409
+ }
410
+ let typedUow: TypedUnitOfWork<TSchema, TRetrieveResults, unknown, THooks> | undefined;
411
+ try {
412
+ if (schema && callbacks.retrieve) {
413
+ const emptyUow = restrictedUow.forSchema<TSchema, THooks>(schema);
414
+ typedUow = callbacks.retrieve(emptyUow);
415
+ }
416
+ } catch (error) {
417
+ restrictedUow.signalReadyForRetrieval();
418
+ restrictedUow.signalReadyForMutation();
419
+ retrievePhase.catch(() => {});
420
+ rejectRetrievePhase(error);
421
+ throw error;
422
+ }
423
+ restrictedUow.signalReadyForRetrieval();
250
424
 
251
- try {
252
- // Create a fresh UOW for this attempt
253
- const uow = options.createUnitOfWork();
254
-
255
- // Apply retrieval phase if provided
256
- let retrievalUow: TypedUnitOfWork<TSchema, TRetrievalResults, TRawInput>;
257
- if (callbacks.retrieve) {
258
- retrievalUow = callbacks.retrieve(uow);
259
- } else {
260
- // No retrieval phase, use empty UOW with type cast
261
- // This is safe because when there's no retrieve, TRetrievalResults should be []
262
- retrievalUow = uow as unknown as TypedUnitOfWork<TSchema, TRetrievalResults, TRawInput>;
263
- }
264
-
265
- // Execute retrieval phase
266
- const results = (await retrievalUow.executeRetrieve()) as TRetrievalResults;
267
-
268
- // Invoke mutation phase callback if provided
269
- let mutationResult: TMutationResult;
270
- if (callbacks.mutate) {
271
- mutationResult = await callbacks.mutate(retrievalUow, results);
272
- } else {
273
- mutationResult = undefined as TMutationResult;
274
- }
275
-
276
- // Execute mutation phase
277
- const { success } = await retrievalUow.executeMutations();
278
-
279
- if (success) {
280
- // Success! Get created IDs and nonce, then invoke onSuccess if provided
281
- const createdIds = retrievalUow.getCreatedIds();
282
- const nonce = retrievalUow.nonce;
283
-
284
- // Await promises in mutationResult (1 level deep)
285
- const awaitedMutationResult = await awaitPromisesInObject(mutationResult);
286
-
287
- if (callbacks.onSuccess) {
288
- await callbacks.onSuccess({
289
- results,
290
- mutationResult: awaitedMutationResult,
291
- createdIds,
292
- nonce,
293
- });
294
- }
295
-
296
- return {
297
- success: true,
298
- results,
299
- mutationResult: awaitedMutationResult,
300
- createdIds,
301
- nonce,
302
- };
303
- }
304
-
305
- // Failed - check if we should retry
306
- // attempt represents the number of attempts completed so far
307
- if (!retryPolicy.shouldRetry(attempt, undefined, signal)) {
308
- // No more retries
309
- return { success: false, reason: "conflict" };
310
- }
425
+ // Set up the retrieve phase promise to resolve when the handler executes retrieve
426
+ if (typedUow) {
427
+ typedUow.retrievalPhase.then(
428
+ (results) => resolveRetrievePhase(results as TRetrieveResults),
429
+ (error) => rejectRetrievePhase(error),
430
+ );
431
+ } else if (!callbacks.retrieve) {
432
+ // No retrieve callback - resolve immediately with empty array
433
+ resolveRetrievePhase([] as unknown as TRetrieveResults);
434
+ }
311
435
 
312
- // Wait before retrying
313
- const delayMs = retryPolicy.getDelayMs(attempt);
314
- if (delayMs > 0) {
315
- await new Promise((resolve) => setTimeout(resolve, delayMs));
316
- }
436
+ const internal: TxResultInternal<
437
+ TSchema,
438
+ TRetrieveResults,
439
+ TRetrieveSuccessResult,
440
+ TServiceCalls,
441
+ TMutateResult,
442
+ TSuccessResult,
443
+ THooks
444
+ > = {
445
+ schema,
446
+ callbacks,
447
+ typedUow,
448
+ restrictedUow,
449
+ retrievePhase,
450
+ resolveRetrievePhase,
451
+ rejectRetrievePhase,
452
+ retrieveSuccessResult: undefined,
453
+ mutateResult: undefined,
454
+ finalResult: undefined,
455
+ serviceCalls,
456
+ };
317
457
 
318
- // Increment attempt counter for next iteration
319
- attempt++;
320
- } catch (error) {
321
- // An error was thrown during execution
322
- return { success: false, reason: "error", error };
323
- }
324
- }
458
+ return {
459
+ [TX_RESULT_BRAND]: true as const,
460
+ // Cast through unknown to avoid type incompatibility issues with generic constraints
461
+ _internal: internal as unknown as TxResultInternal<
462
+ AnySchema,
463
+ unknown[],
464
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
+ any,
466
+ readonly TxResult<unknown>[],
467
+ unknown,
468
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
469
+ any,
470
+ HooksMap
471
+ >,
472
+ };
325
473
  }
326
474
 
327
475
  /**
328
- * Options for executing a Unit of Work with restricted access
476
+ * Options for executing transactions
329
477
  */
330
- export interface ExecuteRestrictedUnitOfWorkOptions {
478
+ export interface ExecuteTxOptions {
331
479
  /**
332
480
  * Factory function that creates or resets a UOW instance for each attempt
333
481
  */
@@ -353,61 +501,229 @@ export interface ExecuteRestrictedUnitOfWorkOptions {
353
501
  * Callback invoked after successful mutation phase.
354
502
  * Use this for post-mutation processing like hook execution.
355
503
  */
356
- onSuccess?: (uow: IUnitOfWork) => Promise<void>;
504
+ onAfterMutate?: (uow: IUnitOfWork) => Promise<void>;
505
+ }
506
+
507
+ /**
508
+ * Recursively collect all TxResults from a service call tree.
509
+ * Returns them in a flat array in dependency order (serviceCalls before their dependents).
510
+ * Skips undefined values (which can occur with optional service patterns like
511
+ * optionalService?.method()).
512
+ */
513
+ function collectAllTxResults(
514
+ txResults: readonly (TxResult<unknown> | undefined)[],
515
+ ): TxResult<unknown>[] {
516
+ const collected: TxResult<unknown>[] = [];
517
+ const seen = new Set<TxResult<unknown>>();
518
+
519
+ function collect(txResult: TxResult<unknown> | undefined) {
520
+ if (txResult === undefined) {
521
+ return;
522
+ }
523
+
524
+ if (seen.has(txResult)) {
525
+ return;
526
+ }
527
+ seen.add(txResult);
528
+
529
+ // First collect serviceCalls (so they come before this TxResult)
530
+ const serviceCalls = txResult._internal.serviceCalls;
531
+ if (serviceCalls) {
532
+ for (const serviceCall of serviceCalls) {
533
+ collect(serviceCall);
534
+ }
535
+ }
536
+
537
+ collected.push(txResult);
538
+ }
539
+
540
+ for (const txResult of txResults) {
541
+ collect(txResult);
542
+ }
543
+
544
+ return collected;
545
+ }
546
+
547
+ /**
548
+ * Execute a single TxResult's callbacks after retrieve phase completes.
549
+ * This processes retrieveSuccess, mutate, and success callbacks in order.
550
+ */
551
+ async function processTxResultAfterRetrieve<T>(
552
+ txResult: TxResult<T>,
553
+ baseUow: IUnitOfWork,
554
+ ): Promise<void> {
555
+ const internal = txResult._internal;
556
+ const callbacks = internal.callbacks;
557
+
558
+ // Wait for retrieve phase to complete
559
+ const retrieveResults = await internal.retrievePhase;
560
+
561
+ // Collect serviceCalls' retrieve success results (or mutate results if no retrieve was provided)
562
+ // When a serviceCall has no retrieve/retrieveSuccess but has mutate, its mutate has already run
563
+ // (due to service call execution order), so we use its mutate result as the "retrieve success result".
564
+ const serviceResults: unknown[] = [];
565
+ if (internal.serviceCalls) {
566
+ for (const serviceCall of internal.serviceCalls) {
567
+ if (serviceCall === undefined) {
568
+ serviceResults.push(undefined);
569
+ continue;
570
+ }
571
+
572
+ const serviceCallInternal = serviceCall._internal;
573
+ // Check if this is a mutate-only service call (empty array sentinel with mutate callback)
574
+ // In that case, prefer mutateResult over the empty array retrieveSuccessResult
575
+ if (
576
+ serviceCallInternal.retrieveSuccessResult !== undefined &&
577
+ !(
578
+ Array.isArray(serviceCallInternal.retrieveSuccessResult) &&
579
+ serviceCallInternal.retrieveSuccessResult.length === 0 &&
580
+ serviceCallInternal.callbacks.mutate
581
+ )
582
+ ) {
583
+ serviceResults.push(serviceCallInternal.retrieveSuccessResult);
584
+ } else if (serviceCallInternal.mutateResult !== undefined) {
585
+ serviceResults.push(serviceCallInternal.mutateResult);
586
+ } else {
587
+ serviceResults.push(serviceCallInternal.retrieveSuccessResult);
588
+ }
589
+ }
590
+ }
591
+
592
+ if (callbacks.retrieveSuccess) {
593
+ internal.retrieveSuccessResult = callbacks.retrieveSuccess(
594
+ retrieveResults,
595
+ serviceResults as ExtractServiceRetrieveResults<readonly TxResult<unknown>[]>,
596
+ );
597
+ } else {
598
+ internal.retrieveSuccessResult = retrieveResults as typeof internal.retrieveSuccessResult;
599
+ }
600
+
601
+ if (callbacks.mutate) {
602
+ const mutateCtx = {
603
+ uow: internal.schema
604
+ ? baseUow.forSchema(internal.schema)
605
+ : (undefined as unknown as TypedUnitOfWork<AnySchema, [], unknown, HooksMap>),
606
+ // At this point retrieveSuccessResult has been set (either by retrieveSuccess
607
+ // callback or defaulted to retrieveResults)
608
+ retrieveResult: internal.retrieveSuccessResult as NonNullable<
609
+ typeof internal.retrieveSuccessResult
610
+ >,
611
+ serviceIntermediateResult: serviceResults as ExtractServiceRetrieveResults<
612
+ readonly TxResult<unknown>[]
613
+ >,
614
+ };
615
+ internal.mutateResult = callbacks.mutate(mutateCtx);
616
+ }
617
+
618
+ if (internal.typedUow) {
619
+ internal.typedUow.signalReadyForMutation();
620
+ } else {
621
+ // For TxResults without retrieve callback, signal via the restricted UOW
622
+ internal.restrictedUow.signalReadyForMutation();
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Execute a single TxResult's success callback after mutations complete.
628
+ */
629
+ async function processTxResultAfterMutate<T>(txResult: TxResult<T>): Promise<T> {
630
+ const internal = txResult._internal;
631
+ const callbacks = internal.callbacks;
632
+
633
+ const serviceIntermediateResults: unknown[] = [];
634
+ const serviceFinalResults: unknown[] = [];
635
+ if (internal.serviceCalls) {
636
+ for (const serviceCall of internal.serviceCalls) {
637
+ if (serviceCall === undefined) {
638
+ serviceIntermediateResults.push(undefined);
639
+ serviceFinalResults.push(undefined);
640
+ continue;
641
+ }
642
+
643
+ // Mirror the logic from processTxResultAfterRetrieve/executeTx:
644
+ // For mutate-only serviceCalls (no retrieve phase, just mutations), use mutateResult instead of retrieveSuccessResult
645
+ const serviceCallInternal = serviceCall._internal;
646
+ // Check if this is a mutate-only service call (empty array sentinel with mutate callback)
647
+ // In that case, prefer mutateResult over the empty array retrieveSuccessResult
648
+ if (
649
+ serviceCallInternal.retrieveSuccessResult !== undefined &&
650
+ !(
651
+ Array.isArray(serviceCallInternal.retrieveSuccessResult) &&
652
+ serviceCallInternal.retrieveSuccessResult.length === 0 &&
653
+ serviceCallInternal.callbacks.mutate
654
+ )
655
+ ) {
656
+ serviceIntermediateResults.push(serviceCallInternal.retrieveSuccessResult);
657
+ } else if (serviceCallInternal.mutateResult !== undefined) {
658
+ serviceIntermediateResults.push(serviceCallInternal.mutateResult);
659
+ } else {
660
+ serviceIntermediateResults.push(serviceCallInternal.retrieveSuccessResult);
661
+ }
662
+ serviceFinalResults.push(serviceCallInternal.finalResult);
663
+ }
664
+ }
665
+
666
+ if (callbacks.success) {
667
+ const successCtx = {
668
+ retrieveResult: internal.retrieveSuccessResult as NonNullable<
669
+ typeof internal.retrieveSuccessResult
670
+ >,
671
+ mutateResult: internal.mutateResult,
672
+ serviceResult: serviceFinalResults as ExtractServiceFinalResults<
673
+ readonly TxResult<unknown>[]
674
+ >,
675
+ serviceIntermediateResult: serviceIntermediateResults as ExtractServiceRetrieveResults<
676
+ readonly TxResult<unknown>[]
677
+ >,
678
+ };
679
+ internal.finalResult = callbacks.success(successCtx) as T;
680
+ } else if (callbacks.mutate) {
681
+ internal.finalResult = (await awaitPromisesInObject(internal.mutateResult)) as T;
682
+ } else if (callbacks.retrieveSuccess || callbacks.retrieve) {
683
+ internal.finalResult = internal.retrieveSuccessResult as T;
684
+ } else {
685
+ internal.finalResult = serviceFinalResults as T;
686
+ }
687
+
688
+ return internal.finalResult as T;
357
689
  }
358
690
 
359
691
  /**
360
- * Execute a Unit of Work with explicit phase control and automatic retry support.
692
+ * Execute a transaction with the unified TxResult pattern.
361
693
  *
362
- * This function provides an alternative API where users write a single callback that receives
363
- * a context object with forSchema, executeRetrieve, and executeMutate methods. The user can
364
- * create schema-specific UOWs via forSchema, then call executeRetrieve() and executeMutate()
365
- * to execute the retrieval and mutation phases. The entire callback is re-executed on optimistic
366
- * concurrency conflicts, ensuring retries work properly.
694
+ * This is the handler-level function that actually executes TxResults with retry support.
367
695
  *
368
- * @param callback - Async function that receives a context with forSchema, executeRetrieve, executeMutate, nonce, and currentAttempt
696
+ * @param callbacks - Transaction callbacks (serviceCalls, retrieve, retrieveSuccess, mutate, success)
369
697
  * @param options - Configuration including UOW factory, retry policy, and abort signal
370
- * @returns Promise resolving to the callback's return value
371
- * @throws Error if retries are exhausted or callback throws an error
698
+ * @returns Promise resolving to the result determined by return type priority
372
699
  *
373
700
  * @example
374
701
  * ```ts
375
- * const { userId, profileId } = await executeRestrictedUnitOfWork(
376
- * async ({ forSchema, executeRetrieve, executeMutate, nonce, currentAttempt }) => {
377
- * const uow = forSchema(schema);
378
- * const userId = uow.create("users", { name: "John" });
379
- *
380
- * // Execute retrieval phase
381
- * await executeRetrieve();
382
- *
383
- * const profileId = uow.create("profiles", { userId });
384
- *
385
- * // Execute mutation phase
386
- * await executeMutate();
387
- *
388
- * return { userId, profileId };
389
- * },
390
- * {
391
- * createUnitOfWork: () => db.createUnitOfWork(),
392
- * retryPolicy: new ExponentialBackoffRetryPolicy({ maxRetries: 5 })
393
- * }
394
- * );
395
- * ```
702
+ * // Simple retrieve + transform
703
+ * const user = await executeTx({
704
+ * retrieve: (ctx) => ctx.forSchema(usersSchema).find("users", ...),
705
+ * retrieveSuccess: ([users]) => users[0] ?? null,
706
+ * }, { createUnitOfWork });
707
+ * @internal Used by HandlerTxBuilder.execute()
396
708
  */
397
- export async function executeRestrictedUnitOfWork<TResult, THooks extends HooksMap = {}>(
398
- callback: (context: {
399
- forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
400
- schema: S,
401
- hooks?: H,
402
- ) => TypedUnitOfWork<S, [], unknown, H>;
403
- executeRetrieve: () => Promise<void>;
404
- executeMutate: () => Promise<void>;
405
- nonce: string;
406
- currentAttempt: number;
407
- }) => Promise<TResult>,
408
- options: ExecuteRestrictedUnitOfWorkOptions,
409
- ): Promise<AwaitedPromisesInObject<TResult>> {
410
- // Default retry policy with small, fast retries for optimistic concurrency
709
+ async function executeTx(
710
+ callbacks: HandlerTxCallbacks<
711
+ unknown[],
712
+ unknown,
713
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
714
+ readonly (TxResult<any, any> | undefined)[],
715
+ unknown,
716
+ unknown,
717
+ HooksMap
718
+ >,
719
+ options: ExecuteTxOptions,
720
+ ): Promise<unknown> {
721
+ type TRetrieveResults = unknown[];
722
+ type TRetrieveSuccessResult = unknown;
723
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
724
+ type TServiceCalls = readonly (TxResult<any, any> | undefined)[];
725
+ type TMutateResult = unknown;
726
+ type THooks = HooksMap;
411
727
  const retryPolicy =
412
728
  options.retryPolicy ??
413
729
  new ExponentialBackoffRetryPolicy({
@@ -421,84 +737,1171 @@ export async function executeRestrictedUnitOfWork<TResult, THooks extends HooksM
421
737
  while (true) {
422
738
  // Check if aborted before starting attempt
423
739
  if (signal?.aborted) {
424
- throw new Error("Unit of Work execution aborted");
740
+ throw new Error("Transaction execution aborted");
425
741
  }
426
742
 
427
743
  try {
428
744
  // Create a fresh UOW for this attempt
429
745
  const baseUow = options.createUnitOfWork();
430
746
 
431
- const context = {
747
+ // Create handler context
748
+ const context: HandlerTxContext<THooks> = {
432
749
  forSchema: <S extends AnySchema, H extends HooksMap = THooks>(schema: S, hooks?: H) => {
433
750
  return baseUow.forSchema(schema, hooks);
434
751
  },
435
- executeRetrieve: async () => {
436
- await baseUow.executeRetrieve();
437
- },
438
- executeMutate: async () => {
439
- if (baseUow.state === "executed") {
440
- return;
441
- }
752
+ idempotencyKey: baseUow.idempotencyKey,
753
+ currentAttempt: attempt,
754
+ };
442
755
 
443
- if (baseUow.state === "building-retrieval") {
444
- await baseUow.executeRetrieve();
445
- }
756
+ // Call serviceCalls factory if provided - this creates TxResults that schedule operations
757
+ let serviceCalls: TServiceCalls | undefined;
758
+ if (callbacks.serviceCalls) {
759
+ serviceCalls = callbacks.serviceCalls();
760
+ }
446
761
 
447
- // Add hook mutations before executing
448
- if (options.onBeforeMutate) {
449
- options.onBeforeMutate(baseUow);
450
- }
762
+ // Call retrieve callback - it returns a TypedUnitOfWork with scheduled operations or void
763
+ const typedUowFromRetrieve = callbacks.retrieve?.(context);
764
+
765
+ const allServiceCallTxResults = serviceCalls ? collectAllTxResults([...serviceCalls]) : [];
766
+
767
+ await baseUow.executeRetrieve();
451
768
 
452
- const result = await baseUow.executeMutations();
453
- if (!result.success) {
454
- throw new ConcurrencyConflictError();
769
+ // Get retrieve results from TypedUnitOfWork's retrievalPhase or default to empty array
770
+ const retrieveResult: TRetrieveResults = typedUowFromRetrieve
771
+ ? await typedUowFromRetrieve.retrievalPhase
772
+ : ([] as unknown as TRetrieveResults);
773
+
774
+ for (const txResult of allServiceCallTxResults) {
775
+ await processTxResultAfterRetrieve(txResult, baseUow);
776
+ }
777
+
778
+ const serviceResults: unknown[] = [];
779
+ if (serviceCalls) {
780
+ for (const serviceCall of serviceCalls) {
781
+ if (serviceCall === undefined) {
782
+ serviceResults.push(undefined);
783
+ continue;
784
+ }
785
+ const serviceCallInternal = serviceCall._internal;
786
+ // Check if this is a mutate-only service call (empty array sentinel with mutate callback)
787
+ // In that case, prefer mutateResult over the empty array retrieveSuccessResult
788
+ if (
789
+ serviceCallInternal.retrieveSuccessResult !== undefined &&
790
+ !(
791
+ Array.isArray(serviceCallInternal.retrieveSuccessResult) &&
792
+ serviceCallInternal.retrieveSuccessResult.length === 0 &&
793
+ serviceCallInternal.callbacks.mutate
794
+ )
795
+ ) {
796
+ serviceResults.push(serviceCallInternal.retrieveSuccessResult);
797
+ } else if (serviceCallInternal.mutateResult !== undefined) {
798
+ serviceResults.push(serviceCallInternal.mutateResult);
799
+ } else {
800
+ serviceResults.push(serviceCallInternal.retrieveSuccessResult);
455
801
  }
802
+ }
803
+ }
804
+
805
+ // Call retrieveSuccess if provided
806
+ let retrieveSuccessResult: TRetrieveSuccessResult;
807
+ if (callbacks.retrieveSuccess) {
808
+ retrieveSuccessResult = callbacks.retrieveSuccess(
809
+ retrieveResult,
810
+ serviceResults as ExtractServiceRetrieveResults<TServiceCalls>,
811
+ );
812
+ } else {
813
+ retrieveSuccessResult = retrieveResult as unknown as TRetrieveSuccessResult;
814
+ }
815
+
816
+ let mutateResult: TMutateResult | undefined;
817
+ if (callbacks.mutate) {
818
+ const mutateCtx: HandlerTxMutateContext<
819
+ TRetrieveSuccessResult,
820
+ ExtractServiceRetrieveResults<TServiceCalls>,
821
+ THooks
822
+ > = {
823
+ ...context,
824
+ retrieveResult: retrieveSuccessResult,
825
+ serviceIntermediateResult: serviceResults as ExtractServiceRetrieveResults<TServiceCalls>,
826
+ };
827
+ mutateResult = callbacks.mutate(mutateCtx);
828
+ }
829
+
830
+ if (options.onBeforeMutate) {
831
+ options.onBeforeMutate(baseUow);
832
+ }
833
+ const result = await baseUow.executeMutations();
834
+ if (!result.success) {
835
+ throw new ConcurrencyConflictError();
836
+ }
837
+
838
+ // Process each serviceCall TxResult's success callback
839
+ for (const txResult of allServiceCallTxResults) {
840
+ await processTxResultAfterMutate(txResult);
841
+ }
456
842
 
457
- if (options.onSuccess) {
458
- await options.onSuccess(baseUow);
843
+ const serviceFinalResults: unknown[] = [];
844
+ if (serviceCalls) {
845
+ for (const serviceCall of serviceCalls) {
846
+ if (serviceCall === undefined) {
847
+ serviceFinalResults.push(undefined);
848
+ continue;
459
849
  }
460
- },
461
- nonce: baseUow.nonce,
462
- currentAttempt: attempt,
463
- };
850
+ serviceFinalResults.push(serviceCall._internal.finalResult);
851
+ }
852
+ }
464
853
 
465
- // Execute the callback which will call executeRetrieve and executeMutate
466
- const result = await callback(context);
854
+ let finalResult: unknown;
855
+ if (callbacks.success) {
856
+ // The success context type is determined by the overload - we construct it at runtime
857
+ // and the type safety is guaranteed by the discriminated overloads
858
+ const successCtx = {
859
+ retrieveResult: retrieveSuccessResult,
860
+ mutateResult,
861
+ serviceResult: serviceFinalResults as ExtractServiceFinalResults<TServiceCalls>,
862
+ serviceIntermediateResult: serviceResults as ExtractServiceRetrieveResults<TServiceCalls>,
863
+ } as Parameters<NonNullable<typeof callbacks.success>>[0];
864
+ finalResult = callbacks.success(successCtx);
865
+ } else if (callbacks.mutate) {
866
+ finalResult = await awaitPromisesInObject(mutateResult);
867
+ } else if (callbacks.retrieveSuccess || callbacks.retrieve) {
868
+ finalResult = retrieveSuccessResult;
869
+ } else {
870
+ finalResult = serviceFinalResults;
871
+ }
467
872
 
468
- // Await promises in the result object (1 level deep)
469
- const awaitedResult = await awaitPromisesInObject(result);
873
+ if (options.onAfterMutate) {
874
+ await options.onAfterMutate(baseUow);
875
+ }
470
876
 
471
- // Return the awaited result
472
- return awaitedResult;
877
+ return await awaitPromisesInObject(finalResult);
473
878
  } catch (error) {
474
879
  if (signal?.aborted) {
475
- throw new Error("Unit of Work execution aborted");
880
+ throw new Error("Transaction execution aborted");
476
881
  }
477
882
 
478
883
  // Only retry concurrency conflicts, not other errors
479
884
  if (!(error instanceof ConcurrencyConflictError)) {
480
- // Not a concurrency conflict - throw immediately without retry
481
885
  throw error;
482
886
  }
483
887
 
484
888
  if (!retryPolicy.shouldRetry(attempt, error, signal)) {
485
- // No more retries - check again if aborted or throw conflict error
486
889
  if (signal?.aborted) {
487
- throw new Error("Unit of Work execution aborted");
890
+ throw new Error("Transaction execution aborted");
488
891
  }
489
- throw new Error("Unit of Work execution failed: optimistic concurrency conflict", {
490
- cause: error,
491
- });
892
+ throw new ConcurrencyConflictError();
492
893
  }
493
894
 
494
- // Wait before retrying
495
895
  const delayMs = retryPolicy.getDelayMs(attempt);
496
896
  if (delayMs > 0) {
497
897
  await new Promise((resolve) => setTimeout(resolve, delayMs));
498
898
  }
499
899
 
500
- // Increment attempt counter for next iteration
501
900
  attempt++;
502
901
  }
503
902
  }
504
903
  }
904
+
905
+ /**
906
+ * Error thrown when a Unit of Work execution fails due to optimistic concurrency conflict.
907
+ * This error triggers automatic retry behavior in executeTx.
908
+ */
909
+ export class ConcurrencyConflictError extends Error {
910
+ constructor(message = "Optimistic concurrency conflict detected") {
911
+ super(message);
912
+ this.name = "ConcurrencyConflictError";
913
+ }
914
+ }
915
+
916
+ /**
917
+ * Type utility that unwraps promises 1 level deep in objects, arrays, or direct promises
918
+ * Handles tuples, arrays, objects, and direct promises
919
+ */
920
+ export type AwaitedPromisesInObject<T> =
921
+ // First check if it's a Promise
922
+ T extends Promise<infer U>
923
+ ? Awaited<U>
924
+ : // Check for arrays with known length (tuples) - preserves tuple structure
925
+ T extends readonly [unknown, ...unknown[]]
926
+ ? { [K in keyof T]: AwaitedPromisesInObject<T[K]> }
927
+ : T extends [unknown, ...unknown[]]
928
+ ? { [K in keyof T]: AwaitedPromisesInObject<T[K]> }
929
+ : // Check for regular arrays (unknown length)
930
+ T extends (infer U)[]
931
+ ? Awaited<U>[]
932
+ : T extends readonly (infer U)[]
933
+ ? readonly Awaited<U>[]
934
+ : // Check for objects
935
+ T extends Record<string, unknown>
936
+ ? {
937
+ [K in keyof T]: T[K] extends Promise<infer U> ? Awaited<U> : T[K];
938
+ }
939
+ : // Otherwise return as-is
940
+ T;
941
+
942
+ /**
943
+ * Await promises in an object 1 level deep
944
+ */
945
+ async function awaitPromisesInObject<T>(obj: T): Promise<AwaitedPromisesInObject<T>> {
946
+ if (obj === null || obj === undefined) {
947
+ return obj as AwaitedPromisesInObject<T>;
948
+ }
949
+
950
+ if (typeof obj !== "object") {
951
+ return obj as AwaitedPromisesInObject<T>;
952
+ }
953
+
954
+ // Check if it's a Promise
955
+ if (obj instanceof Promise) {
956
+ return (await obj) as AwaitedPromisesInObject<T>;
957
+ }
958
+
959
+ // Check if it's an array
960
+ if (Array.isArray(obj)) {
961
+ const awaited = await Promise.all(
962
+ obj.map((item) => (item instanceof Promise ? item : Promise.resolve(item))),
963
+ );
964
+ return awaited as AwaitedPromisesInObject<T>;
965
+ }
966
+
967
+ if (obj.constructor !== Object) {
968
+ return obj as AwaitedPromisesInObject<T>;
969
+ }
970
+ const result = {} as T;
971
+ const entries = Object.entries(obj as Record<string, unknown>);
972
+ const awaitedEntries = await Promise.all(
973
+ entries.map(async ([key, value]) => {
974
+ const awaitedValue = value instanceof Promise ? await value : value;
975
+ return [key, awaitedValue] as const;
976
+ }),
977
+ );
978
+
979
+ for (const [key, value] of awaitedEntries) {
980
+ (result as Record<string, unknown>)[key] = value;
981
+ }
982
+
983
+ return result as AwaitedPromisesInObject<T>;
984
+ }
985
+
986
+ // ============================================================================
987
+ // Builder Pattern Types and Classes
988
+ // ============================================================================
989
+
990
+ /**
991
+ * Context passed to service-level mutate callback in builder pattern.
992
+ */
993
+ export interface ServiceBuilderMutateContext<
994
+ TSchema extends AnySchema,
995
+ TRetrieveSuccessResult,
996
+ TServiceResult extends readonly unknown[],
997
+ THooks extends HooksMap,
998
+ > {
999
+ /** Unit of work for scheduling mutations */
1000
+ uow: TypedUnitOfWork<TSchema, [], unknown, THooks>;
1001
+ /** Result from transformRetrieve callback (or raw retrieve results if no transformRetrieve) */
1002
+ retrieveResult: TRetrieveSuccessResult;
1003
+ /** Array of retrieve success results from service calls (intermediate results, not final: retrieve results if service has retrieve, mutate result if service only mutates) */
1004
+ serviceIntermediateResult: TServiceResult;
1005
+ }
1006
+
1007
+ /**
1008
+ * Context passed to handler-level mutate callback in builder pattern.
1009
+ */
1010
+ export interface HandlerBuilderMutateContext<
1011
+ TRetrieveSuccessResult,
1012
+ TServiceResult extends readonly unknown[],
1013
+ THooks extends HooksMap,
1014
+ > {
1015
+ /** Get a typed Unit of Work for the given schema */
1016
+ forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
1017
+ schema: S,
1018
+ hooks?: H,
1019
+ ) => TypedUnitOfWork<S, [], unknown, H>;
1020
+ /** Unique key for this transaction (for idempotency/deduplication) */
1021
+ idempotencyKey: string;
1022
+ /** Current attempt number (0-based) */
1023
+ currentAttempt: number;
1024
+ /** Result from transformRetrieve callback (or raw retrieve results if no transformRetrieve) */
1025
+ retrieveResult: TRetrieveSuccessResult;
1026
+ /** Array of retrieve success results from service calls (intermediate results, not final: retrieve results if service has retrieve, mutate result if service only mutates) */
1027
+ serviceIntermediateResult: TServiceResult;
1028
+ }
1029
+
1030
+ /**
1031
+ * Context passed to transform callback when mutate IS provided.
1032
+ */
1033
+ export interface BuilderTransformContextWithMutate<
1034
+ TRetrieveSuccessResult,
1035
+ TMutateResult,
1036
+ TServiceFinalResult extends readonly unknown[],
1037
+ TServiceIntermediateResult extends readonly unknown[],
1038
+ > {
1039
+ /** Result from transformRetrieve callback (or raw retrieve results if no transformRetrieve) */
1040
+ retrieveResult: TRetrieveSuccessResult;
1041
+ /** Result from mutate callback */
1042
+ mutateResult: TMutateResult;
1043
+ /** Array of final results from service calls (after success/transform callbacks) */
1044
+ serviceResult: TServiceFinalResult;
1045
+ /** Array of retrieve success results from service calls (same as what mutate receives: retrieve results if service has retrieve, mutate result if service only mutates) */
1046
+ serviceIntermediateResult: TServiceIntermediateResult;
1047
+ }
1048
+
1049
+ /**
1050
+ * Context passed to transform callback when mutate is NOT provided.
1051
+ */
1052
+ export interface BuilderTransformContextWithoutMutate<
1053
+ TRetrieveSuccessResult,
1054
+ TServiceFinalResult extends readonly unknown[],
1055
+ TServiceIntermediateResult extends readonly unknown[],
1056
+ > {
1057
+ /** Result from transformRetrieve callback (or raw retrieve results if no transformRetrieve) */
1058
+ retrieveResult: TRetrieveSuccessResult;
1059
+ /** No mutate callback was provided */
1060
+ mutateResult: undefined;
1061
+ /** Array of final results from service calls (after success/transform callbacks) */
1062
+ serviceResult: TServiceFinalResult;
1063
+ /** Array of retrieve success results from service calls (same as what mutate receives: retrieve results if service has retrieve, mutate result if service only mutates) */
1064
+ serviceIntermediateResult: TServiceIntermediateResult;
1065
+ }
1066
+
1067
+ /**
1068
+ * Infer the final result type from builder state:
1069
+ * 1. transform → TTransformResult
1070
+ * 2. mutate → AwaitedPromisesInObject<TMutateResult>
1071
+ * 3. transformRetrieve → TRetrieveSuccessResult
1072
+ * 4. retrieve → TRetrieveResults
1073
+ * 5. withServiceCalls → ExtractServiceFinalResults<TServiceCalls>
1074
+ */
1075
+ export type InferBuilderResultType<
1076
+ TRetrieveResults extends unknown[],
1077
+ TRetrieveSuccessResult,
1078
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1079
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
1080
+ TMutateResult,
1081
+ TTransformResult,
1082
+ HasTransform extends boolean,
1083
+ HasMutate extends boolean,
1084
+ HasTransformRetrieve extends boolean,
1085
+ HasRetrieve extends boolean,
1086
+ > = HasTransform extends true
1087
+ ? TTransformResult
1088
+ : HasMutate extends true
1089
+ ? AwaitedPromisesInObject<TMutateResult>
1090
+ : HasTransformRetrieve extends true
1091
+ ? TRetrieveSuccessResult
1092
+ : HasRetrieve extends true
1093
+ ? TRetrieveResults
1094
+ : ExtractServiceFinalResults<TServiceCalls>;
1095
+
1096
+ /**
1097
+ * Infer the retrieve success result type for the builder:
1098
+ * - If transformRetrieve exists: TRetrieveSuccessResult
1099
+ * - Else if retrieve exists: TRetrieveResults (raw retrieve results)
1100
+ * - Else if mutate exists: AwaitedPromisesInObject<TMutateResult>
1101
+ * (mutate result becomes retrieve result for dependents)
1102
+ * - Else: TRetrieveResults (raw retrieve results, typically [])
1103
+ */
1104
+ export type InferBuilderRetrieveSuccessResult<
1105
+ TRetrieveResults extends unknown[],
1106
+ TRetrieveSuccessResult,
1107
+ TMutateResult,
1108
+ HasTransformRetrieve extends boolean,
1109
+ HasRetrieve extends boolean,
1110
+ HasMutate extends boolean,
1111
+ > = HasTransformRetrieve extends true
1112
+ ? TRetrieveSuccessResult
1113
+ : HasRetrieve extends true
1114
+ ? TRetrieveResults
1115
+ : HasMutate extends true
1116
+ ? AwaitedPromisesInObject<TMutateResult>
1117
+ : TRetrieveResults;
1118
+
1119
+ /**
1120
+ * Internal state for ServiceTxBuilder
1121
+ */
1122
+ interface ServiceTxBuilderState<
1123
+ TSchema extends AnySchema,
1124
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1125
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
1126
+ TRetrieveResults extends unknown[],
1127
+ TRetrieveSuccessResult,
1128
+ TMutateResult,
1129
+ TTransformResult,
1130
+ THooks extends HooksMap,
1131
+ > {
1132
+ schema: TSchema;
1133
+ baseUow: IUnitOfWork;
1134
+ hooks?: THooks;
1135
+ withServiceCallsFn?: () => TServiceCalls;
1136
+ retrieveFn?: (
1137
+ uow: TypedUnitOfWork<TSchema, [], unknown, THooks>,
1138
+ ) => TypedUnitOfWork<TSchema, TRetrieveResults, unknown, THooks>;
1139
+ transformRetrieveFn?: (
1140
+ retrieveResult: TRetrieveResults,
1141
+ serviceRetrieveResult: ExtractServiceRetrieveResults<TServiceCalls>,
1142
+ ) => TRetrieveSuccessResult;
1143
+ mutateFn?: (
1144
+ ctx: ServiceBuilderMutateContext<
1145
+ TSchema,
1146
+ TRetrieveSuccessResult,
1147
+ ExtractServiceRetrieveResults<TServiceCalls>,
1148
+ THooks
1149
+ >,
1150
+ ) => TMutateResult;
1151
+ transformFn?: (
1152
+ ctx:
1153
+ | BuilderTransformContextWithMutate<
1154
+ TRetrieveSuccessResult,
1155
+ TMutateResult,
1156
+ ExtractServiceFinalResults<TServiceCalls>,
1157
+ ExtractServiceRetrieveResults<TServiceCalls>
1158
+ >
1159
+ | BuilderTransformContextWithoutMutate<
1160
+ TRetrieveSuccessResult,
1161
+ ExtractServiceFinalResults<TServiceCalls>,
1162
+ ExtractServiceRetrieveResults<TServiceCalls>
1163
+ >,
1164
+ ) => TTransformResult;
1165
+ }
1166
+
1167
+ /**
1168
+ * Builder for service-level transactions.
1169
+ * Uses a fluent API to build up transaction callbacks with proper type inference.
1170
+ *
1171
+ * @example
1172
+ * ```ts
1173
+ * return serviceTx(schema)
1174
+ * .withServiceCalls(() => [otherService.getData()])
1175
+ * .retrieve((uow) => uow.find("users", ...))
1176
+ * .transformRetrieve(([users], serviceResult) => users[0])
1177
+ * .mutate(({ uow, retrieveResult, serviceIntermediateResult }) =>
1178
+ * uow.create("records", { ... })
1179
+ * )
1180
+ * .transform(({ mutateResult, serviceResult, serviceIntermediateResult }) => ({ id: mutateResult }))
1181
+ * .build();
1182
+ * ```
1183
+ */
1184
+ export class ServiceTxBuilder<
1185
+ TSchema extends AnySchema,
1186
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1187
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
1188
+ TRetrieveResults extends unknown[],
1189
+ TRetrieveSuccessResult,
1190
+ TMutateResult,
1191
+ TTransformResult,
1192
+ HasRetrieve extends boolean,
1193
+ HasTransformRetrieve extends boolean,
1194
+ HasMutate extends boolean,
1195
+ HasTransform extends boolean,
1196
+ THooks extends HooksMap,
1197
+ > {
1198
+ readonly #state: ServiceTxBuilderState<
1199
+ TSchema,
1200
+ TServiceCalls,
1201
+ TRetrieveResults,
1202
+ TRetrieveSuccessResult,
1203
+ TMutateResult,
1204
+ TTransformResult,
1205
+ THooks
1206
+ >;
1207
+
1208
+ constructor(
1209
+ state: ServiceTxBuilderState<
1210
+ TSchema,
1211
+ TServiceCalls,
1212
+ TRetrieveResults,
1213
+ TRetrieveSuccessResult,
1214
+ TMutateResult,
1215
+ TTransformResult,
1216
+ THooks
1217
+ >,
1218
+ ) {
1219
+ this.#state = state;
1220
+ }
1221
+
1222
+ /**
1223
+ * Add dependencies to execute before this transaction.
1224
+ */
1225
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1226
+ withServiceCalls<TNewDeps extends readonly (TxResult<any, any> | undefined)[]>(
1227
+ fn: () => TNewDeps,
1228
+ ): ServiceTxBuilder<
1229
+ TSchema,
1230
+ TNewDeps,
1231
+ TRetrieveResults,
1232
+ TRetrieveSuccessResult,
1233
+ TMutateResult,
1234
+ TTransformResult,
1235
+ HasRetrieve,
1236
+ HasTransformRetrieve,
1237
+ HasMutate,
1238
+ HasTransform,
1239
+ THooks
1240
+ > {
1241
+ return new ServiceTxBuilder({
1242
+ ...this.#state,
1243
+ withServiceCallsFn: fn,
1244
+ } as ServiceTxBuilderState<
1245
+ TSchema,
1246
+ TNewDeps,
1247
+ TRetrieveResults,
1248
+ TRetrieveSuccessResult,
1249
+ TMutateResult,
1250
+ TTransformResult,
1251
+ THooks
1252
+ >);
1253
+ }
1254
+
1255
+ /**
1256
+ * Add retrieval operations to the transaction.
1257
+ */
1258
+ retrieve<TNewRetrieveResults extends unknown[]>(
1259
+ fn: (
1260
+ uow: TypedUnitOfWork<TSchema, [], unknown, THooks>,
1261
+ ) => TypedUnitOfWork<TSchema, TNewRetrieveResults, unknown, THooks>,
1262
+ ): ServiceTxBuilder<
1263
+ TSchema,
1264
+ TServiceCalls,
1265
+ TNewRetrieveResults,
1266
+ TNewRetrieveResults, // Default TRetrieveSuccessResult to TNewRetrieveResults
1267
+ TMutateResult,
1268
+ TTransformResult,
1269
+ true, // HasRetrieve = true
1270
+ false, // Reset HasTransformRetrieve since retrieve results changed
1271
+ HasMutate,
1272
+ HasTransform,
1273
+ THooks
1274
+ > {
1275
+ return new ServiceTxBuilder({
1276
+ ...this.#state,
1277
+ retrieveFn: fn,
1278
+ transformRetrieveFn: undefined, // Clear any existing transformRetrieve since results shape changed
1279
+ } as unknown as ServiceTxBuilderState<
1280
+ TSchema,
1281
+ TServiceCalls,
1282
+ TNewRetrieveResults,
1283
+ TNewRetrieveResults,
1284
+ TMutateResult,
1285
+ TTransformResult,
1286
+ THooks
1287
+ >);
1288
+ }
1289
+
1290
+ /**
1291
+ * Transform retrieve results before passing to mutate.
1292
+ */
1293
+ transformRetrieve<TNewRetrieveSuccessResult>(
1294
+ fn: (
1295
+ retrieveResult: TRetrieveResults,
1296
+ serviceResult: ExtractServiceRetrieveResults<TServiceCalls>,
1297
+ ) => TNewRetrieveSuccessResult,
1298
+ ): ServiceTxBuilder<
1299
+ TSchema,
1300
+ TServiceCalls,
1301
+ TRetrieveResults,
1302
+ TNewRetrieveSuccessResult,
1303
+ TMutateResult,
1304
+ TTransformResult,
1305
+ HasRetrieve,
1306
+ true, // HasTransformRetrieve = true
1307
+ HasMutate,
1308
+ HasTransform,
1309
+ THooks
1310
+ > {
1311
+ return new ServiceTxBuilder({
1312
+ ...this.#state,
1313
+ transformRetrieveFn: fn,
1314
+ } as unknown as ServiceTxBuilderState<
1315
+ TSchema,
1316
+ TServiceCalls,
1317
+ TRetrieveResults,
1318
+ TNewRetrieveSuccessResult,
1319
+ TMutateResult,
1320
+ TTransformResult,
1321
+ THooks
1322
+ >);
1323
+ }
1324
+
1325
+ /**
1326
+ * Add mutation operations based on retrieve results.
1327
+ */
1328
+ mutate<TNewMutateResult>(
1329
+ fn: (
1330
+ ctx: ServiceBuilderMutateContext<
1331
+ TSchema,
1332
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1333
+ ExtractServiceRetrieveResults<TServiceCalls>,
1334
+ THooks
1335
+ >,
1336
+ ) => TNewMutateResult,
1337
+ ): ServiceTxBuilder<
1338
+ TSchema,
1339
+ TServiceCalls,
1340
+ TRetrieveResults,
1341
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1342
+ TNewMutateResult,
1343
+ TTransformResult,
1344
+ HasRetrieve,
1345
+ HasTransformRetrieve,
1346
+ true, // HasMutate = true
1347
+ HasTransform,
1348
+ THooks
1349
+ > {
1350
+ return new ServiceTxBuilder({
1351
+ ...this.#state,
1352
+ mutateFn: fn,
1353
+ } as unknown as ServiceTxBuilderState<
1354
+ TSchema,
1355
+ TServiceCalls,
1356
+ TRetrieveResults,
1357
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1358
+ TNewMutateResult,
1359
+ TTransformResult,
1360
+ THooks
1361
+ >);
1362
+ }
1363
+
1364
+ /**
1365
+ * Add final transformation after mutations complete.
1366
+ */
1367
+ transform<TNewTransformResult>(
1368
+ fn: (
1369
+ ctx: HasMutate extends true
1370
+ ? BuilderTransformContextWithMutate<
1371
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1372
+ TMutateResult,
1373
+ ExtractServiceFinalResults<TServiceCalls>,
1374
+ ExtractServiceRetrieveResults<TServiceCalls>
1375
+ >
1376
+ : BuilderTransformContextWithoutMutate<
1377
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1378
+ ExtractServiceFinalResults<TServiceCalls>,
1379
+ ExtractServiceRetrieveResults<TServiceCalls>
1380
+ >,
1381
+ ) => TNewTransformResult,
1382
+ ): ServiceTxBuilder<
1383
+ TSchema,
1384
+ TServiceCalls,
1385
+ TRetrieveResults,
1386
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1387
+ TMutateResult,
1388
+ TNewTransformResult,
1389
+ HasRetrieve,
1390
+ HasTransformRetrieve,
1391
+ HasMutate,
1392
+ true, // HasTransform = true
1393
+ THooks
1394
+ > {
1395
+ return new ServiceTxBuilder({
1396
+ ...this.#state,
1397
+ transformFn: fn,
1398
+ } as unknown as ServiceTxBuilderState<
1399
+ TSchema,
1400
+ TServiceCalls,
1401
+ TRetrieveResults,
1402
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1403
+ TMutateResult,
1404
+ TNewTransformResult,
1405
+ THooks
1406
+ >);
1407
+ }
1408
+
1409
+ /**
1410
+ * Build and return the TxResult.
1411
+ */
1412
+ build(): TxResult<
1413
+ InferBuilderResultType<
1414
+ TRetrieveResults,
1415
+ TRetrieveSuccessResult,
1416
+ TServiceCalls,
1417
+ TMutateResult,
1418
+ TTransformResult,
1419
+ HasTransform,
1420
+ HasMutate,
1421
+ HasTransformRetrieve,
1422
+ HasRetrieve
1423
+ >,
1424
+ InferBuilderRetrieveSuccessResult<
1425
+ TRetrieveResults,
1426
+ TRetrieveSuccessResult,
1427
+ TMutateResult,
1428
+ HasTransformRetrieve,
1429
+ HasRetrieve,
1430
+ HasMutate
1431
+ >
1432
+ > {
1433
+ const state = this.#state;
1434
+
1435
+ // Convert builder state to legacy callbacks format
1436
+ const callbacks: ServiceTxCallbacks<
1437
+ TSchema,
1438
+ TRetrieveResults,
1439
+ TRetrieveSuccessResult,
1440
+ TServiceCalls,
1441
+ TMutateResult,
1442
+ TTransformResult,
1443
+ THooks
1444
+ > = {
1445
+ serviceCalls: state.withServiceCallsFn,
1446
+ retrieve: state.retrieveFn,
1447
+ retrieveSuccess: state.transformRetrieveFn,
1448
+ mutate: state.mutateFn
1449
+ ? (ctx) => {
1450
+ return state.mutateFn!({
1451
+ uow: ctx.uow,
1452
+ retrieveResult: ctx.retrieveResult,
1453
+ serviceIntermediateResult: ctx.serviceIntermediateResult,
1454
+ });
1455
+ }
1456
+ : undefined,
1457
+ success: state.transformFn
1458
+ ? (ctx) => {
1459
+ return state.transformFn!({
1460
+ retrieveResult: ctx.retrieveResult,
1461
+ mutateResult: ctx.mutateResult,
1462
+ serviceResult: ctx.serviceResult,
1463
+ serviceIntermediateResult: ctx.serviceIntermediateResult,
1464
+ } as BuilderTransformContextWithMutate<
1465
+ TRetrieveSuccessResult,
1466
+ TMutateResult,
1467
+ ExtractServiceFinalResults<TServiceCalls>,
1468
+ ExtractServiceRetrieveResults<TServiceCalls>
1469
+ >);
1470
+ }
1471
+ : undefined,
1472
+ };
1473
+
1474
+ // Use the existing createServiceTx implementation
1475
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1476
+ return createServiceTx(state.schema, callbacks as any, state.baseUow) as unknown as TxResult<
1477
+ InferBuilderResultType<
1478
+ TRetrieveResults,
1479
+ TRetrieveSuccessResult,
1480
+ TServiceCalls,
1481
+ TMutateResult,
1482
+ TTransformResult,
1483
+ HasTransform,
1484
+ HasMutate,
1485
+ HasTransformRetrieve,
1486
+ HasRetrieve
1487
+ >,
1488
+ InferBuilderRetrieveSuccessResult<
1489
+ TRetrieveResults,
1490
+ TRetrieveSuccessResult,
1491
+ TMutateResult,
1492
+ HasTransformRetrieve,
1493
+ HasRetrieve,
1494
+ HasMutate
1495
+ >
1496
+ >;
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * Create a new ServiceTxBuilder for the given schema.
1502
+ */
1503
+ export function createServiceTxBuilder<TSchema extends AnySchema, THooks extends HooksMap = {}>(
1504
+ schema: TSchema,
1505
+ baseUow: IUnitOfWork,
1506
+ hooks?: THooks,
1507
+ ): ServiceTxBuilder<
1508
+ TSchema,
1509
+ readonly [],
1510
+ [],
1511
+ [],
1512
+ unknown,
1513
+ unknown,
1514
+ false,
1515
+ false,
1516
+ false,
1517
+ false,
1518
+ THooks
1519
+ > {
1520
+ return new ServiceTxBuilder({
1521
+ schema,
1522
+ baseUow,
1523
+ hooks,
1524
+ });
1525
+ }
1526
+
1527
+ /**
1528
+ * Internal state for HandlerTxBuilder
1529
+ */
1530
+ interface HandlerTxBuilderState<
1531
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1532
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
1533
+ TRetrieveResults extends unknown[],
1534
+ TRetrieveSuccessResult,
1535
+ TMutateResult,
1536
+ TTransformResult,
1537
+ THooks extends HooksMap,
1538
+ > {
1539
+ options: ExecuteTxOptions;
1540
+ hooks?: THooks;
1541
+ withServiceCallsFn?: () => TServiceCalls;
1542
+ retrieveFn?: (context: {
1543
+ forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
1544
+ schema: S,
1545
+ hooks?: H,
1546
+ ) => TypedUnitOfWork<S, [], unknown, H>;
1547
+ idempotencyKey: string;
1548
+ currentAttempt: number;
1549
+ }) => TypedUnitOfWork<AnySchema, TRetrieveResults, unknown, HooksMap> | void;
1550
+ transformRetrieveFn?: (
1551
+ retrieveResult: TRetrieveResults,
1552
+ serviceResult: ExtractServiceRetrieveResults<TServiceCalls>,
1553
+ ) => TRetrieveSuccessResult;
1554
+ mutateFn?: (
1555
+ ctx: HandlerBuilderMutateContext<
1556
+ TRetrieveSuccessResult,
1557
+ ExtractServiceRetrieveResults<TServiceCalls>,
1558
+ THooks
1559
+ >,
1560
+ ) => TMutateResult;
1561
+ transformFn?: (
1562
+ ctx:
1563
+ | BuilderTransformContextWithMutate<
1564
+ TRetrieveSuccessResult,
1565
+ TMutateResult,
1566
+ ExtractServiceFinalResults<TServiceCalls>,
1567
+ ExtractServiceRetrieveResults<TServiceCalls>
1568
+ >
1569
+ | BuilderTransformContextWithoutMutate<
1570
+ TRetrieveSuccessResult,
1571
+ ExtractServiceFinalResults<TServiceCalls>,
1572
+ ExtractServiceRetrieveResults<TServiceCalls>
1573
+ >,
1574
+ ) => TTransformResult;
1575
+ }
1576
+
1577
+ /**
1578
+ * Builder for handler-level transactions.
1579
+ * Uses a fluent API to build up transaction callbacks with proper type inference.
1580
+ *
1581
+ * @example
1582
+ * ```ts
1583
+ * const result = await handlerTx()
1584
+ * .withServiceCalls(() => [userService.getUser(id)])
1585
+ * .mutate(({ forSchema, idempotencyKey, currentAttempt, serviceIntermediateResult }) => {
1586
+ * return forSchema(ordersSchema).create("orders", { ... });
1587
+ * })
1588
+ * .transform(({ mutateResult, serviceResult }) => ({ ... }))
1589
+ * .execute();
1590
+ * ```
1591
+ */
1592
+ export class HandlerTxBuilder<
1593
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1594
+ TServiceCalls extends readonly (TxResult<any, any> | undefined)[],
1595
+ TRetrieveResults extends unknown[],
1596
+ TRetrieveSuccessResult,
1597
+ TMutateResult,
1598
+ TTransformResult,
1599
+ HasRetrieve extends boolean,
1600
+ HasTransformRetrieve extends boolean,
1601
+ HasMutate extends boolean,
1602
+ HasTransform extends boolean,
1603
+ THooks extends HooksMap,
1604
+ > {
1605
+ readonly #state: HandlerTxBuilderState<
1606
+ TServiceCalls,
1607
+ TRetrieveResults,
1608
+ TRetrieveSuccessResult,
1609
+ TMutateResult,
1610
+ TTransformResult,
1611
+ THooks
1612
+ >;
1613
+
1614
+ constructor(
1615
+ state: HandlerTxBuilderState<
1616
+ TServiceCalls,
1617
+ TRetrieveResults,
1618
+ TRetrieveSuccessResult,
1619
+ TMutateResult,
1620
+ TTransformResult,
1621
+ THooks
1622
+ >,
1623
+ ) {
1624
+ this.#state = state;
1625
+ }
1626
+
1627
+ /**
1628
+ * Add dependencies to execute before this transaction.
1629
+ */
1630
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1631
+ withServiceCalls<TNewDeps extends readonly (TxResult<any, any> | undefined)[]>(
1632
+ fn: () => TNewDeps,
1633
+ ): HandlerTxBuilder<
1634
+ TNewDeps,
1635
+ TRetrieveResults,
1636
+ TRetrieveSuccessResult,
1637
+ TMutateResult,
1638
+ TTransformResult,
1639
+ HasRetrieve,
1640
+ HasTransformRetrieve,
1641
+ HasMutate,
1642
+ HasTransform,
1643
+ THooks
1644
+ > {
1645
+ return new HandlerTxBuilder({
1646
+ ...this.#state,
1647
+ withServiceCallsFn: fn,
1648
+ } as HandlerTxBuilderState<
1649
+ TNewDeps,
1650
+ TRetrieveResults,
1651
+ TRetrieveSuccessResult,
1652
+ TMutateResult,
1653
+ TTransformResult,
1654
+ THooks
1655
+ >);
1656
+ }
1657
+
1658
+ /**
1659
+ * Add retrieval operations to the transaction.
1660
+ * Return a TypedUnitOfWork from forSchema().find() to get typed results.
1661
+ */
1662
+ retrieve<TNewRetrieveResults extends unknown[]>(
1663
+ fn: (context: {
1664
+ forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
1665
+ schema: S,
1666
+ hooks?: H,
1667
+ ) => TypedUnitOfWork<S, [], unknown, H>;
1668
+ idempotencyKey: string;
1669
+ currentAttempt: number;
1670
+ }) => TypedUnitOfWork<AnySchema, TNewRetrieveResults, unknown, HooksMap> | void,
1671
+ ): HandlerTxBuilder<
1672
+ TServiceCalls,
1673
+ TNewRetrieveResults,
1674
+ TNewRetrieveResults, // Default TRetrieveSuccessResult to TNewRetrieveResults
1675
+ TMutateResult,
1676
+ TTransformResult,
1677
+ true, // HasRetrieve = true
1678
+ false, // Reset HasTransformRetrieve since retrieve results changed
1679
+ HasMutate,
1680
+ HasTransform,
1681
+ THooks
1682
+ > {
1683
+ return new HandlerTxBuilder({
1684
+ ...this.#state,
1685
+ retrieveFn: fn,
1686
+ transformRetrieveFn: undefined, // Clear any existing transformRetrieve since results shape changed
1687
+ } as unknown as HandlerTxBuilderState<
1688
+ TServiceCalls,
1689
+ TNewRetrieveResults,
1690
+ TNewRetrieveResults,
1691
+ TMutateResult,
1692
+ TTransformResult,
1693
+ THooks
1694
+ >);
1695
+ }
1696
+
1697
+ /**
1698
+ * Transform retrieve results before passing to mutate.
1699
+ */
1700
+ transformRetrieve<TNewRetrieveSuccessResult>(
1701
+ fn: (
1702
+ retrieveResult: TRetrieveResults,
1703
+ serviceResult: ExtractServiceRetrieveResults<TServiceCalls>,
1704
+ ) => TNewRetrieveSuccessResult,
1705
+ ): HandlerTxBuilder<
1706
+ TServiceCalls,
1707
+ TRetrieveResults,
1708
+ TNewRetrieveSuccessResult,
1709
+ TMutateResult,
1710
+ TTransformResult,
1711
+ HasRetrieve,
1712
+ true, // HasTransformRetrieve = true
1713
+ HasMutate,
1714
+ HasTransform,
1715
+ THooks
1716
+ > {
1717
+ return new HandlerTxBuilder({
1718
+ ...this.#state,
1719
+ transformRetrieveFn: fn,
1720
+ } as unknown as HandlerTxBuilderState<
1721
+ TServiceCalls,
1722
+ TRetrieveResults,
1723
+ TNewRetrieveSuccessResult,
1724
+ TMutateResult,
1725
+ TTransformResult,
1726
+ THooks
1727
+ >);
1728
+ }
1729
+
1730
+ /**
1731
+ * Add mutation operations based on retrieve results.
1732
+ */
1733
+ mutate<TNewMutateResult>(
1734
+ fn: (
1735
+ ctx: HandlerBuilderMutateContext<
1736
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1737
+ ExtractServiceRetrieveResults<TServiceCalls>,
1738
+ THooks
1739
+ >,
1740
+ ) => TNewMutateResult,
1741
+ ): HandlerTxBuilder<
1742
+ TServiceCalls,
1743
+ TRetrieveResults,
1744
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1745
+ TNewMutateResult,
1746
+ TTransformResult,
1747
+ HasRetrieve,
1748
+ HasTransformRetrieve,
1749
+ true, // HasMutate = true
1750
+ HasTransform,
1751
+ THooks
1752
+ > {
1753
+ return new HandlerTxBuilder({
1754
+ ...this.#state,
1755
+ mutateFn: fn,
1756
+ } as unknown as HandlerTxBuilderState<
1757
+ TServiceCalls,
1758
+ TRetrieveResults,
1759
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1760
+ TNewMutateResult,
1761
+ TTransformResult,
1762
+ THooks
1763
+ >);
1764
+ }
1765
+
1766
+ /**
1767
+ * Add final transformation after mutations complete.
1768
+ */
1769
+ transform<TNewTransformResult>(
1770
+ fn: (
1771
+ ctx: HasMutate extends true
1772
+ ? BuilderTransformContextWithMutate<
1773
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1774
+ TMutateResult,
1775
+ ExtractServiceFinalResults<TServiceCalls>,
1776
+ ExtractServiceRetrieveResults<TServiceCalls>
1777
+ >
1778
+ : BuilderTransformContextWithoutMutate<
1779
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1780
+ ExtractServiceFinalResults<TServiceCalls>,
1781
+ ExtractServiceRetrieveResults<TServiceCalls>
1782
+ >,
1783
+ ) => TNewTransformResult,
1784
+ ): HandlerTxBuilder<
1785
+ TServiceCalls,
1786
+ TRetrieveResults,
1787
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1788
+ TMutateResult,
1789
+ TNewTransformResult,
1790
+ HasRetrieve,
1791
+ HasTransformRetrieve,
1792
+ HasMutate,
1793
+ true, // HasTransform = true
1794
+ THooks
1795
+ > {
1796
+ return new HandlerTxBuilder({
1797
+ ...this.#state,
1798
+ transformFn: fn,
1799
+ } as unknown as HandlerTxBuilderState<
1800
+ TServiceCalls,
1801
+ TRetrieveResults,
1802
+ HasTransformRetrieve extends true ? TRetrieveSuccessResult : TRetrieveResults,
1803
+ TMutateResult,
1804
+ TNewTransformResult,
1805
+ THooks
1806
+ >);
1807
+ }
1808
+
1809
+ /**
1810
+ * Execute the transaction and return the result.
1811
+ */
1812
+ execute(): Promise<
1813
+ AwaitedPromisesInObject<
1814
+ InferBuilderResultType<
1815
+ TRetrieveResults,
1816
+ TRetrieveSuccessResult,
1817
+ TServiceCalls,
1818
+ TMutateResult,
1819
+ TTransformResult,
1820
+ HasTransform,
1821
+ HasMutate,
1822
+ HasTransformRetrieve,
1823
+ HasRetrieve
1824
+ >
1825
+ >
1826
+ > {
1827
+ const state = this.#state;
1828
+
1829
+ // Convert builder state to legacy callbacks format
1830
+ const callbacks: HandlerTxCallbacks<
1831
+ TRetrieveResults,
1832
+ TRetrieveSuccessResult,
1833
+ TServiceCalls,
1834
+ TMutateResult,
1835
+ TTransformResult,
1836
+ THooks
1837
+ > = {
1838
+ serviceCalls: state.withServiceCallsFn,
1839
+ retrieve: state.retrieveFn
1840
+ ? (context) => {
1841
+ return state.retrieveFn!({
1842
+ forSchema: context.forSchema,
1843
+ idempotencyKey: context.idempotencyKey,
1844
+ currentAttempt: context.currentAttempt,
1845
+ });
1846
+ }
1847
+ : undefined,
1848
+ retrieveSuccess: state.transformRetrieveFn,
1849
+ mutate: state.mutateFn
1850
+ ? (ctx) => {
1851
+ return state.mutateFn!({
1852
+ forSchema: ctx.forSchema,
1853
+ idempotencyKey: ctx.idempotencyKey,
1854
+ currentAttempt: ctx.currentAttempt,
1855
+ retrieveResult: ctx.retrieveResult,
1856
+ serviceIntermediateResult: ctx.serviceIntermediateResult,
1857
+ });
1858
+ }
1859
+ : undefined,
1860
+ success: state.transformFn
1861
+ ? (ctx) => {
1862
+ return state.transformFn!({
1863
+ retrieveResult: ctx.retrieveResult,
1864
+ mutateResult: ctx.mutateResult,
1865
+ serviceResult: ctx.serviceResult,
1866
+ serviceIntermediateResult: ctx.serviceIntermediateResult,
1867
+ } as BuilderTransformContextWithMutate<
1868
+ TRetrieveSuccessResult,
1869
+ TMutateResult,
1870
+ ExtractServiceFinalResults<TServiceCalls>,
1871
+ ExtractServiceRetrieveResults<TServiceCalls>
1872
+ >);
1873
+ }
1874
+ : undefined,
1875
+ };
1876
+
1877
+ // Use the existing executeTx implementation
1878
+ return executeTx(callbacks as Parameters<typeof executeTx>[0], state.options) as Promise<
1879
+ AwaitedPromisesInObject<
1880
+ InferBuilderResultType<
1881
+ TRetrieveResults,
1882
+ TRetrieveSuccessResult,
1883
+ TServiceCalls,
1884
+ TMutateResult,
1885
+ TTransformResult,
1886
+ HasTransform,
1887
+ HasMutate,
1888
+ HasTransformRetrieve,
1889
+ HasRetrieve
1890
+ >
1891
+ >
1892
+ >;
1893
+ }
1894
+ }
1895
+
1896
+ /**
1897
+ * Create a new HandlerTxBuilder with the given options.
1898
+ */
1899
+ export function createHandlerTxBuilder<THooks extends HooksMap = {}>(
1900
+ options: ExecuteTxOptions,
1901
+ hooks?: THooks,
1902
+ ): HandlerTxBuilder<readonly [], [], [], unknown, unknown, false, false, false, false, THooks> {
1903
+ return new HandlerTxBuilder({
1904
+ options,
1905
+ hooks,
1906
+ });
1907
+ }