1- import { describe , expect , it , vi } from "vitest" ;
1+ import { describe , expect , it } from "vitest" ;
22import {
33 $transaction ,
44 isTransactionAcquisitionError ,
@@ -41,6 +41,18 @@ function config(
4141 } ;
4242}
4343
44+ function counter ( ) {
45+ let calls = 0 ;
46+ return {
47+ get calls ( ) {
48+ return calls ;
49+ } ,
50+ tick ( ) {
51+ calls += 1 ;
52+ } ,
53+ } ;
54+ }
55+
4456describe ( "isTransactionAcquisitionError" , ( ) => {
4557 it ( "is true only for P2028 raised at acquisition" , ( ) => {
4658 expect ( isTransactionAcquisitionError ( acquisitionError ( ) ) ) . toBe ( true ) ;
@@ -62,53 +74,89 @@ describe("isTransactionAcquisitionError", () => {
6274
6375describe ( "withTransactionStartRetry" , ( ) => {
6476 it ( "runs once on success" , async ( ) => {
65- const run = vi . fn ( ) . mockResolvedValue ( "ok" ) ;
77+ const c = counter ( ) ;
78+ const run = async ( ) => {
79+ c . tick ( ) ;
80+ return "ok" ;
81+ } ;
6682 await expect ( withTransactionStartRetry ( run , config ( ) ) ) . resolves . toBe ( "ok" ) ;
67- expect ( run ) . toHaveBeenCalledTimes ( 1 ) ;
83+ expect ( c . calls ) . toBe ( 1 ) ;
6884 } ) ;
6985
7086 it ( "retries an acquisition failure then succeeds" , async ( ) => {
71- const run = vi . fn ( ) . mockRejectedValueOnce ( acquisitionError ( ) ) . mockResolvedValueOnce ( "ok" ) ;
87+ const c = counter ( ) ;
88+ const run = async ( ) => {
89+ c . tick ( ) ;
90+ if ( c . calls === 1 ) throw acquisitionError ( ) ;
91+ return "ok" ;
92+ } ;
7293 await expect ( withTransactionStartRetry ( run , config ( ) ) ) . resolves . toBe ( "ok" ) ;
73- expect ( run ) . toHaveBeenCalledTimes ( 2 ) ;
94+ expect ( c . calls ) . toBe ( 2 ) ;
7495 } ) ;
7596
7697 it ( "does NOT retry P2024" , async ( ) => {
98+ const c = counter ( ) ;
7799 const err = poolTimeoutError ( ) ;
78- const run = vi . fn ( ) . mockRejectedValue ( err ) ;
100+ const run = async ( ) => {
101+ c . tick ( ) ;
102+ throw err ;
103+ } ;
79104 await expect ( withTransactionStartRetry ( run , config ( ) ) ) . rejects . toBe ( err ) ;
80- expect ( run ) . toHaveBeenCalledTimes ( 1 ) ;
105+ expect ( c . calls ) . toBe ( 1 ) ;
81106 } ) ;
82107
83108 it ( "stops after maxAttempts total attempts" , async ( ) => {
84- const run = vi . fn ( ) . mockRejectedValue ( acquisitionError ( ) ) ;
109+ const c = counter ( ) ;
110+ const run = async ( ) => {
111+ c . tick ( ) ;
112+ throw acquisitionError ( ) ;
113+ } ;
85114 await expect ( withTransactionStartRetry ( run , config ( { maxAttempts : 3 } ) ) ) . rejects . toMatchObject ( {
86115 code : "P2028" ,
87116 } ) ;
88- expect ( run ) . toHaveBeenCalledTimes ( 3 ) ;
117+ expect ( c . calls ) . toBe ( 3 ) ;
89118 } ) ;
90119
91120 it ( "runs once when disabled" , async ( ) => {
92- const run = vi . fn ( ) . mockRejectedValue ( acquisitionError ( ) ) ;
121+ const c = counter ( ) ;
122+ const run = async ( ) => {
123+ c . tick ( ) ;
124+ throw acquisitionError ( ) ;
125+ } ;
93126 await expect ( withTransactionStartRetry ( run , config ( { enabled : false } ) ) ) . rejects . toMatchObject ( {
94127 code : "P2028" ,
95128 } ) ;
96- expect ( run ) . toHaveBeenCalledTimes ( 1 ) ;
129+ expect ( c . calls ) . toBe ( 1 ) ;
97130 } ) ;
98131
99132 it ( "does not retry when the budget is exhausted" , async ( ) => {
100- const run = vi . fn ( ) . mockRejectedValue ( acquisitionError ( ) ) ;
101- const budget = { tryConsume : vi . fn ( ) . mockReturnValue ( false ) } ;
133+ const c = counter ( ) ;
134+ const budgetChecks = counter ( ) ;
135+ const run = async ( ) => {
136+ c . tick ( ) ;
137+ throw acquisitionError ( ) ;
138+ } ;
139+ const budget = {
140+ tryConsume ( ) {
141+ budgetChecks . tick ( ) ;
142+ return false ;
143+ } ,
144+ } ;
102145 await expect (
103146 withTransactionStartRetry ( run , { ...config ( { maxAttempts : 5 } ) , budget } )
104147 ) . rejects . toMatchObject ( { code : "P2028" } ) ;
105- expect ( run ) . toHaveBeenCalledTimes ( 1 ) ;
106- expect ( budget . tryConsume ) . toHaveBeenCalledTimes ( 1 ) ;
148+ expect ( c . calls ) . toBe ( 1 ) ;
149+ expect ( budgetChecks . calls ) . toBe ( 1 ) ;
107150 } ) ;
108151
109152 it ( "sleeps a jittered delay within [min, max]" , async ( ) => {
153+ const c = counter ( ) ;
110154 const delays : number [ ] = [ ] ;
111- const run = vi . fn ( ) . mockRejectedValueOnce ( acquisitionError ( ) ) . mockResolvedValueOnce ( "ok" ) ;
155+ const run = async ( ) => {
156+ c . tick ( ) ;
157+ if ( c . calls === 1 ) throw acquisitionError ( ) ;
158+ return "ok" ;
159+ } ;
112160 await withTransactionStartRetry ( run , {
113161 options : { enabled : true , maxAttempts : 2 , backoffMinMs : 50 , backoffMaxMs : 250 } ,
114162 sleep : ( ms ) => {
@@ -140,77 +188,111 @@ describe("TokenBucketRetryBudget", () => {
140188} ) ;
141189
142190describe ( "$transaction startRetry wiring" , ( ) => {
143- it ( "retries a transaction start that fails with an acquisition error" , async ( ) => {
144- let calls = 0 ;
145- const prisma = {
146- $transaction : vi . fn ( ( fn : ( tx : unknown ) => Promise < unknown > ) => {
147- calls += 1 ;
148- if ( calls === 1 ) return Promise . reject ( acquisitionError ( ) ) ;
149- return fn ( { } ) ;
150- } ) ,
151- } as any ;
191+ function fakeClient ( behavior : ( call : number ) => Promise < unknown > ) {
192+ const c = counter ( ) ;
193+ return {
194+ client : {
195+ $transaction : ( fn : ( tx : unknown ) => Promise < unknown > , _options ?: unknown ) => {
196+ c . tick ( ) ;
197+ return behavior ( c . calls ) . then ( ( ) => fn ( { } ) ) ;
198+ } ,
199+ } as any ,
200+ get calls ( ) {
201+ return c . calls ;
202+ } ,
203+ } ;
204+ }
152205
206+ it ( "retries a transaction start that fails with an acquisition error" , async ( ) => {
207+ const fake = fakeClient ( ( call ) =>
208+ call === 1 ? Promise . reject ( acquisitionError ( ) ) : Promise . resolve ( )
209+ ) ;
153210 const result = await $transaction (
154- prisma ,
211+ fake . client ,
155212 async ( ) => "done" ,
156213 ( ) => { } ,
157214 { startRetry : config ( ) }
158215 ) ;
159-
160216 expect ( result ) . toBe ( "done" ) ;
161- expect ( prisma . $transaction ) . toHaveBeenCalledTimes ( 2 ) ;
217+ expect ( fake . calls ) . toBe ( 2 ) ;
162218 } ) ;
163219
164220 it ( "does not retry without a startRetry config" , async ( ) => {
165221 const err = acquisitionError ( ) ;
166- const prisma = { $transaction : vi . fn ( ) . mockRejectedValue ( err ) } as any ;
167- const onError = vi . fn ( ) ;
168- await expect ( $transaction ( prisma , async ( ) => "x" , onError , { } ) ) . rejects . toBe ( err ) ;
169- expect ( prisma . $transaction ) . toHaveBeenCalledTimes ( 1 ) ;
170- expect ( onError ) . toHaveBeenCalledWith ( err ) ;
222+ const fake = fakeClient ( ( ) => Promise . reject ( err ) ) ;
223+ let captured : unknown ;
224+ await expect (
225+ $transaction (
226+ fake . client ,
227+ async ( ) => "x" ,
228+ ( e ) => {
229+ captured = e ;
230+ } ,
231+ { }
232+ )
233+ ) . rejects . toBe ( err ) ;
234+ expect ( fake . calls ) . toBe ( 1 ) ;
235+ expect ( captured ) . toBe ( err ) ;
171236 } ) ;
172237
173238 it ( "passes maxWait through to prisma.$transaction options" , async ( ) => {
174- const prisma = {
175- $transaction : vi . fn ( ( fn : ( tx : unknown ) => Promise < unknown > ) => fn ( { } ) ) ,
239+ let seenOptions : unknown ;
240+ const client = {
241+ $transaction : ( fn : ( tx : unknown ) => Promise < unknown > , options ?: unknown ) => {
242+ seenOptions = options ;
243+ return fn ( { } ) ;
244+ } ,
176245 } as any ;
177246 await $transaction (
178- prisma ,
247+ client ,
179248 async ( ) => "x" ,
180249 ( ) => { } ,
181250 { maxWait : 10000 }
182251 ) ;
183- expect ( prisma . $transaction ) . toHaveBeenCalledWith ( expect . any ( Function ) , { maxWait : 10000 } ) ;
252+ expect ( seenOptions ) . toEqual ( { maxWait : 10000 } ) ;
184253 } ) ;
185254
186- it ( "UNLIMITED_RETRY_BUDGET always consumes" , ( ) => {
187- expect ( UNLIMITED_RETRY_BUDGET . tryConsume ( ) ) . toBe ( true ) ;
255+ it ( "does not let maxRetries retry an acquisition error while startRetry is active" , async ( ) => {
256+ const fake = fakeClient ( ( ) => Promise . reject ( acquisitionError ( ) ) ) ;
257+ await expect (
258+ $transaction (
259+ fake . client ,
260+ async ( ) => "x" ,
261+ ( ) => { } ,
262+ { startRetry : config ( { maxAttempts : 2 } ) , maxRetries : 3 }
263+ )
264+ ) . rejects . toMatchObject ( { code : "P2028" } ) ;
265+ expect ( fake . calls ) . toBe ( 2 ) ;
188266 } ) ;
189267
190- it ( "does not let maxRetries retry an acquisition error beyond the startRetry budget " , async ( ) => {
191- const prisma = { $transaction : vi . fn ( ) . mockRejectedValue ( acquisitionError ( ) ) } as any ;
268+ it ( "falls back to maxRetries for acquisition errors when startRetry is disabled " , async ( ) => {
269+ const fake = fakeClient ( ( ) => Promise . reject ( acquisitionError ( ) ) ) ;
192270 await expect (
193271 $transaction (
194- prisma ,
272+ fake . client ,
195273 async ( ) => "x" ,
196274 ( ) => { } ,
197- { startRetry : config ( { maxAttempts : 2 } ) , maxRetries : 3 }
275+ { startRetry : config ( { enabled : false } ) , maxRetries : 3 }
198276 )
199277 ) . rejects . toMatchObject ( { code : "P2028" } ) ;
200- expect ( prisma . $transaction ) . toHaveBeenCalledTimes ( 2 ) ;
278+ expect ( fake . calls ) . toBe ( 4 ) ;
201279 } ) ;
202280
203281 it ( "still lets maxRetries retry a serialization error (P2034)" , async ( ) => {
204282 const serializationError = { code : "P2034" , message : "write conflict / deadlock" } ;
205- const prisma = { $transaction : vi . fn ( ) . mockRejectedValue ( serializationError ) } as any ;
283+ const fake = fakeClient ( ( ) => Promise . reject ( serializationError ) ) ;
206284 await expect (
207285 $transaction (
208- prisma ,
286+ fake . client ,
209287 async ( ) => "x" ,
210288 ( ) => { } ,
211289 { maxRetries : 2 }
212290 )
213291 ) . rejects . toMatchObject ( { code : "P2034" } ) ;
214- expect ( prisma . $transaction ) . toHaveBeenCalledTimes ( 3 ) ;
292+ expect ( fake . calls ) . toBe ( 3 ) ;
293+ } ) ;
294+
295+ it ( "UNLIMITED_RETRY_BUDGET always consumes" , ( ) => {
296+ expect ( UNLIMITED_RETRY_BUDGET . tryConsume ( ) ) . toBe ( true ) ;
215297 } ) ;
216298} ) ;
0 commit comments