@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
|
@@ -722,54 +722,54 @@ describe("UOW Coordinator - Parent-Child Execution", () => {
|
|
|
722
722
|
// If we got here without Node.js throwing an unhandled rejection, the test passes
|
|
723
723
|
});
|
|
724
724
|
|
|
725
|
-
it("should inherit
|
|
725
|
+
it("should inherit idempotencyKey from parent to children for idempotent operations", () => {
|
|
726
726
|
const executor = createMockExecutor();
|
|
727
727
|
const parentUow = createUnitOfWork(createCompiler(), executor, createMockDecoder());
|
|
728
728
|
|
|
729
|
-
// Parent UOW should have
|
|
730
|
-
const
|
|
731
|
-
expect(
|
|
732
|
-
expect(typeof
|
|
733
|
-
expect(
|
|
729
|
+
// Parent UOW should have an idempotencyKey
|
|
730
|
+
const parentIdempotencyKey = parentUow.idempotencyKey;
|
|
731
|
+
expect(parentIdempotencyKey).toBeDefined();
|
|
732
|
+
expect(typeof parentIdempotencyKey).toBe("string");
|
|
733
|
+
expect(parentIdempotencyKey.length).toBeGreaterThan(0);
|
|
734
734
|
|
|
735
735
|
// Create first child
|
|
736
736
|
const child1 = parentUow.restrict();
|
|
737
|
-
expect(child1.
|
|
737
|
+
expect(child1.idempotencyKey).toBe(parentIdempotencyKey);
|
|
738
738
|
|
|
739
739
|
// Create second child (sibling to child1)
|
|
740
740
|
const child2 = parentUow.restrict();
|
|
741
|
-
expect(child2.
|
|
741
|
+
expect(child2.idempotencyKey).toBe(parentIdempotencyKey);
|
|
742
742
|
|
|
743
743
|
// Create nested child (child of child1)
|
|
744
744
|
const grandchild = child1.restrict();
|
|
745
|
-
expect(grandchild.
|
|
745
|
+
expect(grandchild.idempotencyKey).toBe(parentIdempotencyKey);
|
|
746
746
|
|
|
747
|
-
// All UOWs in the hierarchy should share the same
|
|
748
|
-
expect(parentUow.
|
|
749
|
-
expect(child1.
|
|
750
|
-
expect(child2.
|
|
747
|
+
// All UOWs in the hierarchy should share the same idempotencyKey
|
|
748
|
+
expect(parentUow.idempotencyKey).toBe(child1.idempotencyKey);
|
|
749
|
+
expect(child1.idempotencyKey).toBe(child2.idempotencyKey);
|
|
750
|
+
expect(child2.idempotencyKey).toBe(grandchild.idempotencyKey);
|
|
751
751
|
});
|
|
752
752
|
|
|
753
|
-
it("should generate different
|
|
753
|
+
it("should generate different idempotencyKeys for separate UOW hierarchies", () => {
|
|
754
754
|
const executor = createMockExecutor();
|
|
755
755
|
|
|
756
756
|
// Create two separate parent UOWs
|
|
757
757
|
const parentUow1 = createUnitOfWork(createCompiler(), executor, createMockDecoder());
|
|
758
758
|
const parentUow2 = createUnitOfWork(createCompiler(), executor, createMockDecoder());
|
|
759
759
|
|
|
760
|
-
// They should have different
|
|
761
|
-
expect(parentUow1.
|
|
760
|
+
// They should have different idempotencyKeys
|
|
761
|
+
expect(parentUow1.idempotencyKey).not.toBe(parentUow2.idempotencyKey);
|
|
762
762
|
|
|
763
|
-
// But children within each hierarchy should inherit their parent's
|
|
763
|
+
// But children within each hierarchy should inherit their parent's idempotencyKey
|
|
764
764
|
const child1 = parentUow1.restrict();
|
|
765
765
|
const child2 = parentUow2.restrict();
|
|
766
766
|
|
|
767
|
-
expect(child1.
|
|
768
|
-
expect(child2.
|
|
769
|
-
expect(child1.
|
|
767
|
+
expect(child1.idempotencyKey).toBe(parentUow1.idempotencyKey);
|
|
768
|
+
expect(child2.idempotencyKey).toBe(parentUow2.idempotencyKey);
|
|
769
|
+
expect(child1.idempotencyKey).not.toBe(child2.idempotencyKey);
|
|
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
|
});
|
|
@@ -838,6 +838,70 @@ describe("DeleteBuilder with string ID", () => {
|
|
|
838
838
|
});
|
|
839
839
|
});
|
|
840
840
|
|
|
841
|
+
describe("generateId", () => {
|
|
842
|
+
const testSchema = schema((s) =>
|
|
843
|
+
s.addTable("users", (t) =>
|
|
844
|
+
t.addColumn("id", idColumn()).addColumn("email", "string").addColumn("name", "string"),
|
|
845
|
+
),
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
it("should generate a new FragnoId without creating a record", () => {
|
|
849
|
+
const uow = createUnitOfWork(createMockCompiler(), createMockExecutor(), createMockDecoder());
|
|
850
|
+
|
|
851
|
+
const id = uow.forSchema(testSchema).generateId("users");
|
|
852
|
+
|
|
853
|
+
expect(id).toBeInstanceOf(FragnoId);
|
|
854
|
+
expect(id.externalId).toBeDefined();
|
|
855
|
+
expect(typeof id.externalId).toBe("string");
|
|
856
|
+
expect(id.externalId.length).toBeGreaterThan(0);
|
|
857
|
+
expect(id.version).toBe(0);
|
|
858
|
+
|
|
859
|
+
// No mutation operations should be added
|
|
860
|
+
expect(uow.getMutationOperations()).toHaveLength(0);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("should generate unique IDs on each call", () => {
|
|
864
|
+
const uow = createUnitOfWork(createMockCompiler(), createMockExecutor(), createMockDecoder());
|
|
865
|
+
const typedUow = uow.forSchema(testSchema);
|
|
866
|
+
|
|
867
|
+
const id1 = typedUow.generateId("users");
|
|
868
|
+
const id2 = typedUow.generateId("users");
|
|
869
|
+
|
|
870
|
+
expect(id1.externalId).not.toBe(id2.externalId);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("should allow using generated ID in create", async () => {
|
|
874
|
+
const executor = {
|
|
875
|
+
executeRetrievalPhase: async () => [],
|
|
876
|
+
executeMutationPhase: async () => ({
|
|
877
|
+
success: true,
|
|
878
|
+
createdInternalIds: [1n],
|
|
879
|
+
}),
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const uow = createUnitOfWork(createMockCompiler(), executor, createMockDecoder());
|
|
883
|
+
const typedUow = uow.forSchema(testSchema);
|
|
884
|
+
|
|
885
|
+
const id = typedUow.generateId("users");
|
|
886
|
+
typedUow.create("users", { id, email: "test@example.com", name: "Test" });
|
|
887
|
+
|
|
888
|
+
await uow.executeMutations();
|
|
889
|
+
const createdIds = uow.getCreatedIds();
|
|
890
|
+
|
|
891
|
+
expect(createdIds).toHaveLength(1);
|
|
892
|
+
expect(createdIds[0].externalId).toBe(id.externalId);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("should throw for non-existent table", () => {
|
|
896
|
+
const uow = createUnitOfWork(createMockCompiler(), createMockExecutor(), createMockDecoder());
|
|
897
|
+
|
|
898
|
+
expect(() => {
|
|
899
|
+
// @ts-expect-error - testing runtime error for non-existent table
|
|
900
|
+
uow.forSchema(testSchema).generateId("nonexistent");
|
|
901
|
+
}).toThrow("Table nonexistent not found in schema");
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
841
905
|
describe("getCreatedIds", () => {
|
|
842
906
|
const testSchema = schema((s) =>
|
|
843
907
|
s.addTable("users", (t) =>
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
Relation,
|
|
8
8
|
} from "../../schema/create";
|
|
9
9
|
import { FragnoId } from "../../schema/create";
|
|
10
|
+
import { generateId } from "../../schema/generate-id";
|
|
10
11
|
import type { Condition, ConditionBuilder } from "../condition-builder";
|
|
11
12
|
import type {
|
|
12
13
|
SelectClause,
|
|
@@ -262,13 +263,9 @@ export type MutationResult =
|
|
|
262
263
|
* Executor interface for Unit of Work operations
|
|
263
264
|
*/
|
|
264
265
|
export interface UOWExecutor<TOutput, TRawResult = unknown> {
|
|
265
|
-
/**
|
|
266
|
-
* Execute the retrieval phase - all queries run in a single transaction for snapshot isolation
|
|
267
|
-
*/
|
|
268
266
|
executeRetrievalPhase(retrievalBatch: TOutput[]): Promise<TRawResult[]>;
|
|
269
267
|
|
|
270
268
|
/**
|
|
271
|
-
* Execute the mutation phase - all queries run in a transaction with version checks
|
|
272
269
|
* Returns success status indicating if mutations completed without conflicts,
|
|
273
270
|
* and internal IDs for create operations (null if database doesn't support RETURNING)
|
|
274
271
|
*/
|
|
@@ -903,7 +900,7 @@ export interface IUnitOfWork {
|
|
|
903
900
|
// Getters (schema-agnostic)
|
|
904
901
|
readonly state: UOWState;
|
|
905
902
|
readonly name: string | undefined;
|
|
906
|
-
readonly
|
|
903
|
+
readonly idempotencyKey: string;
|
|
907
904
|
readonly retrievalPhase: Promise<unknown[]>;
|
|
908
905
|
readonly mutationPhase: Promise<void>;
|
|
909
906
|
|
|
@@ -917,7 +914,11 @@ export interface IUnitOfWork {
|
|
|
917
914
|
getCreatedIds(): FragnoId[];
|
|
918
915
|
|
|
919
916
|
// Parent-child relationships
|
|
920
|
-
restrict(): IUnitOfWork;
|
|
917
|
+
restrict(options?: { readyFor?: "mutation" | "retrieval" | "none" }): IUnitOfWork;
|
|
918
|
+
|
|
919
|
+
// Coordination for restricted UOWs
|
|
920
|
+
signalReadyForRetrieval(): void;
|
|
921
|
+
signalReadyForMutation(): void;
|
|
921
922
|
|
|
922
923
|
// Reset for retry support
|
|
923
924
|
reset(): void;
|
|
@@ -963,7 +964,7 @@ export function createUnitOfWork(
|
|
|
963
964
|
export interface UnitOfWorkConfig {
|
|
964
965
|
dryRun?: boolean;
|
|
965
966
|
onQuery?: (query: unknown) => void;
|
|
966
|
-
|
|
967
|
+
idempotencyKey?: string;
|
|
967
968
|
}
|
|
968
969
|
|
|
969
970
|
/**
|
|
@@ -1195,7 +1196,7 @@ class UOWChildCoordinator<TRawInput> {
|
|
|
1195
1196
|
export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
1196
1197
|
#name?: string;
|
|
1197
1198
|
#config?: UnitOfWorkConfig;
|
|
1198
|
-
#
|
|
1199
|
+
#idempotencyKey: string;
|
|
1199
1200
|
|
|
1200
1201
|
#state: UOWState = "building-retrieval";
|
|
1201
1202
|
|
|
@@ -1239,7 +1240,7 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1239
1240
|
this.#schemaNamespaceMap = schemaNamespaceMap ?? new WeakMap();
|
|
1240
1241
|
this.#name = name;
|
|
1241
1242
|
this.#config = config;
|
|
1242
|
-
this.#
|
|
1243
|
+
this.#idempotencyKey = config?.idempotencyKey ?? crypto.randomUUID();
|
|
1243
1244
|
}
|
|
1244
1245
|
|
|
1245
1246
|
/**
|
|
@@ -1275,20 +1276,25 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1275
1276
|
* Create a restricted child UOW that cannot execute phases.
|
|
1276
1277
|
* The child shares the same operation storage but must signal readiness
|
|
1277
1278
|
* before the parent can execute each phase.
|
|
1279
|
+
*
|
|
1280
|
+
* @param options.readyFor - Controls automatic readiness signaling:
|
|
1281
|
+
* - "mutation" (default): Signals ready for both retrieval and mutation immediately
|
|
1282
|
+
* - "retrieval": Signals ready for retrieval only
|
|
1283
|
+
* - "none": No automatic signaling, caller must signal manually
|
|
1278
1284
|
*/
|
|
1279
|
-
restrict(): UnitOfWork<TRawInput> {
|
|
1285
|
+
restrict(options?: { readyFor?: "mutation" | "retrieval" | "none" }): UnitOfWork<TRawInput> {
|
|
1286
|
+
const readyFor = options?.readyFor ?? "mutation";
|
|
1287
|
+
|
|
1280
1288
|
const child = new UnitOfWork(
|
|
1281
1289
|
this.#compiler,
|
|
1282
1290
|
this.#executor,
|
|
1283
1291
|
this.#decoder,
|
|
1284
1292
|
this.#name,
|
|
1285
|
-
{ ...this.#config,
|
|
1293
|
+
{ ...this.#config, idempotencyKey: this.#idempotencyKey },
|
|
1286
1294
|
this.#schemaNamespaceMap,
|
|
1287
1295
|
);
|
|
1288
1296
|
child.#coordinator.setAsRestricted(this, this.#coordinator);
|
|
1289
1297
|
|
|
1290
|
-
// Share state with parent
|
|
1291
|
-
child.#state = this.#state;
|
|
1292
1298
|
child.#retrievalOps = this.#retrievalOps;
|
|
1293
1299
|
child.#mutationOps = this.#mutationOps;
|
|
1294
1300
|
child.#retrievalResults = this.#retrievalResults;
|
|
@@ -1301,10 +1307,13 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1301
1307
|
|
|
1302
1308
|
this.#coordinator.addChild(child);
|
|
1303
1309
|
|
|
1304
|
-
//
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1310
|
+
// Signal readiness based on options
|
|
1311
|
+
if (readyFor === "mutation" || readyFor === "retrieval") {
|
|
1312
|
+
child.signalReadyForRetrieval();
|
|
1313
|
+
}
|
|
1314
|
+
if (readyFor === "mutation") {
|
|
1315
|
+
child.signalReadyForMutation();
|
|
1316
|
+
}
|
|
1308
1317
|
|
|
1309
1318
|
return child;
|
|
1310
1319
|
}
|
|
@@ -1375,15 +1384,15 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1375
1384
|
}
|
|
1376
1385
|
|
|
1377
1386
|
get state(): UOWState {
|
|
1378
|
-
return this.#state;
|
|
1387
|
+
return this.#coordinator.parent?.state ?? this.#state;
|
|
1379
1388
|
}
|
|
1380
1389
|
|
|
1381
1390
|
get name(): string | undefined {
|
|
1382
1391
|
return this.#name;
|
|
1383
1392
|
}
|
|
1384
1393
|
|
|
1385
|
-
get
|
|
1386
|
-
return this.#
|
|
1394
|
+
get idempotencyKey(): string {
|
|
1395
|
+
return this.#idempotencyKey;
|
|
1387
1396
|
}
|
|
1388
1397
|
|
|
1389
1398
|
/**
|
|
@@ -1502,7 +1511,9 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1502
1511
|
this.#state = "executed";
|
|
1503
1512
|
|
|
1504
1513
|
if (result.success) {
|
|
1505
|
-
|
|
1514
|
+
// Mutate array in-place to preserve shared references with child UOWs
|
|
1515
|
+
this.#createdInternalIds.length = 0;
|
|
1516
|
+
this.#createdInternalIds.push(...result.createdInternalIds);
|
|
1506
1517
|
}
|
|
1507
1518
|
|
|
1508
1519
|
// Resolve the mutation phase promise to unblock waiting service methods
|
|
@@ -1536,9 +1547,9 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1536
1547
|
* Add a retrieval operation (used by TypedUnitOfWork)
|
|
1537
1548
|
*/
|
|
1538
1549
|
addRetrievalOperation(op: RetrievalOperation<AnySchema>): number {
|
|
1539
|
-
if (this
|
|
1550
|
+
if (this.state !== "building-retrieval") {
|
|
1540
1551
|
throw new Error(
|
|
1541
|
-
`Cannot add retrieval operation in state ${this
|
|
1552
|
+
`Cannot add retrieval operation in state ${this.state}. Must be in building-retrieval state.`,
|
|
1542
1553
|
);
|
|
1543
1554
|
}
|
|
1544
1555
|
this.#retrievalOps.push(op);
|
|
@@ -1550,7 +1561,7 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1550
1561
|
* Add a mutation operation (used by TypedUnitOfWork)
|
|
1551
1562
|
*/
|
|
1552
1563
|
addMutationOperation(op: MutationOperation<AnySchema>): void {
|
|
1553
|
-
if (this
|
|
1564
|
+
if (this.state === "executed") {
|
|
1554
1565
|
throw new Error(`Cannot add mutation operation in executed state.`);
|
|
1555
1566
|
}
|
|
1556
1567
|
this.#mutationOps.push(op);
|
|
@@ -1565,9 +1576,9 @@ export class UnitOfWork<const TRawInput = unknown> implements IUnitOfWork {
|
|
|
1565
1576
|
* @returns Array of FragnoIds in the same order as create() calls
|
|
1566
1577
|
*/
|
|
1567
1578
|
getCreatedIds(): FragnoId[] {
|
|
1568
|
-
if (this
|
|
1579
|
+
if (this.state !== "executed") {
|
|
1569
1580
|
throw new Error(
|
|
1570
|
-
`getCreatedIds() can only be called after executeMutations(). Current state: ${this
|
|
1581
|
+
`getCreatedIds() can only be called after executeMutations(). Current state: ${this.state}`,
|
|
1571
1582
|
);
|
|
1572
1583
|
}
|
|
1573
1584
|
|
|
@@ -1660,8 +1671,8 @@ export class TypedUnitOfWork<
|
|
|
1660
1671
|
return this.#uow.name;
|
|
1661
1672
|
}
|
|
1662
1673
|
|
|
1663
|
-
get
|
|
1664
|
-
return this.#uow.
|
|
1674
|
+
get idempotencyKey(): string {
|
|
1675
|
+
return this.#uow.idempotencyKey;
|
|
1665
1676
|
}
|
|
1666
1677
|
|
|
1667
1678
|
get state() {
|
|
@@ -1712,8 +1723,16 @@ export class TypedUnitOfWork<
|
|
|
1712
1723
|
return this.#uow.executeMutations();
|
|
1713
1724
|
}
|
|
1714
1725
|
|
|
1715
|
-
restrict(): IUnitOfWork {
|
|
1716
|
-
return this.#uow.restrict();
|
|
1726
|
+
restrict(options?: { readyFor?: "mutation" | "retrieval" | "none" }): IUnitOfWork {
|
|
1727
|
+
return this.#uow.restrict(options);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
signalReadyForRetrieval(): void {
|
|
1731
|
+
this.#uow.signalReadyForRetrieval();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
signalReadyForMutation(): void {
|
|
1735
|
+
this.#uow.signalReadyForMutation();
|
|
1717
1736
|
}
|
|
1718
1737
|
|
|
1719
1738
|
reset(): void {
|
|
@@ -1940,6 +1959,22 @@ export class TypedUnitOfWork<
|
|
|
1940
1959
|
return this as any;
|
|
1941
1960
|
}
|
|
1942
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* Generate a new ID for a table without creating a record.
|
|
1964
|
+
* This is useful when you need to reference an ID before actually creating the record,
|
|
1965
|
+
* or when you need to pass the ID to external services.
|
|
1966
|
+
*
|
|
1967
|
+
* @example
|
|
1968
|
+
* ```ts
|
|
1969
|
+
* const userId = uow.generateId("users");
|
|
1970
|
+
* // Use userId in related records or pass to external services
|
|
1971
|
+
* uow.create("users", { id: userId, name: "John" });
|
|
1972
|
+
* ```
|
|
1973
|
+
*/
|
|
1974
|
+
generateId<TableName extends keyof TSchema["tables"] & string>(tableName: TableName): FragnoId {
|
|
1975
|
+
return generateId(this.#schema, tableName);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1943
1978
|
create<TableName extends keyof TSchema["tables"] & string>(
|
|
1944
1979
|
tableName: TableName,
|
|
1945
1980
|
values: TableToInsertValues<TSchema["tables"][TableName]>,
|
package/src/schema/create.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createId } from "../id";
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
export { generateId } from "./generate-id";
|
|
3
4
|
|
|
4
5
|
export type AnySchema = Schema<Record<string, AnyTable>>;
|
|
5
6
|
|
|
@@ -548,10 +549,6 @@ export class FragnoId {
|
|
|
548
549
|
valueOf(): string {
|
|
549
550
|
return this.#externalId;
|
|
550
551
|
}
|
|
551
|
-
|
|
552
|
-
[inspect.custom](): string {
|
|
553
|
-
return `FragnoId { externalId: ${this.#externalId}, internalId: ${this.#internalId?.toString()} }`;
|
|
554
|
-
}
|
|
555
552
|
}
|
|
556
553
|
|
|
557
554
|
/**
|