@@ -195,6 +195,17 @@ export class SessionDatabase implements ISessionDatabase {
195195 protected _closed : Promise < void > | true | undefined ;
196196 private readonly _fileEditSequencer = new SequencerByKey < string > ( ) ;
197197
198+ /**
199+ * In-flight write operations. Tracked so {@link whenIdle} can await them
200+ * before the process exits — without this, a `SIGTERM` arriving between
201+ * a fire-and-forget mutating call (e.g. `setMetadata`) being invoked and
202+ * its underlying SQLite query completing would silently drop the write.
203+ * Every public mutating method routes its returned promise through
204+ * {@link _track}; reads (`getMetadata`, `getFileEdits`, ...) skip
205+ * tracking since shutdown does not need to wait for them.
206+ */
207+ private readonly _pendingWrites = new Set < Promise < unknown > > ( ) ;
208+
198209 constructor (
199210 private readonly _path : string ,
200211 private readonly _migrations : readonly ISessionDatabaseMigration [ ] = sessionDatabaseMigrations ,
@@ -251,23 +262,29 @@ export class SessionDatabase implements ISessionDatabase {
251262
252263 // ---- Turns ----------------------------------------------------------
253264
254- async createTurn ( turnId : string ) : Promise < void > {
255- const db = await this . _ensureDb ( ) ;
256- await dbRun ( db , 'INSERT OR IGNORE INTO turns (id) VALUES (?)' , [ turnId ] ) ;
265+ createTurn ( turnId : string ) : Promise < void > {
266+ return this . _track ( async ( ) => {
267+ const db = await this . _ensureDb ( ) ;
268+ await dbRun ( db , 'INSERT OR IGNORE INTO turns (id) VALUES (?)' , [ turnId ] ) ;
269+ } ) ;
257270 }
258271
259- async deleteTurn ( turnId : string ) : Promise < void > {
260- const db = await this . _ensureDb ( ) ;
261- await dbRun ( db , 'DELETE FROM turns WHERE id = ?' , [ turnId ] ) ;
272+ deleteTurn ( turnId : string ) : Promise < void > {
273+ return this . _track ( async ( ) => {
274+ const db = await this . _ensureDb ( ) ;
275+ await dbRun ( db , 'DELETE FROM turns WHERE id = ?' , [ turnId ] ) ;
276+ } ) ;
262277 }
263278
264- async setTurnEventId ( turnId : string , eventId : string ) : Promise < void > {
265- const db = await this . _ensureDb ( ) ;
266- await dbRun ( db , 'INSERT OR IGNORE INTO turns (id) VALUES (?)' , [ turnId ] ) ;
267- // Only set the event ID if not already set — steering messages
268- // trigger additional user.message events within the same turn,
269- // and we must preserve the first (boundary) event ID.
270- await dbRun ( db , 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL' , [ eventId , turnId ] ) ;
279+ setTurnEventId ( turnId : string , eventId : string ) : Promise < void > {
280+ return this . _track ( async ( ) => {
281+ const db = await this . _ensureDb ( ) ;
282+ await dbRun ( db , 'INSERT OR IGNORE INTO turns (id) VALUES (?)' , [ turnId ] ) ;
283+ // Only set the event ID if not already set — steering messages
284+ // trigger additional user.message events within the same turn,
285+ // and we must preserve the first (boundary) event ID.
286+ await dbRun ( db , 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL' , [ eventId , turnId ] ) ;
287+ } ) ;
271288 }
272289
273290 async getTurnEventId ( turnId : string ) : Promise < string | undefined > {
@@ -292,36 +309,42 @@ export class SessionDatabase implements ISessionDatabase {
292309 return row ?. event_id as string | undefined ?? undefined ;
293310 }
294311
295- async truncateFromTurn ( turnId : string ) : Promise < void > {
296- const db = await this . _ensureDb ( ) ;
297- // Delete the target turn and all turns inserted after it (by rowid order).
298- // File edits cascade-delete via the foreign key constraint.
299- await dbRun ( db ,
300- `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)` ,
301- [ turnId ] ,
302- ) ;
312+ truncateFromTurn ( turnId : string ) : Promise < void > {
313+ return this . _track ( async ( ) => {
314+ const db = await this . _ensureDb ( ) ;
315+ // Delete the target turn and all turns inserted after it (by rowid order).
316+ // File edits cascade-delete via the foreign key constraint.
317+ await dbRun ( db ,
318+ `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)` ,
319+ [ turnId ] ,
320+ ) ;
321+ } ) ;
303322 }
304323
305- async deleteTurnsAfter ( turnId : string ) : Promise < void > {
306- const db = await this . _ensureDb ( ) ;
307- // Delete all turns inserted after the given turn (by rowid order),
308- // keeping the given turn itself.
309- // File edits cascade-delete via the foreign key constraint.
310- await dbRun ( db ,
311- `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)` ,
312- [ turnId ] ,
313- ) ;
324+ deleteTurnsAfter ( turnId : string ) : Promise < void > {
325+ return this . _track ( async ( ) => {
326+ const db = await this . _ensureDb ( ) ;
327+ // Delete all turns inserted after the given turn (by rowid order),
328+ // keeping the given turn itself.
329+ // File edits cascade-delete via the foreign key constraint.
330+ await dbRun ( db ,
331+ `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)` ,
332+ [ turnId ] ,
333+ ) ;
334+ } ) ;
314335 }
315336
316- async deleteAllTurns ( ) : Promise < void > {
317- const db = await this . _ensureDb ( ) ;
318- await dbExec ( db , 'DELETE FROM turns' ) ;
337+ deleteAllTurns ( ) : Promise < void > {
338+ return this . _track ( async ( ) => {
339+ const db = await this . _ensureDb ( ) ;
340+ await dbExec ( db , 'DELETE FROM turns' ) ;
341+ } ) ;
319342 }
320343
321344 // ---- File edits -----------------------------------------------------
322345
323- async storeFileEdit ( edit : IFileEditRecord & IFileEditContent ) : Promise < void > {
324- return this . _fileEditSequencer . queue ( edit . filePath , async ( ) => {
346+ storeFileEdit ( edit : IFileEditRecord & IFileEditContent ) : Promise < void > {
347+ return this . _track ( ( ) => this . _fileEditSequencer . queue ( edit . filePath , async ( ) => {
325348 const db = await this . _ensureDb ( ) ;
326349 // Ensure the turn exists — lazily insert since the turn record
327350 // may not have been created by an explicit createTurn() call.
@@ -343,7 +366,7 @@ export class SessionDatabase implements ISessionDatabase {
343366 edit . removedLines ?? null ,
344367 ] ,
345368 ) ;
346- } ) ;
369+ } ) ) ;
347370 }
348371
349372 async getFileEdits ( toolCallIds : string [ ] ) : Promise < IFileEditRecord [ ] > {
@@ -459,42 +482,74 @@ export class SessionDatabase implements ISessionDatabase {
459482 return result ;
460483 }
461484
462- async setMetadata ( key : string , value : string ) : Promise < void > {
463- const db = await this . _ensureDb ( ) ;
464- await dbRun ( db , 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)' , [ key , value ] ) ;
485+ setMetadata ( key : string , value : string ) : Promise < void > {
486+ return this . _track ( async ( ) => {
487+ const db = await this . _ensureDb ( ) ;
488+ await dbRun ( db , 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)' , [ key , value ] ) ;
489+ } ) ;
465490 }
466491
467- async remapTurnIds ( mapping : ReadonlyMap < string , string > ) : Promise < void > {
468- const db = await this . _ensureDb ( ) ;
469- // Defer FK checks to commit time so we can update turns.id and
470- // file_edits.turn_id in any order without mid-statement violations.
471- // This pragma auto-resets after the transaction ends.
472- await dbExec ( db , 'PRAGMA defer_foreign_keys = ON' ) ;
473- await dbExec ( db , 'BEGIN TRANSACTION' ) ;
474- try {
475- // Delete turns not present in the mapping (e.g. turns beyond
476- // the fork point). File edits cascade-delete via FK.
477- const oldIds = [ ...mapping . keys ( ) ] ;
478- if ( oldIds . length > 0 ) {
479- const placeholders = oldIds . map ( ( ) => '?' ) . join ( ',' ) ;
480- await dbRun ( db ,
481- `DELETE FROM turns WHERE id NOT IN (${ placeholders } )` ,
482- oldIds ,
483- ) ;
484- }
492+ remapTurnIds ( mapping : ReadonlyMap < string , string > ) : Promise < void > {
493+ return this . _track ( async ( ) => {
494+ const db = await this . _ensureDb ( ) ;
495+ // Defer FK checks to commit time so we can update turns.id and
496+ // file_edits.turn_id in any order without mid-statement violations.
497+ // This pragma auto-resets after the transaction ends.
498+ await dbExec ( db , 'PRAGMA defer_foreign_keys = ON' ) ;
499+ await dbExec ( db , 'BEGIN TRANSACTION' ) ;
500+ try {
501+ // Delete turns not present in the mapping (e.g. turns beyond
502+ // the fork point). File edits cascade-delete via FK.
503+ const oldIds = [ ...mapping . keys ( ) ] ;
504+ if ( oldIds . length > 0 ) {
505+ const placeholders = oldIds . map ( ( ) => '?' ) . join ( ',' ) ;
506+ await dbRun ( db ,
507+ `DELETE FROM turns WHERE id NOT IN (${ placeholders } )` ,
508+ oldIds ,
509+ ) ;
510+ }
485511
486- // Remap the remaining turn IDs to their new values
487- for ( const [ oldId , newId ] of mapping ) {
488- await dbRun ( db , 'UPDATE turns SET id = ? WHERE id = ?' , [ newId , oldId ] ) ;
489- await dbRun ( db , 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?' , [ newId , oldId ] ) ;
512+ // Remap the remaining turn IDs to their new values
513+ for ( const [ oldId , newId ] of mapping ) {
514+ await dbRun ( db , 'UPDATE turns SET id = ? WHERE id = ?' , [ newId , oldId ] ) ;
515+ await dbRun ( db , 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?' , [ newId , oldId ] ) ;
516+ }
517+ await dbExec ( db , 'COMMIT' ) ;
518+ } catch ( err ) {
519+ await dbExec ( db , 'ROLLBACK' ) ;
520+ throw err ;
490521 }
491- await dbExec ( db , 'COMMIT' ) ;
492- } catch ( err ) {
493- await dbExec ( db , 'ROLLBACK' ) ;
494- throw err ;
522+ } ) ;
523+ }
524+
525+ /**
526+ * Resolves once all currently in-flight write operations have settled.
527+ * Used by graceful shutdown to flush pending fire-and-forget writes
528+ * before the process exits. Should be called from a path where no
529+ * further writes are expected; loops until idle to also drain any
530+ * writes that get queued while we're awaiting.
531+ */
532+ async whenIdle ( ) : Promise < void > {
533+ while ( this . _pendingWrites . size > 0 ) {
534+ await Promise . allSettled ( [ ...this . _pendingWrites ] ) ;
495535 }
496536 }
497537
538+ /**
539+ * Wrap a mutating operation's promise so {@link whenIdle} can await it.
540+ * Invoke at the **outermost** layer of every public mutating method so
541+ * that any internal awaits (notably `_ensureDb()`) are covered too —
542+ * tracking only the leaf `dbRun`/`dbExec` would miss the window
543+ * between the method being called and the query actually being queued.
544+ */
545+ private _track < T > ( fn : ( ) => Promise < T > ) : Promise < T > {
546+ const p = fn ( ) ;
547+ this . _pendingWrites . add ( p ) ;
548+ const untrack = ( ) => { this . _pendingWrites . delete ( p ) ; } ;
549+ p . then ( untrack , untrack ) ;
550+ return p ;
551+ }
552+
498553 async close ( ) {
499554 await ( this . _closed ??= this . _dbPromise ?. then ( db => db . close ( ) ) . catch ( ( ) => { } ) || true ) ;
500555 }
0 commit comments