@@ -148,105 +148,65 @@ describe("concurrent setState", () => {
148148 ) ;
149149 } ) ;
150150
151- it ( "should expose lost update when stale write commits last" , async ( ) => {
152- await Match . create ( match ) ;
153-
154- const stateA : State = {
155- ... state ,
156- ctx : { ... state . ctx , currentPlayer : "102" , turn : 2 } ,
157- _stateID : 2 ,
158- } ;
159- const stateB : State = {
160- ... state ,
161- ctx : { ... state . ctx , currentPlayer : "103" , turn : 3 } ,
162- _stateID : 3 ,
163- } ;
164-
165- const logA : LogEntry [ ] = [
166- {
167- ...logEntry ,
151+ // Regression: without SELECT … FOR UPDATE, concurrent setState calls can
152+ // read the same stale _stateID, both pass the < check, and the last writer
153+ // wins — even if it carries a lower _stateID (lost update).
154+ // The row lock ensures the second transaction sees the first's committed
155+ // write, so the stale update is correctly rejected.
156+ // We repeat the race multiple times to catch non-deterministic regressions.
157+ it ( "should never regress to a lower _stateID under concurrent writes" , async ( ) => {
158+ const ITERATIONS = 20 ;
159+
160+ for ( let i = 0 ; i < ITERATIONS ; i ++ ) {
161+ await testStore . beforeEach ( ) ; // reset DB between iterations
162+
163+ await Match . create ( match ) ;
164+
165+ const stateA : State = {
166+ ... state ,
167+ ctx : { ...state . ctx , currentPlayer : "102" , turn : 2 } ,
168168 _stateID : 2 ,
169- turn : 2 ,
170- action : {
171- ...logEntry . action ,
172- payload : { ...logEntry . action . payload , playerID : "102" } ,
173- } ,
174- } ,
175- ] ;
176- const logB : LogEntry [ ] = [
177- {
178- ...logEntry ,
169+ } ;
170+ const stateB : State = {
171+ ...state ,
172+ ctx : { ...state . ctx , currentPlayer : "103" , turn : 3 } ,
179173 _stateID : 3 ,
180- turn : 3 ,
181- action : {
182- ...logEntry . action ,
183- payload : { ...logEntry . action . payload , playerID : "103" } ,
174+ } ;
175+
176+ const logA : LogEntry [ ] = [
177+ {
178+ ...logEntry ,
179+ _stateID : 2 ,
180+ turn : 2 ,
181+ action : {
182+ ...logEntry . action ,
183+ payload : { ...logEntry . action . payload , playerID : "102" } ,
184+ } ,
184185 } ,
185- } ,
186- ] ;
187-
188- const originalFindByPk = Match . findByPk . bind ( Match ) ;
189- const originalUpsert = Match . upsert . bind ( Match ) ;
190-
191- let findByPkCount = 0 ;
192- let releaseReads ! : ( ) => void ;
193- const readsReady = new Promise < void > ( ( resolve ) => {
194- releaseReads = resolve ;
195- } ) ;
196-
197- let releaseStateAWrite ! : ( ) => void ;
198- const stateAWriteGate = new Promise < void > ( ( resolve ) => {
199- releaseStateAWrite = resolve ;
200- } ) ;
201-
202- const findByPkSpy = jest
203- . spyOn ( Match , "findByPk" )
204- . mockImplementation ( async ( ...args : Parameters < typeof Match . findByPk > ) => {
205- const row = await originalFindByPk ( ...args ) ;
206- findByPkCount += 1 ;
207- if ( findByPkCount === 2 ) {
208- releaseReads ( ) ;
209- }
210- await readsReady ;
211- return row ;
212- } ) ;
213-
214- const upsertSpy = jest
215- . spyOn ( Match , "upsert" )
216- . mockImplementation ( async ( ...args : Parameters < typeof Match . upsert > ) => {
217- const values = args [ 0 ] as { state ?: State } ;
218- const incomingStateID = values . state ?. _stateID ;
219-
220- if ( incomingStateID === 2 ) {
221- await stateAWriteGate ;
222- }
223-
224- const result = await originalUpsert ( ...args ) ;
225-
226- if ( incomingStateID === 3 ) {
227- releaseStateAWrite ( ) ;
228- }
229-
230- return result ;
231- } ) ;
186+ ] ;
187+ const logB : LogEntry [ ] = [
188+ {
189+ ...logEntry ,
190+ _stateID : 3 ,
191+ turn : 3 ,
192+ action : {
193+ ...logEntry . action ,
194+ payload : { ...logEntry . action . payload , playerID : "103" } ,
195+ } ,
196+ } ,
197+ ] ;
232198
233- try {
234199 await Promise . all ( [
235200 testStore . db . setState ( match . id ! , stateA , logA ) ,
236201 testStore . db . setState ( match . id ! , stateB , logB ) ,
237202 ] ) ;
238- } finally {
239- findByPkSpy . mockRestore ( ) ;
240- upsertSpy . mockRestore ( ) ;
241- }
242203
243- const result = await testStore . db . fetch ( match . id ! , {
244- state : true ,
245- log : true ,
246- } ) ;
204+ const result = await testStore . db . fetch ( match . id ! , {
205+ state : true ,
206+ } ) ;
247207
248- // Correct behavior would keep the highest _stateID even if stale write runs last.
249- // This expectation is intentionally red until setState is fixed.
250- expect ( result . state ! . _stateID ) . toBe ( 3 ) ;
251- } ) ;
208+ expect ( result . state ! . _stateID ) . toBe ( 3 ) ;
209+ expect ( result . state ! . ctx . currentPlayer ) . toBe ( "103" ) ;
210+ }
211+ } , 30_000 ) ;
252212} ) ;
0 commit comments