@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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +20 -20
  2. package/CHANGELOG.md +17 -0
  3. package/dist/db-fragment-definition-builder.d.ts +55 -1
  4. package/dist/db-fragment-definition-builder.d.ts.map +1 -1
  5. package/dist/db-fragment-definition-builder.js +49 -5
  6. package/dist/db-fragment-definition-builder.js.map +1 -1
  7. package/dist/fragments/internal-fragment.d.ts.map +1 -1
  8. package/dist/fragments/internal-fragment.js.map +1 -1
  9. package/dist/mod.d.ts.map +1 -1
  10. package/dist/query/unit-of-work/execute-unit-of-work.d.ts +37 -1
  11. package/dist/query/unit-of-work/execute-unit-of-work.d.ts.map +1 -1
  12. package/dist/query/unit-of-work/execute-unit-of-work.js +133 -1
  13. package/dist/query/unit-of-work/execute-unit-of-work.js.map +1 -1
  14. package/dist/query/unit-of-work/unit-of-work.d.ts +18 -3
  15. package/dist/query/unit-of-work/unit-of-work.d.ts.map +1 -1
  16. package/dist/query/unit-of-work/unit-of-work.js +25 -11
  17. package/dist/query/unit-of-work/unit-of-work.js.map +1 -1
  18. package/dist/schema/create.d.ts +0 -3
  19. package/dist/schema/create.d.ts.map +1 -1
  20. package/dist/schema/create.js +0 -4
  21. package/dist/schema/create.js.map +1 -1
  22. package/dist/sql-driver/dialects/durable-object-dialect.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/adapters/drizzle/drizzle-adapter-pglite.test.ts +1 -0
  25. package/src/db-fragment-definition-builder.ts +187 -7
  26. package/src/fragments/internal-fragment.ts +0 -1
  27. package/src/hooks/hooks.test.ts +28 -16
  28. package/src/query/unit-of-work/execute-unit-of-work.test.ts +555 -1
  29. package/src/query/unit-of-work/execute-unit-of-work.ts +312 -4
  30. package/src/query/unit-of-work/unit-of-work-coordinator.test.ts +249 -1
  31. package/src/query/unit-of-work/unit-of-work.ts +39 -17
  32. 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.skip("should not cause unhandled rejection when service method awaits retrievalPhase and executeRetrieve fails", async () => {
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
  });