simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 3020f927bc3b71cbcf06399a6d29e50b0d958225
parent d06bc806010b43254f0596de9c8b9d8963138827
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 13 Jun 2026 13:11:10 +0200

fix: bump migration version inside the transaction

Version update now happens inside the same BEGIN IMMEDIATE
transaction as the migration SQL. If either fails, both roll back.
Prevents the case where a migration succeeds but the version bump
crashes, causing a non-idempotent migration to re-run.

Pre-transaction SQL (like PRAGMA foreign_keys=OFF) is specified
per-migration in the runner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Mmigrations/0003_strict_tables.sql | 5-----
Msrc/migrations.rs | 53+++++++++++++++++++++++++++++++++++++----------------
2 files changed, 37 insertions(+), 21 deletions(-)

diff --git a/migrations/0003_strict_tables.sql b/migrations/0003_strict_tables.sql @@ -2,9 +2,6 @@ -- STRICT enforces column types at the SQLite level, preventing -- silent type coercion (e.g., inserting a string into an INTEGER column). -PRAGMA foreign_keys=OFF; -BEGIN IMMEDIATE; - -- Drop all triggers first (they reference the old tables) DROP TRIGGER IF EXISTS story_fts_insert; DROP TRIGGER IF EXISTS story_fts_update; @@ -170,5 +167,3 @@ CREATE TRIGGER vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id); END; -COMMIT; -PRAGMA foreign_keys=ON; diff --git a/src/migrations.rs b/src/migrations.rs @@ -1,21 +1,24 @@ use rusqlite::Connection; -const MIGRATIONS: &[&str] = &[ - include_str!("../migrations/0001_initial_schema.sql"), - include_str!("../migrations/0002_email_verification.sql"), - include_str!("../migrations/0003_strict_tables.sql"), +/// Each entry is (pre_sql, migration_sql). +/// pre_sql runs outside the transaction (for PRAGMAs that can't run inside one). +/// migration_sql runs inside BEGIN IMMEDIATE with the version bump. +const MIGRATIONS: &[(&str, &str)] = &[ + ("", include_str!("../migrations/0001_initial_schema.sql")), + ("", include_str!("../migrations/0002_email_verification.sql")), + ( + "PRAGMA foreign_keys=OFF;", + include_str!("../migrations/0003_strict_tables.sql"), + ), ]; -/// Run pending migrations. Each migration manages its own transactions. -/// The runner only wraps the version update in a transaction with the -/// migration, so a failed migration doesn't bump the version. pub fn run(conn: &Connection) -> Result<(), String> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS migration_version (version INTEGER NOT NULL);", ) .map_err(|e| format!("Failed to create migration_version table: {e}"))?; - let mut current_version: i64 = conn + let current_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM migration_version", [], @@ -49,18 +52,36 @@ pub fn run(conn: &Connection) -> Result<(), String> { for i in current_version..total { eprintln!(" Applying migration {}...", i); + let (pre_sql, migration_sql) = MIGRATIONS[i as usize]; - conn.execute_batch(MIGRATIONS[i as usize]).map_err(|e| { - format!("Migration {} failed: {}", i, e) - })?; + // Run pre-transaction statements (PRAGMAs, etc.) + if !pre_sql.is_empty() { + conn.execute_batch(pre_sql) + .map_err(|e| format!("Migration {i} pre-sql failed: {e}"))?; + } + + // Run migration + version bump in one transaction + conn.execute_batch("BEGIN IMMEDIATE;") + .map_err(|e| format!("Migration {i}: failed to begin: {e}"))?; + + if let Err(e) = conn.execute_batch(migration_sql) { + let _ = conn.execute_batch("ROLLBACK;"); + return Err(format!("Migration {i} failed: {e}")); + } - // Update version after each successful migration - conn.execute("UPDATE migration_version SET version = ?1", [i + 1]) - .map_err(|e| format!("Failed to update migration version: {e}"))?; + if let Err(e) = conn.execute("UPDATE migration_version SET version = ?1", [i + 1]) { + let _ = conn.execute_batch("ROLLBACK;"); + return Err(format!("Migration {i}: failed to update version: {e}")); + } - current_version = i + 1; + conn.execute_batch("COMMIT;") + .map_err(|e| format!("Migration {i}: failed to commit: {e}"))?; } - eprintln!("Migrations complete. Database is now at version {}", current_version); + // Restore foreign keys + conn.execute_batch("PRAGMA foreign_keys=ON;") + .map_err(|e| format!("Failed to restore foreign_keys: {e}"))?; + + eprintln!("Migrations complete. Database is now at version {}", total); Ok(()) }