@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.
- package/.turbo/turbo-build.log +34 -30
- package/CHANGELOG.md +32 -0
- package/dist/adapters/generic-sql/query/where-builder.js +1 -1
- package/dist/db-fragment-definition-builder.d.ts +27 -89
- package/dist/db-fragment-definition-builder.d.ts.map +1 -1
- package/dist/db-fragment-definition-builder.js +16 -56
- package/dist/db-fragment-definition-builder.js.map +1 -1
- package/dist/fragments/internal-fragment.d.ts +94 -8
- package/dist/fragments/internal-fragment.d.ts.map +1 -1
- package/dist/fragments/internal-fragment.js +56 -55
- package/dist/fragments/internal-fragment.js.map +1 -1
- package/dist/hooks/hooks.d.ts +5 -3
- package/dist/hooks/hooks.d.ts.map +1 -1
- package/dist/hooks/hooks.js +38 -37
- package/dist/hooks/hooks.js.map +1 -1
- package/dist/mod.d.ts +3 -3
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +4 -4
- package/dist/mod.js.map +1 -1
- package/dist/query/unit-of-work/execute-unit-of-work.d.ts +351 -100
- package/dist/query/unit-of-work/execute-unit-of-work.d.ts.map +1 -1
- package/dist/query/unit-of-work/execute-unit-of-work.js +431 -263
- package/dist/query/unit-of-work/execute-unit-of-work.js.map +1 -1
- package/dist/query/unit-of-work/unit-of-work.d.ts +17 -8
- package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
- package/dist/query/unit-of-work/unit-of-work.js +24 -8
- package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
- package/dist/query/value-decoding.js +1 -1
- package/dist/schema/create.d.ts +3 -1
- package/dist/schema/create.d.ts.map +1 -1
- package/dist/schema/create.js +2 -1
- package/dist/schema/create.js.map +1 -1
- package/dist/schema/generate-id.d.ts +20 -0
- package/dist/schema/generate-id.d.ts.map +1 -0
- package/dist/schema/generate-id.js +28 -0
- package/dist/schema/generate-id.js.map +1 -0
- package/package.json +1 -1
- package/src/adapters/drizzle/drizzle-adapter-sqlite3.test.ts +41 -25
- package/src/adapters/generic-sql/test/generic-drizzle-adapter-sqlite3.test.ts +39 -25
- package/src/db-fragment-definition-builder.test.ts +58 -42
- package/src/db-fragment-definition-builder.ts +58 -248
- package/src/db-fragment-instantiator.test.ts +64 -88
- package/src/db-fragment-integration.test.ts +292 -142
- package/src/fragments/internal-fragment.test.ts +272 -266
- package/src/fragments/internal-fragment.ts +155 -121
- package/src/hooks/hooks.test.ts +248 -256
- package/src/hooks/hooks.ts +74 -63
- package/src/mod.ts +14 -4
- package/src/query/unit-of-work/execute-unit-of-work.test.ts +1494 -1464
- package/src/query/unit-of-work/execute-unit-of-work.ts +1685 -590
- package/src/query/unit-of-work/tx-builder.test.ts +1041 -0
- package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +20 -20
- package/src/query/unit-of-work/unit-of-work.test.ts +64 -0
- package/src/query/unit-of-work/unit-of-work.ts +26 -13
- package/src/schema/create.ts +2 -0
- package/src/schema/generate-id.test.ts +57 -0
- package/src/schema/generate-id.ts +38 -0
- package/src/shared/config.ts +0 -10
- package/src/shared/connection-pool.ts +0 -24
- package/src/shared/prisma.ts +0 -45
|
@@ -1,28 +1,25 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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("
|
|
165
|
+
id: FragnoId.fromExternal("1", 1),
|
|
151
166
|
email: "test@example.com",
|
|
152
|
-
name: "Test
|
|
167
|
+
name: "Test",
|
|
153
168
|
balance: 100,
|
|
154
169
|
},
|
|
155
170
|
],
|
|
156
|
-
]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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(
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
|
471
|
-
const
|
|
472
|
-
const
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
{
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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("
|
|
549
|
-
it("should
|
|
550
|
-
const
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
};
|
|
618
|
-
},
|
|
368
|
+
id: FragnoId.fromExternal("2", 1),
|
|
369
|
+
email: "bob@example.com",
|
|
370
|
+
name: "Bob",
|
|
371
|
+
balance: 200,
|
|
619
372
|
},
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
|
407
|
+
name: "Test",
|
|
770
408
|
balance: 100,
|
|
771
409
|
});
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
923
|
-
|
|
446
|
+
})
|
|
447
|
+
.withServiceCalls(() => [getUserById()])
|
|
448
|
+
.transform(({ serviceResult: [user] }) => user)
|
|
449
|
+
.execute();
|
|
924
450
|
|
|
925
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
946
|
-
const { factory } = createMockUOWFactory([{ success: true }]);
|
|
468
|
+
let currentUow: IUnitOfWork | null = null;
|
|
947
469
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
-
|
|
498
|
+
expect(result.ok).toBe(true);
|
|
499
|
+
if (result.ok) {
|
|
500
|
+
expect(result.newUserId).toBeInstanceOf(FragnoId);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
961
503
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
968
|
-
expect(result.userId.externalId).toBe("user-123");
|
|
969
|
-
});
|
|
970
|
-
});
|
|
518
|
+
let currentUow: IUnitOfWork | null = null;
|
|
971
519
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
548
|
+
originalUser: user,
|
|
549
|
+
mutationResult: mutateResult,
|
|
550
|
+
summary: "Transaction completed",
|
|
983
551
|
};
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
);
|
|
552
|
+
})
|
|
553
|
+
.execute();
|
|
987
554
|
|
|
988
|
-
expect(result).
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
996
|
-
const
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
|
1010
|
-
const
|
|
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
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
},
|
|
1017
|
-
|
|
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
|
-
|
|
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
|
|
1024
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
1039
|
-
|
|
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
|
-
|
|
1043
|
-
const
|
|
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
|
-
|
|
1050
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
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
|
-
|
|
1084
|
-
);
|
|
768
|
+
};
|
|
769
|
+
const decoder = createMockDecoder();
|
|
1085
770
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1093
|
-
const
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1125
|
-
const
|
|
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
|
|
1128
|
-
|
|
1129
|
-
await executeMutate();
|
|
1130
|
-
return {};
|
|
1131
|
-
},
|
|
1132
|
-
{ createUnitOfWork: factory },
|
|
1133
|
-
);
|
|
817
|
+
const controller = new AbortController();
|
|
818
|
+
controller.abort();
|
|
1134
819
|
|
|
1135
|
-
expect(
|
|
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
|
|
1139
|
-
const
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1147
|
-
);
|
|
1148
|
-
|
|
1149
|
-
expect(stringResult).toBe("test-string");
|
|
843
|
+
};
|
|
844
|
+
const decoder = createMockDecoder();
|
|
1150
845
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return 42;
|
|
846
|
+
await createHandlerTxBuilder({
|
|
847
|
+
createUnitOfWork: () => {
|
|
848
|
+
callCount++;
|
|
849
|
+
return createUnitOfWork(compiler, executor, decoder);
|
|
1156
850
|
},
|
|
1157
|
-
{
|
|
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
|
-
|
|
862
|
+
// Verify factory was called for each attempt (initial + 2 retries)
|
|
863
|
+
expect(callCount).toBe(3);
|
|
1161
864
|
});
|
|
1162
|
-
});
|
|
1163
865
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
const
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
|
1184
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
1234
|
-
const { factory } = createMockUOWFactory([{ success: true }]);
|
|
939
|
+
expect(depRetrieveResult).toEqual(mockUser);
|
|
1235
940
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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).
|
|
1247
|
-
expect(result).
|
|
950
|
+
expect(result.depUpdated).toBe(true);
|
|
951
|
+
expect(result.depNewBalance).toBe(200);
|
|
1248
952
|
});
|
|
1249
953
|
});
|
|
1250
954
|
|
|
1251
|
-
describe("
|
|
1252
|
-
it("should
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
|
1272
|
-
createUnitOfWork(
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
1010
|
+
expect(result).toBe("mutate result");
|
|
1011
|
+
});
|
|
1292
1012
|
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
const
|
|
1334
|
-
()
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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(
|
|
1359
|
-
expect(servicesResolved).toBe(true);
|
|
1360
|
-
expect(result).toEqual([{ result1: "value1" }, { result2: "value2" }]);
|
|
1064
|
+
expect(result).toEqual([mockUser]);
|
|
1361
1065
|
});
|
|
1066
|
+
});
|
|
1362
1067
|
|
|
1363
|
-
|
|
1068
|
+
describe("serviceResult vs serviceIntermediateResult", () => {
|
|
1069
|
+
it("serviceIntermediateResult in mutate should contain transformRetrieve results", async () => {
|
|
1364
1070
|
const compiler = createMockCompiler();
|
|
1365
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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(
|
|
1387
|
-
expect(result).toEqual([{ result: "value" }]);
|
|
1110
|
+
expect(capturedServiceIntermediateResult[0]).toEqual({ transformed: true, user: mockUser });
|
|
1388
1111
|
});
|
|
1389
1112
|
|
|
1390
|
-
it("should
|
|
1113
|
+
it("serviceResult in transform should contain final (mutate) results", async () => {
|
|
1391
1114
|
const compiler = createMockCompiler();
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
|
|
1442
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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).
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1534
|
-
const
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
return {
|
|
1542
|
-
}
|
|
1543
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
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("
|
|
1567
|
-
it("should
|
|
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
|
-
|
|
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
|
-
//
|
|
1614
|
-
|
|
1493
|
+
// Define hooks type for this test
|
|
1494
|
+
type TestHooks = {
|
|
1495
|
+
onSubscribe: (payload: { email: string }) => void;
|
|
1496
|
+
};
|
|
1615
1497
|
|
|
1616
|
-
|
|
1617
|
-
|
|
1498
|
+
const hooks: TestHooks = {
|
|
1499
|
+
onSubscribe: (payload: { email: string }) => {
|
|
1500
|
+
console.log(`onSubscribe: ${payload.email}`);
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
1618
1503
|
|
|
1619
|
-
//
|
|
1620
|
-
|
|
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
|
-
//
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
|
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: [
|
|
1554
|
+
createdInternalIds: [],
|
|
1634
1555
|
}),
|
|
1635
1556
|
};
|
|
1636
1557
|
const decoder = createMockDecoder();
|
|
1637
1558
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
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
|
-
|
|
1667
|
-
);
|
|
1570
|
+
};
|
|
1668
1571
|
|
|
1669
|
-
//
|
|
1670
|
-
|
|
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
|
-
|
|
1673
|
-
|
|
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: [
|
|
1624
|
+
createdInternalIds: [],
|
|
1683
1625
|
}),
|
|
1684
1626
|
};
|
|
1685
1627
|
const decoder = createMockDecoder();
|
|
1686
1628
|
|
|
1687
|
-
|
|
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
|
-
|
|
1727
|
-
await Promise.all([handlerSimulation, servicePromise]);
|
|
1631
|
+
const syncError = new Error("Retrieve callback threw synchronously");
|
|
1728
1632
|
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
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
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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
|
});
|