@fragno-dev/db 0.2.0 → 0.2.1
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 +20 -20
- package/CHANGELOG.md +17 -0
- package/dist/db-fragment-definition-builder.d.ts +55 -1
- package/dist/db-fragment-definition-builder.d.ts.map +1 -1
- package/dist/db-fragment-definition-builder.js +49 -5
- package/dist/db-fragment-definition-builder.js.map +1 -1
- package/dist/fragments/internal-fragment.d.ts.map +1 -1
- package/dist/fragments/internal-fragment.js.map +1 -1
- package/dist/mod.d.ts.map +1 -1
- package/dist/query/unit-of-work/execute-unit-of-work.d.ts +37 -1
- 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 +133 -1
- 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 +18 -3
- package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
- package/dist/query/unit-of-work/unit-of-work.js +25 -11
- package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
- package/dist/schema/create.d.ts +0 -3
- package/dist/schema/create.d.ts.map +1 -1
- package/dist/schema/create.js +0 -4
- package/dist/schema/create.js.map +1 -1
- 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/db-fragment-definition-builder.ts +187 -7
- package/src/fragments/internal-fragment.ts +0 -1
- package/src/hooks/hooks.test.ts +28 -16
- package/src/query/unit-of-work/execute-unit-of-work.test.ts +555 -1
- package/src/query/unit-of-work/execute-unit-of-work.ts +312 -4
- package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +249 -1
- package/src/query/unit-of-work/unit-of-work.ts +39 -17
- package/src/schema/create.ts +0 -5
|
@@ -309,13 +309,11 @@ export async function executeUnitOfWork<
|
|
|
309
309
|
return { success: false, reason: "conflict" };
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
// Wait before retrying
|
|
313
312
|
const delayMs = retryPolicy.getDelayMs(attempt);
|
|
314
313
|
if (delayMs > 0) {
|
|
315
314
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
316
315
|
}
|
|
317
316
|
|
|
318
|
-
// Increment attempt counter for next iteration
|
|
319
317
|
attempt++;
|
|
320
318
|
} catch (error) {
|
|
321
319
|
// An error was thrown during execution
|
|
@@ -356,6 +354,57 @@ export interface ExecuteRestrictedUnitOfWorkOptions {
|
|
|
356
354
|
onSuccess?: (uow: IUnitOfWork) => Promise<void>;
|
|
357
355
|
}
|
|
358
356
|
|
|
357
|
+
/**
|
|
358
|
+
* Context provided to handler tx callbacks
|
|
359
|
+
*/
|
|
360
|
+
export interface TxPhaseContext<THooks extends HooksMap> {
|
|
361
|
+
/**
|
|
362
|
+
* Get a typed Unit of Work for the given schema
|
|
363
|
+
*/
|
|
364
|
+
forSchema: <S extends AnySchema, H extends HooksMap = THooks>(
|
|
365
|
+
schema: S,
|
|
366
|
+
hooks?: H,
|
|
367
|
+
) => TypedUnitOfWork<S, [], unknown, H>;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Handler callbacks for tx() - SYNCHRONOUS ONLY (no Promise return allowed)
|
|
372
|
+
* This prevents accidentally awaiting services in the wrong place
|
|
373
|
+
*/
|
|
374
|
+
export interface HandlerTxCallbacks<TRetrieveResult, TMutationResult, THooks extends HooksMap> {
|
|
375
|
+
/**
|
|
376
|
+
* Retrieval phase callback - schedules retrievals and optionally calls services
|
|
377
|
+
* Must be synchronous - cannot await promises
|
|
378
|
+
*/
|
|
379
|
+
retrieve?: (context: TxPhaseContext<THooks>) => TRetrieveResult;
|
|
380
|
+
/**
|
|
381
|
+
* Mutation phase callback - receives retrieve result, schedules mutations
|
|
382
|
+
* Must be synchronous - cannot await promises (but may return a promise to be awaited)
|
|
383
|
+
*/
|
|
384
|
+
mutate?: (context: TxPhaseContext<THooks>, retrieveResult: TRetrieveResult) => TMutationResult;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface ServiceTxCallbacks<
|
|
388
|
+
TSchema extends AnySchema,
|
|
389
|
+
TRetrievalResults extends unknown[],
|
|
390
|
+
TMutationResult,
|
|
391
|
+
THooks extends HooksMap,
|
|
392
|
+
> {
|
|
393
|
+
/**
|
|
394
|
+
* Retrieval phase callback - schedules retrievals, returns typed UOW
|
|
395
|
+
*/
|
|
396
|
+
retrieve?: (
|
|
397
|
+
uow: TypedUnitOfWork<TSchema, [], unknown, THooks>,
|
|
398
|
+
) => TypedUnitOfWork<TSchema, TRetrievalResults, unknown, THooks>;
|
|
399
|
+
/**
|
|
400
|
+
* Mutation phase callback - receives retrieval results, schedules mutations and hooks
|
|
401
|
+
*/
|
|
402
|
+
mutate?: (
|
|
403
|
+
uow: TypedUnitOfWork<TSchema, TRetrievalResults, unknown, THooks>,
|
|
404
|
+
results: TRetrievalResults,
|
|
405
|
+
) => TMutationResult | Promise<TMutationResult>;
|
|
406
|
+
}
|
|
407
|
+
|
|
359
408
|
/**
|
|
360
409
|
* Execute a Unit of Work with explicit phase control and automatic retry support.
|
|
361
410
|
*
|
|
@@ -491,14 +540,273 @@ export async function executeRestrictedUnitOfWork<TResult, THooks extends HooksM
|
|
|
491
540
|
});
|
|
492
541
|
}
|
|
493
542
|
|
|
494
|
-
// Wait before retrying
|
|
495
543
|
const delayMs = retryPolicy.getDelayMs(attempt);
|
|
496
544
|
if (delayMs > 0) {
|
|
497
545
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
498
546
|
}
|
|
499
547
|
|
|
500
|
-
// Increment attempt counter for next iteration
|
|
501
548
|
attempt++;
|
|
502
549
|
}
|
|
503
550
|
}
|
|
504
551
|
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Execute a transaction with array syntax (handler context).
|
|
555
|
+
* Takes a factory function that creates an array of service promises, enabling proper retry support.
|
|
556
|
+
*
|
|
557
|
+
* @param servicesFactory - Function that creates an array of service promises
|
|
558
|
+
* @param options - Configuration including UOW factory, retry policy, and abort signal
|
|
559
|
+
* @returns Promise resolving to array of awaited service results
|
|
560
|
+
*
|
|
561
|
+
* @example
|
|
562
|
+
* ```ts
|
|
563
|
+
* const [result1, result2] = await executeTxArray(
|
|
564
|
+
* () => [
|
|
565
|
+
* executeServiceTx(schema, callbacks1, uow),
|
|
566
|
+
* executeServiceTx(schema, callbacks2, uow)
|
|
567
|
+
* ],
|
|
568
|
+
* { createUnitOfWork }
|
|
569
|
+
* );
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
572
|
+
export async function executeTxArray<T extends readonly unknown[]>(
|
|
573
|
+
servicesFactory: () => readonly [...{ [K in keyof T]: Promise<T[K]> }],
|
|
574
|
+
options: ExecuteRestrictedUnitOfWorkOptions,
|
|
575
|
+
): Promise<{ [K in keyof T]: T[K] }> {
|
|
576
|
+
const retryPolicy =
|
|
577
|
+
options.retryPolicy ??
|
|
578
|
+
new ExponentialBackoffRetryPolicy({
|
|
579
|
+
maxRetries: 5,
|
|
580
|
+
initialDelayMs: 10,
|
|
581
|
+
maxDelayMs: 100,
|
|
582
|
+
});
|
|
583
|
+
const signal = options.signal;
|
|
584
|
+
let attempt = 0;
|
|
585
|
+
|
|
586
|
+
while (true) {
|
|
587
|
+
// Check if aborted before starting attempt
|
|
588
|
+
if (signal?.aborted) {
|
|
589
|
+
throw new Error("Unit of Work execution aborted");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
// Create a fresh UOW for this attempt
|
|
594
|
+
const baseUow = options.createUnitOfWork();
|
|
595
|
+
|
|
596
|
+
// Call factory to create fresh service promises for this attempt
|
|
597
|
+
const services = servicesFactory();
|
|
598
|
+
|
|
599
|
+
await baseUow.executeRetrieve();
|
|
600
|
+
|
|
601
|
+
if (options.onBeforeMutate) {
|
|
602
|
+
options.onBeforeMutate(baseUow);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const result = await baseUow.executeMutations();
|
|
606
|
+
if (!result.success) {
|
|
607
|
+
throw new ConcurrencyConflictError();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (options.onSuccess) {
|
|
611
|
+
await options.onSuccess(baseUow);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Now await all service promises - they should all resolve now that mutations executed
|
|
615
|
+
const results = await Promise.all(services);
|
|
616
|
+
return results as { [K in keyof T]: T[K] };
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (signal?.aborted) {
|
|
619
|
+
throw new Error("Unit of Work execution aborted");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Only retry concurrency conflicts, not other errors
|
|
623
|
+
if (!(error instanceof ConcurrencyConflictError)) {
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (!retryPolicy.shouldRetry(attempt, error, signal)) {
|
|
628
|
+
if (signal?.aborted) {
|
|
629
|
+
throw new Error("Unit of Work execution aborted");
|
|
630
|
+
}
|
|
631
|
+
throw new Error("Unit of Work execution failed: optimistic concurrency conflict", {
|
|
632
|
+
cause: error,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const delayMs = retryPolicy.getDelayMs(attempt);
|
|
637
|
+
if (delayMs > 0) {
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
attempt++;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Execute a transaction with callback syntax (handler context).
|
|
648
|
+
* Callbacks are synchronous only to prevent accidentally awaiting services in wrong place.
|
|
649
|
+
*
|
|
650
|
+
* @param callbacks - Object containing retrieve and mutate callbacks
|
|
651
|
+
* @param options - Configuration including UOW factory, retry policy, and abort signal
|
|
652
|
+
* @returns Promise resolving to the mutation result with promises awaited 1 level deep
|
|
653
|
+
*/
|
|
654
|
+
export async function executeTxCallbacks<
|
|
655
|
+
TRetrieveResult,
|
|
656
|
+
TMutationResult,
|
|
657
|
+
THooks extends HooksMap = {},
|
|
658
|
+
>(
|
|
659
|
+
callbacks: HandlerTxCallbacks<TRetrieveResult, TMutationResult, THooks>,
|
|
660
|
+
options: ExecuteRestrictedUnitOfWorkOptions,
|
|
661
|
+
): Promise<AwaitedPromisesInObject<TMutationResult>> {
|
|
662
|
+
const retryPolicy =
|
|
663
|
+
options.retryPolicy ??
|
|
664
|
+
new ExponentialBackoffRetryPolicy({
|
|
665
|
+
maxRetries: 5,
|
|
666
|
+
initialDelayMs: 10,
|
|
667
|
+
maxDelayMs: 100,
|
|
668
|
+
});
|
|
669
|
+
const signal = options.signal;
|
|
670
|
+
let attempt = 0;
|
|
671
|
+
|
|
672
|
+
while (true) {
|
|
673
|
+
// Check if aborted before starting attempt
|
|
674
|
+
if (signal?.aborted) {
|
|
675
|
+
throw new Error("Unit of Work execution aborted");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
// Create a fresh UOW for this attempt
|
|
680
|
+
const baseUow = options.createUnitOfWork();
|
|
681
|
+
|
|
682
|
+
const context: TxPhaseContext<THooks> = {
|
|
683
|
+
forSchema: <S extends AnySchema, H extends HooksMap = THooks>(schema: S, hooks?: H) => {
|
|
684
|
+
return baseUow.forSchema(schema, hooks);
|
|
685
|
+
},
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
let retrieveResult: TRetrieveResult;
|
|
689
|
+
if (callbacks.retrieve) {
|
|
690
|
+
retrieveResult = callbacks.retrieve(context);
|
|
691
|
+
} else {
|
|
692
|
+
retrieveResult = undefined as TRetrieveResult;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await baseUow.executeRetrieve();
|
|
696
|
+
|
|
697
|
+
let mutationResult: TMutationResult;
|
|
698
|
+
if (callbacks.mutate) {
|
|
699
|
+
mutationResult = callbacks.mutate(context, retrieveResult);
|
|
700
|
+
} else {
|
|
701
|
+
mutationResult = retrieveResult as unknown as TMutationResult;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const awaitedMutationResult = await awaitPromisesInObject(mutationResult);
|
|
705
|
+
|
|
706
|
+
if (options.onBeforeMutate) {
|
|
707
|
+
options.onBeforeMutate(baseUow);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const result = await baseUow.executeMutations();
|
|
711
|
+
if (!result.success) {
|
|
712
|
+
throw new ConcurrencyConflictError();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (options.onSuccess) {
|
|
716
|
+
await options.onSuccess(baseUow);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return awaitedMutationResult;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (signal?.aborted) {
|
|
722
|
+
throw new Error("Unit of Work execution aborted");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Only retry concurrency conflicts, not other errors
|
|
726
|
+
if (!(error instanceof ConcurrencyConflictError)) {
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!retryPolicy.shouldRetry(attempt, error, signal)) {
|
|
731
|
+
if (signal?.aborted) {
|
|
732
|
+
throw new Error("Unit of Work execution aborted");
|
|
733
|
+
}
|
|
734
|
+
throw new Error("Unit of Work execution failed: optimistic concurrency conflict", {
|
|
735
|
+
cause: error,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const delayMs = retryPolicy.getDelayMs(attempt);
|
|
740
|
+
if (delayMs > 0) {
|
|
741
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
attempt++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Execute a transaction for service context.
|
|
751
|
+
* Service callbacks can be async for ergonomic async work.
|
|
752
|
+
*
|
|
753
|
+
* @param schema - Schema to use for the transaction
|
|
754
|
+
* @param callbacks - Object containing retrieve and mutate callbacks
|
|
755
|
+
* @param baseUow - Base Unit of Work (restricted) to use
|
|
756
|
+
* @returns Promise resolving to the mutation result with promises awaited 1 level deep
|
|
757
|
+
*/
|
|
758
|
+
export async function executeServiceTx<
|
|
759
|
+
TSchema extends AnySchema,
|
|
760
|
+
TRetrievalResults extends unknown[],
|
|
761
|
+
TMutationResult,
|
|
762
|
+
THooks extends HooksMap,
|
|
763
|
+
>(
|
|
764
|
+
schema: TSchema,
|
|
765
|
+
callbacks: ServiceTxCallbacks<TSchema, TRetrievalResults, TMutationResult, THooks>,
|
|
766
|
+
baseUow: IUnitOfWork,
|
|
767
|
+
): Promise<AwaitedPromisesInObject<TMutationResult>> {
|
|
768
|
+
const typedUow = baseUow.restrict({ readyFor: "none" }).forSchema<TSchema, THooks>(schema);
|
|
769
|
+
|
|
770
|
+
let retrievalUow: TypedUnitOfWork<TSchema, TRetrievalResults, unknown, THooks>;
|
|
771
|
+
try {
|
|
772
|
+
if (callbacks.retrieve) {
|
|
773
|
+
retrievalUow = callbacks.retrieve(typedUow);
|
|
774
|
+
} else {
|
|
775
|
+
// Safe cast: when there's no retrieve callback, TRetrievalResults should be []
|
|
776
|
+
retrievalUow = typedUow as unknown as TypedUnitOfWork<
|
|
777
|
+
TSchema,
|
|
778
|
+
TRetrievalResults,
|
|
779
|
+
unknown,
|
|
780
|
+
THooks
|
|
781
|
+
>;
|
|
782
|
+
}
|
|
783
|
+
} catch (error) {
|
|
784
|
+
typedUow.signalReadyForRetrieval();
|
|
785
|
+
typedUow.signalReadyForMutation();
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
typedUow.signalReadyForRetrieval();
|
|
790
|
+
|
|
791
|
+
// Safe cast: retrievalPhase returns the correct type based on the UOW's type parameters
|
|
792
|
+
const results = (await retrievalUow.retrievalPhase) as TRetrievalResults;
|
|
793
|
+
|
|
794
|
+
let mutationResult: TMutationResult;
|
|
795
|
+
try {
|
|
796
|
+
if (callbacks.mutate) {
|
|
797
|
+
mutationResult = await callbacks.mutate(retrievalUow, results);
|
|
798
|
+
} else {
|
|
799
|
+
// Safe cast: when there's no mutate callback, TMutationResult should be void
|
|
800
|
+
mutationResult = undefined as TMutationResult;
|
|
801
|
+
}
|
|
802
|
+
} catch (error) {
|
|
803
|
+
typedUow.signalReadyForMutation();
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
typedUow.signalReadyForMutation();
|
|
808
|
+
|
|
809
|
+
await retrievalUow.mutationPhase;
|
|
810
|
+
|
|
811
|
+
return await awaitPromisesInObject(mutationResult);
|
|
812
|
+
}
|
|
@@ -769,7 +769,7 @@ describe("UOW Coordinator - Parent-Child Execution", () => {
|
|
|
769
769
|
expect(child1.nonce).not.toBe(child2.nonce);
|
|
770
770
|
});
|
|
771
771
|
|
|
772
|
-
it
|
|
772
|
+
it("should not cause unhandled rejection when service method awaits retrievalPhase and executeRetrieve fails", async () => {
|
|
773
773
|
const testSchema = schema((s) =>
|
|
774
774
|
s.addTable("settings", (t) =>
|
|
775
775
|
t
|
|
@@ -828,4 +828,252 @@ describe("UOW Coordinator - Parent-Child Execution", () => {
|
|
|
828
828
|
|
|
829
829
|
expect(await deferred.promise).toContain('relation "settings" does not exist');
|
|
830
830
|
});
|
|
831
|
+
|
|
832
|
+
it("should allow child UOW to call getCreatedIds() after parent executes mutations", async () => {
|
|
833
|
+
const testSchema = schema((s) =>
|
|
834
|
+
s.addTable("products", (t) =>
|
|
835
|
+
t.addColumn("id", idColumn()).addColumn("name", "string").addColumn("price", "integer"),
|
|
836
|
+
),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
const executor = createMockExecutor();
|
|
840
|
+
const parentUow = createUnitOfWork(createCompiler(), executor, createMockDecoder());
|
|
841
|
+
|
|
842
|
+
// Service method that creates a product using a child UOW and returns the child
|
|
843
|
+
const createProduct = (name: string, price: number) => {
|
|
844
|
+
const childUow = parentUow.restrict();
|
|
845
|
+
const productId = childUow.forSchema(testSchema).create("products", { name, price });
|
|
846
|
+
// Return both the child UOW and the product ID reference
|
|
847
|
+
return { childUow, productId };
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
// Call service to create a product via child UOW
|
|
851
|
+
const { childUow, productId } = createProduct("Widget", 1999);
|
|
852
|
+
|
|
853
|
+
// Parent executes mutations
|
|
854
|
+
await parentUow.executeMutations();
|
|
855
|
+
|
|
856
|
+
// Child should be able to call getCreatedIds() after parent has executed
|
|
857
|
+
// This tests that child checks parent's state, not its own stale state
|
|
858
|
+
const createdIds = childUow.getCreatedIds();
|
|
859
|
+
|
|
860
|
+
expect(createdIds).toHaveLength(1);
|
|
861
|
+
expect(createdIds[0].externalId).toBe(productId.externalId);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("should preserve internal IDs in child UOW when using two-phase pattern with mutationPhase await", async () => {
|
|
865
|
+
const testSchema = schema((s) =>
|
|
866
|
+
s.addTable("orders", (t) =>
|
|
867
|
+
t
|
|
868
|
+
.addColumn("id", idColumn())
|
|
869
|
+
.addColumn("customerId", "string")
|
|
870
|
+
.addColumn("total", "integer"),
|
|
871
|
+
),
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
const executor = createMockExecutor();
|
|
875
|
+
const parentUow = createUnitOfWork(createCompiler(), executor, createMockDecoder());
|
|
876
|
+
|
|
877
|
+
// Service method that uses two-phase pattern (common with hooks/async operations)
|
|
878
|
+
// This simulates a service that creates a record and needs to return the internal ID
|
|
879
|
+
const createOrder = async (customerId: string, total: number) => {
|
|
880
|
+
const childUow = parentUow.restrict();
|
|
881
|
+
const typedUow = childUow.forSchema(testSchema);
|
|
882
|
+
|
|
883
|
+
const orderId = typedUow.create("orders", { customerId, total });
|
|
884
|
+
|
|
885
|
+
// Service awaits mutationPhase to coordinate with parent execution
|
|
886
|
+
await childUow.mutationPhase;
|
|
887
|
+
|
|
888
|
+
// After mutationPhase resolves, service should be able to get internal IDs
|
|
889
|
+
const createdIds = childUow.getCreatedIds();
|
|
890
|
+
const foundId = createdIds.find((id) => id.externalId === orderId.externalId);
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
externalId: orderId.externalId,
|
|
894
|
+
internalId: foundId?.internalId,
|
|
895
|
+
};
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Handler orchestrates the service call and mutation execution
|
|
899
|
+
const handler = async () => {
|
|
900
|
+
const orderPromise = createOrder("customer-123", 9999);
|
|
901
|
+
|
|
902
|
+
// Execute mutations - this should resolve the service's mutationPhase await
|
|
903
|
+
await parentUow.executeMutations();
|
|
904
|
+
|
|
905
|
+
// Now the service can complete and return the result with internal ID
|
|
906
|
+
return await orderPromise;
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const result = await handler();
|
|
910
|
+
|
|
911
|
+
// The key assertion: internal ID should be defined (not undefined)
|
|
912
|
+
// This tests that child UOW preserves shared reference to parent's createdInternalIds array
|
|
913
|
+
expect(result.internalId).toBeDefined();
|
|
914
|
+
expect(typeof result.internalId).toBe("bigint");
|
|
915
|
+
expect(result.internalId).toBeGreaterThan(0n);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("should fail when handler executes mutations before service finishes scheduling them (anti-pattern)", async () => {
|
|
919
|
+
const testSchema = schema((s) =>
|
|
920
|
+
s.addTable("totp_secret", (t) =>
|
|
921
|
+
t
|
|
922
|
+
.addColumn("id", idColumn())
|
|
923
|
+
.addColumn("userId", "string")
|
|
924
|
+
.addColumn("secret", "string")
|
|
925
|
+
.addColumn("backupCodes", "string")
|
|
926
|
+
.createIndex("idx_totp_user", ["userId"]),
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// Create executor that returns empty results (no existing record)
|
|
931
|
+
const customExecutor: UOWExecutor<CompiledQuery, unknown> = {
|
|
932
|
+
executeRetrievalPhase: async (queries: CompiledQuery[]) => {
|
|
933
|
+
// Return empty array for each query (no existing records)
|
|
934
|
+
return queries.map(() => []);
|
|
935
|
+
},
|
|
936
|
+
executeMutationPhase: async (mutations: CompiledMutation<CompiledQuery>[]) => {
|
|
937
|
+
return {
|
|
938
|
+
success: true,
|
|
939
|
+
createdInternalIds: mutations.map(() => BigInt(Math.floor(Math.random() * 1000))),
|
|
940
|
+
};
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
const parentUow = createUnitOfWork(createCompiler(), customExecutor, createMockDecoder());
|
|
945
|
+
|
|
946
|
+
// Service that has async work AFTER awaiting retrievalPhase but BEFORE scheduling mutations
|
|
947
|
+
// This is a problematic pattern that can lead to race conditions
|
|
948
|
+
const enableTotp = async (userId: string) => {
|
|
949
|
+
const childUow = parentUow.restrict();
|
|
950
|
+
const typedUow = childUow
|
|
951
|
+
.forSchema(testSchema)
|
|
952
|
+
.findFirst("totp_secret", (b) =>
|
|
953
|
+
b.whereIndex("idx_totp_user", (eb) => eb("userId", "=", userId)),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// Service awaits retrieval phase
|
|
957
|
+
const [existing] = await typedUow.retrievalPhase;
|
|
958
|
+
|
|
959
|
+
if (existing) {
|
|
960
|
+
throw new Error("TOTP already enabled");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Simulate async work (like hashing backup codes) that yields control
|
|
964
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
965
|
+
|
|
966
|
+
// By the time we get here, if the handler has already called executeMutate(),
|
|
967
|
+
// the UOW will be in "executed" state and this will fail
|
|
968
|
+
typedUow.create("totp_secret", {
|
|
969
|
+
userId,
|
|
970
|
+
secret: "TESTSECRET123",
|
|
971
|
+
backupCodes: "[]",
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
await typedUow.mutationPhase;
|
|
975
|
+
|
|
976
|
+
return { success: true };
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
// ANTI-PATTERN: Handler executes both phases immediately without awaiting service
|
|
980
|
+
const badHandler = async () => {
|
|
981
|
+
// Call service - returns promise immediately
|
|
982
|
+
const resultPromise = enableTotp("user-123");
|
|
983
|
+
|
|
984
|
+
// Execute retrieval phase
|
|
985
|
+
await parentUow.executeRetrieve();
|
|
986
|
+
|
|
987
|
+
// Execute mutation phase BEFORE service has finished scheduling mutations
|
|
988
|
+
// This is the bug - service is still running async work and hasn't scheduled mutations yet
|
|
989
|
+
await parentUow.executeMutations();
|
|
990
|
+
|
|
991
|
+
// Now await service promise - but it's too late, UOW is already in "executed" state
|
|
992
|
+
return await resultPromise;
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// This should throw "Cannot add mutation operation in executed state"
|
|
996
|
+
await expect(badHandler()).rejects.toThrow("Cannot add mutation operation in executed state");
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("should succeed when handler awaits service promise between phases (correct pattern)", async () => {
|
|
1000
|
+
const testSchema = schema((s) =>
|
|
1001
|
+
s.addTable("totp_secret", (t) =>
|
|
1002
|
+
t
|
|
1003
|
+
.addColumn("id", idColumn())
|
|
1004
|
+
.addColumn("userId", "string")
|
|
1005
|
+
.addColumn("secret", "string")
|
|
1006
|
+
.addColumn("backupCodes", "string")
|
|
1007
|
+
.createIndex("idx_totp_user", ["userId"]),
|
|
1008
|
+
),
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
// Create executor that returns empty results (no existing record)
|
|
1012
|
+
const customExecutor: UOWExecutor<CompiledQuery, unknown> = {
|
|
1013
|
+
executeRetrievalPhase: async (queries: CompiledQuery[]) => {
|
|
1014
|
+
return queries.map(() => []);
|
|
1015
|
+
},
|
|
1016
|
+
executeMutationPhase: async (mutations: CompiledMutation<CompiledQuery>[]) => {
|
|
1017
|
+
return {
|
|
1018
|
+
success: true,
|
|
1019
|
+
createdInternalIds: mutations.map(() => BigInt(Math.floor(Math.random() * 1000))),
|
|
1020
|
+
};
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const parentUow = createUnitOfWork(createCompiler(), customExecutor, createMockDecoder());
|
|
1025
|
+
|
|
1026
|
+
// Same service as before - has async work between retrieval and mutation scheduling
|
|
1027
|
+
const enableTotp = async (userId: string) => {
|
|
1028
|
+
const childUow = parentUow.restrict();
|
|
1029
|
+
const typedUow = childUow
|
|
1030
|
+
.forSchema(testSchema)
|
|
1031
|
+
.findFirst("totp_secret", (b) =>
|
|
1032
|
+
b.whereIndex("idx_totp_user", (eb) => eb("userId", "=", userId)),
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
const [existing] = await typedUow.retrievalPhase;
|
|
1036
|
+
|
|
1037
|
+
if (existing) {
|
|
1038
|
+
throw new Error("TOTP already enabled");
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Simulate async work (like hashing backup codes)
|
|
1042
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1043
|
+
|
|
1044
|
+
// Schedule mutation - this will work because handler waits for service to finish
|
|
1045
|
+
typedUow.create("totp_secret", {
|
|
1046
|
+
userId,
|
|
1047
|
+
secret: "TESTSECRET123",
|
|
1048
|
+
backupCodes: "[]",
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
return { success: true };
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
// CORRECT PATTERN: Handler awaits service promise between phase executions
|
|
1055
|
+
const goodHandler = async () => {
|
|
1056
|
+
// Call service first - it schedules retrieval operations synchronously
|
|
1057
|
+
const resultPromise = enableTotp("user-123");
|
|
1058
|
+
|
|
1059
|
+
// Execute retrieval phase - service can now continue past its retrieval await
|
|
1060
|
+
await parentUow.executeRetrieve();
|
|
1061
|
+
|
|
1062
|
+
// Wait for service to finish - it will schedule mutations
|
|
1063
|
+
const result = await resultPromise;
|
|
1064
|
+
|
|
1065
|
+
// Execute mutations that service scheduled
|
|
1066
|
+
await parentUow.executeMutations();
|
|
1067
|
+
|
|
1068
|
+
return result;
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// This should succeed without errors
|
|
1072
|
+
const result = await goodHandler();
|
|
1073
|
+
expect(result.success).toBe(true);
|
|
1074
|
+
|
|
1075
|
+
// Verify operations were registered
|
|
1076
|
+
expect(parentUow.getRetrievalOperations()).toHaveLength(1);
|
|
1077
|
+
expect(parentUow.getMutationOperations()).toHaveLength(1);
|
|
1078
|
+
});
|
|
831
1079
|
});
|