@@ -178,6 +178,23 @@ impl Database {
178178 MIGRATIONS . last( ) . map( |m| m. version) . unwrap_or( 0 )
179179 ) ;
180180
181+ // Disable foreign-key enforcement for the duration of the migration
182+ // run, following SQLite's documented "other kinds of table schema
183+ // changes" procedure (https://sqlite.org/lang_altertable.html): the
184+ // table-rebuild migrations (005/006 rebuild inbound_clients, 012
185+ // rebuilds workspace_bindings) `DROP TABLE` the parent, and SQLite's
186+ // implicit pre-DROP DELETE FIRES `ON DELETE CASCADE` into child rows
187+ // (oauth_tokens, oauth_authorization_codes, the FS junction) — wiping
188+ // them. The `PRAGMA foreign_keys=OFF` inside those migration files is
189+ // a NO-OP because it runs inside the per-migration transaction below;
190+ // the pragma only takes effect OUTSIDE a transaction, so it must be
191+ // set here, before any `BEGIN`. The connection-level setting persists
192+ // across the per-migration transactions until we restore it.
193+ let fk_was_on = self . foreign_keys_enabled ( ) ;
194+ if fk_was_on {
195+ self . conn . pragma_update ( None , "foreign_keys" , "OFF" ) ?;
196+ }
197+
181198 // Run all migrations that haven't been applied yet
182199 for migration in MIGRATIONS {
183200 if migration. version > current_version {
@@ -196,6 +213,10 @@ impl Database {
196213 migration. name,
197214 e
198215 ) ;
216+ // Best-effort restore of FK enforcement before bailing.
217+ if fk_was_on {
218+ let _ = self . conn . pragma_update ( None , "foreign_keys" , "ON" ) ;
219+ }
199220 return Err ( anyhow:: anyhow!(
200221 "Failed to run migration {} ({}): {}" ,
201222 migration. version,
@@ -219,6 +240,45 @@ impl Database {
219240 }
220241 }
221242
243+ // Re-enable FK enforcement and verify the migrated schema is still
244+ // referentially consistent (catches any genuine orphan a migration
245+ // might have introduced while enforcement was off).
246+ if fk_was_on {
247+ self . foreign_key_check ( ) ?;
248+ self . conn . pragma_update ( None , "foreign_keys" , "ON" ) ?;
249+ }
250+
251+ Ok ( ( ) )
252+ }
253+
254+ /// Whether foreign-key enforcement is currently enabled on the connection.
255+ fn foreign_keys_enabled ( & self ) -> bool {
256+ self . conn
257+ . query_row ( "PRAGMA foreign_keys" , [ ] , |row| row. get :: < _ , i64 > ( 0 ) )
258+ . map ( |v| v != 0 )
259+ . unwrap_or ( false )
260+ }
261+
262+ /// Run `PRAGMA foreign_key_check` and fail if any violation is reported.
263+ /// Called after a migration run completes with FK enforcement temporarily
264+ /// disabled, so a buggy migration can't silently leave dangling rows.
265+ fn foreign_key_check ( & self ) -> Result < ( ) > {
266+ let mut stmt = self . conn . prepare ( "PRAGMA foreign_key_check" ) ?;
267+ let mut rows = stmt. query ( [ ] ) ?;
268+ let mut violations: Vec < String > = Vec :: new ( ) ;
269+ while let Some ( row) = rows. next ( ) ? {
270+ // Columns: table, rowid, referred_table, fk_index
271+ let table: String = row. get ( 0 ) . unwrap_or_default ( ) ;
272+ let referred: String = row. get ( 2 ) . unwrap_or_default ( ) ;
273+ violations. push ( format ! ( "{table} -> {referred}" ) ) ;
274+ }
275+ if !violations. is_empty ( ) {
276+ return Err ( anyhow:: anyhow!(
277+ "Post-migration foreign_key_check found {} violation(s): {}" ,
278+ violations. len( ) ,
279+ violations. join( ", " )
280+ ) ) ;
281+ }
222282 Ok ( ( ) )
223283 }
224284
@@ -316,6 +376,140 @@ mod tests {
316376 assert ! ( count > 0 , "Tables should be created" ) ;
317377 }
318378
379+ /// After a full migration run on a fresh DB, FK enforcement must be back
380+ /// ON and the schema must be referentially consistent — the contract of
381+ /// the runner's disable-during-migration / re-enable-after logic.
382+ #[ test]
383+ fn fresh_db_has_fk_enabled_and_no_orphans ( ) {
384+ let db = Database :: open_in_memory ( ) . unwrap ( ) ;
385+ let fk: i64 = db
386+ . connection ( )
387+ . query_row ( "PRAGMA foreign_keys" , [ ] , |r| r. get ( 0 ) )
388+ . unwrap ( ) ;
389+ assert_eq ! (
390+ fk, 1 ,
391+ "runner must re-enable FK enforcement after migrations"
392+ ) ;
393+ let violations: i64 = db
394+ . connection ( )
395+ . query_row ( "SELECT count(*) FROM pragma_foreign_key_check" , [ ] , |r| {
396+ r. get ( 0 )
397+ } )
398+ . unwrap ( ) ;
399+ assert_eq ! ( violations, 0 , "migrated schema must have no FK violations" ) ;
400+ }
401+
402+ /// The exact mechanic the table-rebuild migrations (005/006/012) depend
403+ /// on: DROPping a parent referenced by a child via `ON DELETE CASCADE`
404+ /// must NOT wipe the child — which only holds when FK enforcement is OFF
405+ /// during the rebuild (the runner disables it outside the transaction;
406+ /// the `PRAGMA foreign_keys=OFF` *inside* a migration file is a no-op).
407+ #[ test]
408+ fn dropping_parent_with_fk_off_preserves_cascade_child ( ) {
409+ use rusqlite:: Connection ;
410+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
411+ conn. pragma_update ( None , "foreign_keys" , "ON" ) . unwrap ( ) ;
412+ conn. execute_batch (
413+ "CREATE TABLE parent(id TEXT PRIMARY KEY);
414+ CREATE TABLE child(id TEXT PRIMARY KEY,
415+ pid TEXT REFERENCES parent(id) ON DELETE CASCADE);
416+ INSERT INTO parent VALUES('p1');
417+ INSERT INTO child VALUES('c1','p1');" ,
418+ )
419+ . unwrap ( ) ;
420+
421+ // Disable FK enforcement the way the runner does, then rebuild parent.
422+ conn. pragma_update ( None , "foreign_keys" , "OFF" ) . unwrap ( ) ;
423+ conn. execute_batch (
424+ "CREATE TABLE parent_new(id TEXT PRIMARY KEY);
425+ INSERT INTO parent_new SELECT * FROM parent;
426+ DROP TABLE parent;
427+ ALTER TABLE parent_new RENAME TO parent;" ,
428+ )
429+ . unwrap ( ) ;
430+ conn. pragma_update ( None , "foreign_keys" , "ON" ) . unwrap ( ) ;
431+
432+ let n: i64 = conn
433+ . query_row ( "SELECT count(*) FROM child" , [ ] , |r| r. get ( 0 ) )
434+ . unwrap ( ) ;
435+ assert_eq ! (
436+ n, 1 ,
437+ "child must survive a parent table-rebuild when FK enforcement is off"
438+ ) ;
439+ }
440+
441+ /// End-to-end regression for the 005/006 data-loss bug: an
442+ /// `oauth_tokens` row seeded BEFORE the inbound_clients rebuild
443+ /// migrations must survive the upgrade. With FK enforcement left on
444+ /// (pre-fix), `DROP TABLE inbound_clients` cascade-deleted every token.
445+ #[ test]
446+ fn migration_upgrade_preserves_oauth_tokens ( ) {
447+ use rusqlite:: { params, Connection } ;
448+
449+ let conn = Connection :: open_in_memory ( ) . unwrap ( ) ;
450+ conn. pragma_update ( None , "foreign_keys" , "ON" ) . unwrap ( ) ;
451+ let db = Database { conn } ;
452+ db. ensure_migrations_table ( ) . unwrap ( ) ;
453+
454+ // Apply migrations up to v4 (last version before the first
455+ // inbound_clients rebuild at 005), recording each as applied.
456+ const PRE_REBUILD : i64 = 4 ;
457+ for m in MIGRATIONS . iter ( ) . filter ( |m| m. version <= PRE_REBUILD ) {
458+ db. conn . execute_batch ( m. sql ) . unwrap ( ) ;
459+ db. conn
460+ . execute (
461+ "INSERT OR REPLACE INTO schema_migrations (version, name, applied_at) \
462+ VALUES (?1, ?2, datetime('now'))",
463+ params ! [ m. version, m. name] ,
464+ )
465+ . unwrap ( ) ;
466+ }
467+
468+ // Seed an inbound client + an issued OAuth token (child via CASCADE).
469+ db. conn
470+ . execute (
471+ "INSERT INTO inbound_clients
472+ (client_id, registration_type, client_name, redirect_uris,
473+ grant_types, response_types, token_endpoint_auth_method,
474+ created_at, updated_at)
475+ VALUES ('client-1','dcr','Test','[\" http://127.0.0.1/cb\" ]',
476+ '[\" authorization_code\" ]','[\" code\" ]','none',
477+ datetime('now'), datetime('now'))" ,
478+ [ ] ,
479+ )
480+ . unwrap ( ) ;
481+ db. conn
482+ . execute (
483+ "INSERT INTO oauth_tokens
484+ (id, client_id, token_type, token_hash, created_at)
485+ VALUES ('tok-1','client-1','access','deadbeef', datetime('now'))" ,
486+ [ ] ,
487+ )
488+ . unwrap ( ) ;
489+
490+ // Run the remaining migrations (005..=latest) through the real runner.
491+ db. run_migrations ( ) . unwrap ( ) ;
492+
493+ let tokens: i64 = db
494+ . conn
495+ . query_row ( "SELECT count(*) FROM oauth_tokens" , [ ] , |r| r. get ( 0 ) )
496+ . unwrap ( ) ;
497+ assert_eq ! (
498+ tokens, 1 ,
499+ "oauth token must survive the inbound_clients rebuild migrations"
500+ ) ;
501+ // And the client row itself was preserved through the rebuilds.
502+ let clients: i64 = db
503+ . conn
504+ . query_row (
505+ "SELECT count(*) FROM inbound_clients WHERE client_id='client-1'" ,
506+ [ ] ,
507+ |r| r. get ( 0 ) ,
508+ )
509+ . unwrap ( ) ;
510+ assert_eq ! ( clients, 1 , "inbound client must survive the rebuilds" ) ;
511+ }
512+
319513 #[ test]
320514 fn test_persistent_database ( ) {
321515 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments