@@ -763,6 +763,274 @@ procedure echoOverview(o: Overview): Overview
763763 expect ( r . data . stringList ) . toEqual ( [ 'd' , 'e' , 'f' ] ) ;
764764 } ) ;
765765
766+ describe ( 'transaction' , ( ) => {
767+ it ( 'runs sequential operations atomically' , async ( ) => {
768+ const handleRequest = makeHandler ( ) ;
769+
770+ // Clean up
771+ await rawClient . post . deleteMany ( ) ;
772+ await rawClient . user . deleteMany ( ) ;
773+
774+ const r = await handleRequest ( {
775+ method : 'post' ,
776+ path : '/$transaction/sequential' ,
777+ requestBody : [
778+ {
779+ model : 'User' ,
780+ op : 'create' ,
781+ args : { data : { id : 'txuser1' , email : 'txuser1@abc.com' } } ,
782+ } ,
783+ {
784+ model : 'Post' ,
785+ op : 'create' ,
786+ args : { data : { id : 'txpost1' , title : 'Tx Post' , authorId : 'txuser1' } } ,
787+ } ,
788+ {
789+ model : 'Post' ,
790+ op : 'findMany' ,
791+ args : { where : { authorId : 'txuser1' } } ,
792+ } ,
793+ ] ,
794+ client : rawClient ,
795+ } ) ;
796+
797+ expect ( r . status ) . toBe ( 200 ) ;
798+ expect ( Array . isArray ( r . data ) ) . toBe ( true ) ;
799+ expect ( r . data ) . toHaveLength ( 3 ) ;
800+ expect ( r . data [ 0 ] ) . toMatchObject ( { id : 'txuser1' , email : 'txuser1@abc.com' } ) ;
801+ expect ( r . data [ 1 ] ) . toMatchObject ( { id : 'txpost1' , title : 'Tx Post' } ) ;
802+ expect ( r . data [ 2 ] ) . toHaveLength ( 1 ) ;
803+ expect ( r . data [ 2 ] [ 0 ] ) . toMatchObject ( { id : 'txpost1' } ) ;
804+
805+ // Clean up
806+ await rawClient . post . deleteMany ( ) ;
807+ await rawClient . user . deleteMany ( ) ;
808+ } ) ;
809+
810+ it ( 'rejects non-POST methods' , async ( ) => {
811+ const handleRequest = makeHandler ( ) ;
812+
813+ const r = await handleRequest ( {
814+ method : 'get' ,
815+ path : '/$transaction/sequential' ,
816+ client : rawClient ,
817+ } ) ;
818+ expect ( r . status ) . toBe ( 400 ) ;
819+ expect ( r . error . message ) . toMatch ( / o n l y P O S T i s s u p p o r t e d / i) ;
820+ } ) ;
821+
822+ it ( 'rejects missing or non-array body' , async ( ) => {
823+ const handleRequest = makeHandler ( ) ;
824+
825+ let r = await handleRequest ( {
826+ method : 'post' ,
827+ path : '/$transaction/sequential' ,
828+ client : rawClient ,
829+ } ) ;
830+ expect ( r . status ) . toBe ( 400 ) ;
831+ expect ( r . error . message ) . toMatch ( / n o n - e m p t y a r r a y / i) ;
832+
833+ r = await handleRequest ( {
834+ method : 'post' ,
835+ path : '/$transaction/sequential' ,
836+ requestBody : [ ] ,
837+ client : rawClient ,
838+ } ) ;
839+ expect ( r . status ) . toBe ( 400 ) ;
840+ expect ( r . error . message ) . toMatch ( / n o n - e m p t y a r r a y / i) ;
841+
842+ r = await handleRequest ( {
843+ method : 'post' ,
844+ path : '/$transaction/sequential' ,
845+ requestBody : { model : 'User' , op : 'findMany' , args : { } } ,
846+ client : rawClient ,
847+ } ) ;
848+ expect ( r . status ) . toBe ( 400 ) ;
849+ expect ( r . error . message ) . toMatch ( / n o n - e m p t y a r r a y / i) ;
850+ } ) ;
851+
852+ it ( 'rejects unknown model in operation' , async ( ) => {
853+ const handleRequest = makeHandler ( ) ;
854+
855+ const r = await handleRequest ( {
856+ method : 'post' ,
857+ path : '/$transaction/sequential' ,
858+ requestBody : [ { model : 'Ghost' , op : 'create' , args : { data : { } } } ] ,
859+ client : rawClient ,
860+ } ) ;
861+ expect ( r . status ) . toBe ( 400 ) ;
862+ expect ( r . error . message ) . toMatch ( / u n k n o w n m o d e l / i) ;
863+ } ) ;
864+
865+ it ( 'rejects invalid op in operation' , async ( ) => {
866+ const handleRequest = makeHandler ( ) ;
867+
868+ const r = await handleRequest ( {
869+ method : 'post' ,
870+ path : '/$transaction/sequential' ,
871+ requestBody : [ { model : 'User' , op : 'dropTable' , args : { } } ] ,
872+ client : rawClient ,
873+ } ) ;
874+ expect ( r . status ) . toBe ( 400 ) ;
875+ expect ( r . error . message ) . toMatch ( / i n v a l i d o p / i) ;
876+ } ) ;
877+
878+ it ( 'rejects operation missing model or op field' , async ( ) => {
879+ const handleRequest = makeHandler ( ) ;
880+
881+ let r = await handleRequest ( {
882+ method : 'post' ,
883+ path : '/$transaction/sequential' ,
884+ requestBody : [ { op : 'create' , args : { data : { } } } ] ,
885+ client : rawClient ,
886+ } ) ;
887+ expect ( r . status ) . toBe ( 400 ) ;
888+ expect ( r . error . message ) . toMatch ( / " m o d e l " / i) ;
889+
890+ r = await handleRequest ( {
891+ method : 'post' ,
892+ path : '/$transaction/sequential' ,
893+ requestBody : [ { model : 'User' , args : { data : { } } } ] ,
894+ client : rawClient ,
895+ } ) ;
896+ expect ( r . status ) . toBe ( 400 ) ;
897+ expect ( r . error . message ) . toMatch ( / " o p " / i) ;
898+ } ) ;
899+
900+ it ( 'returns error for invalid args (non-existent field in where clause)' , async ( ) => {
901+ const handleRequest = makeHandler ( ) ;
902+
903+ // findMany with a non-existent field in where → ORM validation error
904+ let r = await handleRequest ( {
905+ method : 'post' ,
906+ path : '/$transaction/sequential' ,
907+ requestBody : [
908+ {
909+ model : 'User' ,
910+ op : 'findMany' ,
911+ args : { where : { nonExistentField : 'value' } } ,
912+ } ,
913+ ] ,
914+ client : rawClient ,
915+ } ) ;
916+ expect ( r . status ) . toBe ( 422 ) ;
917+ expect ( r . error . message ) . toMatch ( / v a l i d a t i o n e r r o r / i) ;
918+
919+ // findUnique missing required where clause → ORM validation error
920+ r = await handleRequest ( {
921+ method : 'post' ,
922+ path : '/$transaction/sequential' ,
923+ requestBody : [
924+ {
925+ model : 'Post' ,
926+ op : 'findUnique' ,
927+ args : { } ,
928+ } ,
929+ ] ,
930+ client : rawClient ,
931+ } ) ;
932+ expect ( r . status ) . toBe ( 422 ) ;
933+ expect ( r . error . message ) . toMatch ( / v a l i d a t i o n e r r o r / i) ;
934+
935+ // create with missing required field → ORM validation error
936+ r = await handleRequest ( {
937+ method : 'post' ,
938+ path : '/$transaction/sequential' ,
939+ requestBody : [
940+ {
941+ model : 'Post' ,
942+ op : 'create' ,
943+ // title is required but omitted
944+ args : { data : { } } ,
945+ } ,
946+ ] ,
947+ client : rawClient ,
948+ } ) ;
949+ expect ( r . status ) . toBe ( 422 ) ;
950+ expect ( r . error . message ) . toMatch ( / v a l i d a t i o n e r r o r / i) ;
951+ } ) ;
952+
953+ it ( 'deserializes SuperJSON-encoded args per operation' , async ( ) => {
954+ const handleRequest = makeHandler ( ) ;
955+
956+ // Clean up
957+ await rawClient . post . deleteMany ( ) ;
958+ await rawClient . user . deleteMany ( ) ;
959+
960+ // Serialize args containing a Date so they need SuperJSON deserialization
961+ const publishedAt = new Date ( '2025-01-15T00:00:00.000Z' ) ;
962+ const serialized = SuperJSON . serialize ( {
963+ data : { id : 'txuser3' , email : 'txuser3@abc.com' } ,
964+ } ) ;
965+ const serializedPost = SuperJSON . serialize ( {
966+ data : { id : 'txpost3' , title : 'Dated Post' , authorId : 'txuser3' , publishedAt } ,
967+ } ) ;
968+
969+ const r = await handleRequest ( {
970+ method : 'post' ,
971+ path : '/$transaction/sequential' ,
972+ requestBody : [
973+ {
974+ model : 'User' ,
975+ op : 'create' ,
976+ args : { ...( serialized . json as any ) , meta : { serialization : serialized . meta } } ,
977+ } ,
978+ {
979+ model : 'Post' ,
980+ op : 'create' ,
981+ args : { ...( serializedPost . json as any ) , meta : { serialization : serializedPost . meta } } ,
982+ } ,
983+ ] ,
984+ client : rawClient ,
985+ } ) ;
986+
987+ expect ( r . status ) . toBe ( 200 ) ;
988+ expect ( r . data ) . toHaveLength ( 2 ) ;
989+ expect ( r . data [ 0 ] ) . toMatchObject ( { id : 'txuser3' } ) ;
990+ expect ( r . data [ 1 ] ) . toMatchObject ( { id : 'txpost3' } ) ;
991+
992+ // Verify the Date was stored correctly
993+ const post = await ( rawClient as any ) . post . findUnique ( { where : { id : 'txpost3' } } ) ;
994+ expect ( post ?. publishedAt instanceof Date ) . toBe ( true ) ;
995+ expect ( ( post ?. publishedAt as Date ) ?. toISOString ( ) ) . toBe ( publishedAt . toISOString ( ) ) ;
996+
997+ // Clean up
998+ await rawClient . post . deleteMany ( ) ;
999+ await rawClient . user . deleteMany ( ) ;
1000+ } ) ;
1001+
1002+ it ( 'rolls back all operations when one fails' , async ( ) => {
1003+ const handleRequest = makeHandler ( ) ;
1004+
1005+ // Ensure no users before
1006+ await rawClient . user . deleteMany ( ) ;
1007+
1008+ const r = await handleRequest ( {
1009+ method : 'post' ,
1010+ path : '/$transaction/sequential' ,
1011+ requestBody : [
1012+ {
1013+ model : 'User' ,
1014+ op : 'create' ,
1015+ args : { data : { id : 'txuser2' , email : 'txuser2@abc.com' } } ,
1016+ } ,
1017+ // duplicate id will cause a DB error → whole tx rolls back
1018+ {
1019+ model : 'User' ,
1020+ op : 'create' ,
1021+ args : { data : { id : 'txuser2' , email : 'txuser2@abc.com' } } ,
1022+ } ,
1023+ ] ,
1024+ client : rawClient ,
1025+ } ) ;
1026+ expect ( r . status ) . toBeGreaterThanOrEqual ( 400 ) ;
1027+
1028+ // User should not have been committed
1029+ const count = await rawClient . user . count ( ) ;
1030+ expect ( count ) . toBe ( 0 ) ;
1031+ } ) ;
1032+ } ) ;
1033+
7661034 function makeHandler ( ) {
7671035 const handler = new RPCApiHandler ( { schema : client . $schema } ) ;
7681036 return async ( args : any ) => {
0 commit comments