@@ -131,15 +131,58 @@ const createLegacyMigrationDatabase = (file: string, definitionId: string): void
131131 const insert = db . prepare (
132132 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ,
133133 ) ;
134- insert . run ( 1 , 'event' , 'bumped' , '{"by":2}' , null , null , 'legacy:event' , '2026-01-01T00:00:00.000Z' ) ;
134+ insert . run ( 1 , 'event' , 'bumped' , '{"by":2}' , '{"count":2}' , null , 'legacy:event' , '2026-01-01T00:00:00.000Z' ) ;
135135 insert . run ( 2 , 'reset' , null , null , '{"count":5}' , null , 'legacy:reset' , '2026-01-01T00:00:01.000Z' ) ;
136- insert . run ( 3 , 'event' , 'bumped' , '{"by":1}' , null , null , 'legacy:event-2' , '2026-01-01T00:00:02.000Z' ) ;
136+ insert . run ( 3 , 'event' , 'bumped' , '{"by":1}' , '{"count":6}' , null , 'legacy:event-2' , '2026-01-01T00:00:02.000Z' ) ;
137137 db . prepare ( 'INSERT INTO agent_state_head (id, revision, state) VALUES (1, 3, ?)' ) . run ( '{"count":6}' ) ;
138138 } finally {
139139 db . close ( ) ;
140140 }
141141} ;
142142
143+ const clearLegacyEventResults = ( file : string ) : void => {
144+ const db = new DatabaseSync ( file ) ;
145+ try {
146+ db . exec ( "UPDATE agent_state_journal SET state = NULL WHERE kind = 'event'" ) ;
147+ } finally {
148+ db . close ( ) ;
149+ }
150+ } ;
151+
152+ interface ValueState {
153+ readonly value : number ;
154+ }
155+
156+ const valueCounterDefinition = (
157+ id = 'state-sqlite-test/value-counter' ,
158+ ) : AgentStateDefinition < ValueState , typeof counterEvents > =>
159+ defineState ( {
160+ events : counterEvents ,
161+ id,
162+ initial : { value : 0 } ,
163+ lifetime : 'workspace-durable' ,
164+ migrations : {
165+ 2 : ( persisted ) => ( { value : ( persisted as CounterState ) . count * 10 } ) ,
166+ } ,
167+ reduce : ( state , event ) => ( { value : state . value + event . payload . by } ) ,
168+ schema : z . object ( { value : z . number ( ) . int ( ) } ) . strict ( ) ,
169+ version : 2 ,
170+ } ) ;
171+
172+ const createLegacyHeadOnlyDatabase = ( file : string , definitionId : string ) : void => {
173+ createLegacyMigrationDatabase ( file , definitionId ) ;
174+ const db = new DatabaseSync ( file ) ;
175+ try {
176+ db . exec ( `
177+ DELETE FROM agent_state_journal WHERE revision > 1;
178+ UPDATE agent_state_journal SET state = NULL WHERE revision = 1;
179+ UPDATE agent_state_head SET revision = 1, state = '{"count":2}' WHERE id = 1;
180+ ` ) ;
181+ } finally {
182+ db . close ( ) ;
183+ }
184+ } ;
185+
143186const holdUncheckpointedLegacyEvent = ( file : string ) : DatabaseSync => {
144187 const keeper = new DatabaseSync ( file ) ;
145188 keeper . exec ( 'PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0; BEGIN DEFERRED' ) ;
@@ -240,19 +283,31 @@ describe('sqlite driver storage behavior', () => {
240283 }
241284 } ) ) ;
242285
243- it ( 'backfills legacy NULL event results before migration for idempotent replay ' , ( ) =>
286+ it ( 'fails closed when a non-head legacy event has no recoverable committed result ' , ( ) =>
244287 withRoot ( async ( root ) => {
245288 const definition = migratingCounterDefinition ( ) ;
246289 const file = join ( root , 'legacy-null-event.sqlite' ) ;
247290 createLegacyMigrationDatabase ( file , definition . id ) ;
291+ clearLegacyEventResults ( file ) ;
292+
293+ await expect ( createSqliteStateDriver ( { file } ) . open ( definition ) ) . rejects . toMatchObject ( {
294+ code : 'migration-failure' ,
295+ message : expect . stringContaining ( 'has no recoverable committed result' ) ,
296+ name : 'AgentStateError' ,
297+ } ) ;
298+ } ) ) ;
299+
300+ it ( 'migrates a legacy journal-head result from the materialized head without using the current reducer' , ( ) =>
301+ withRoot ( async ( root ) => {
302+ const definition = valueCounterDefinition ( ) ;
303+ const file = join ( root , 'legacy-head-event.sqlite' ) ;
304+ createLegacyHeadOnlyDatabase ( file , definition . id ) ;
248305
249306 const store = await createSqliteStateDriver ( { file } ) . open ( definition ) ;
250307 await expect (
251308 store . dispatch ( 'bumped' , { by : 2 } , { idempotencyKey : 'legacy:event' } ) ,
252- ) . resolves . toEqual ( { replayed : true , revision : 1 , state : { count : 20 } } ) ;
253- await expect (
254- store . dispatch ( 'bumped' , { by : 1 } , { idempotencyKey : 'legacy:event-2' } ) ,
255- ) . resolves . toEqual ( { replayed : true , revision : 3 , state : { count : 60 } } ) ;
309+ ) . resolves . toEqual ( { replayed : true , revision : 1 , state : { value : 20 } } ) ;
310+ await expect ( store . read ( ) ) . resolves . toEqual ( { revision : 2 , state : { value : 20 } } ) ;
256311 await store . close ( ) ;
257312 } ) ) ;
258313
@@ -372,6 +427,59 @@ describe('sqlite driver storage behavior', () => {
372427 }
373428 } ) ) ;
374429
430+ it ( 'preserves the initialization error when database close also fails' , ( ) =>
431+ withRoot ( async ( root ) => {
432+ const file = join ( root , 'state.sqlite' ) ;
433+ const seed = await createSqliteStateDriver ( { file } ) . open ( counterDefinition ( ) ) ;
434+ await seed . close ( ) ;
435+ const db = new DatabaseSync ( file ) ;
436+ db . exec ( 'UPDATE agent_state_meta SET kernel_format = 99' ) ;
437+ db . close ( ) ;
438+
439+ const closeFailure = new Error ( 'database close failed' ) ;
440+ const originalClose = DatabaseSync . prototype . close ;
441+ DatabaseSync . prototype . close = function close ( this : DatabaseSync ) : void {
442+ originalClose . call ( this ) ;
443+ throw closeFailure ;
444+ } ;
445+ try {
446+ await expect ( createSqliteStateDriver ( { file } ) . open ( counterDefinition ( ) ) ) . rejects . toMatchObject ( {
447+ code : 'corrupt' ,
448+ message : expect . stringContaining ( 'kernel format 99' ) ,
449+ name : 'AgentStateError' ,
450+ } ) ;
451+ } finally {
452+ DatabaseSync . prototype . close = originalClose ;
453+ }
454+ } ) ) ;
455+
456+ it ( 'attempts every store close before propagating the first close failure' , ( ) =>
457+ withRoot ( async ( root ) => {
458+ const driver = createSqliteStateDriver ( { root } ) ;
459+ const first = await driver . open ( counterDefinition ( ) ) ;
460+ const second = await driver . open ( otherDefinition ( ) ) ;
461+ const closeFailure = new Error ( 'first database close failed' ) ;
462+ const originalClose = DatabaseSync . prototype . close ;
463+ let closeAttempts = 0 ;
464+ DatabaseSync . prototype . close = function close ( this : DatabaseSync ) : void {
465+ originalClose . call ( this ) ;
466+ closeAttempts += 1 ;
467+ if ( closeAttempts === 1 ) throw closeFailure ;
468+ } ;
469+ try {
470+ await expect ( driver . close ( ) ) . rejects . toBe ( closeFailure ) ;
471+ expect ( closeAttempts ) . toBe ( 2 ) ;
472+ await expect ( driver . close ( ) ) . rejects . toBe ( closeFailure ) ;
473+ expect ( closeAttempts ) . toBe ( 2 ) ;
474+ await expect ( first . read ( ) ) . rejects . toMatchObject ( { code : 'store-closed' } ) ;
475+ await expect ( second . read ( ) ) . rejects . toMatchObject ( { code : 'store-closed' } ) ;
476+ } finally {
477+ DatabaseSync . prototype . close = originalClose ;
478+ await first . close ( ) . catch ( ( ) => undefined ) ;
479+ await second . close ( ) . catch ( ( ) => undefined ) ;
480+ }
481+ } ) ) ;
482+
375483 it ( 'fails closed with a typed corrupt error when the file is not a database' , ( ) =>
376484 withRoot ( async ( root ) => {
377485 const file = join ( root , 'state.sqlite' ) ;
0 commit comments