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