Skip to main content

tga_core/db/
migrations.rs

1//! Versioned SQL migrations.
2//!
3//! Migrations are stored as a static list of `(version, name, sql)` tuples
4//! and applied in order. Each migration is wrapped in a transaction along
5//! with the corresponding row insert into `schema_migrations`, so partial
6//! application is impossible.
7//!
8//! Adding a new migration:
9//! 1. Append a new entry to [`MIGRATIONS`] with a strictly increasing version.
10//! 2. Never edit an existing migration in place — write a follow-up migration.
11
12use rusqlite::Connection;
13use tracing::{debug, info};
14
15use crate::errors::{Result, TgaError};
16
17/// A single migration step.
18pub struct Migration {
19    /// Strictly increasing version number; must be unique.
20    pub version: i64,
21    /// Human-readable label, recorded for audit/debugging.
22    pub name: &'static str,
23    /// The SQL to execute. May contain multiple statements separated by `;`.
24    pub sql: &'static str,
25}
26
27/// All migrations known to this binary, in order of application.
28pub const MIGRATIONS: &[Migration] = &[Migration {
29    version: 1,
30    name: "initial_schema",
31    sql: include_str!("sql/0001_initial_schema.sql"),
32}];
33
34/// Ensure the `schema_migrations` bookkeeping table exists.
35fn ensure_migrations_table(conn: &Connection) -> Result<()> {
36    conn.execute_batch(
37        "CREATE TABLE IF NOT EXISTS schema_migrations ( \
38            version    INTEGER PRIMARY KEY, \
39            name       TEXT NOT NULL, \
40            applied_at TEXT NOT NULL \
41        );",
42    )?;
43    Ok(())
44}
45
46/// Return the highest applied migration version, or 0 if none have been applied.
47fn current_version(conn: &Connection) -> Result<i64> {
48    let v: Option<i64> = conn
49        .query_row(
50            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
51            [],
52            |row| row.get(0),
53        )
54        .map_err(TgaError::from)?;
55    Ok(v.unwrap_or(0))
56}
57
58/// Apply all migrations whose version is greater than the current schema version.
59///
60/// Idempotent: running it twice in a row is a no-op the second time.
61///
62/// # Errors
63///
64/// Returns [`TgaError::MigrationError`] if a migration's SQL fails. The
65/// transaction guarantees partial application cannot occur.
66pub fn run(conn: &mut Connection) -> Result<()> {
67    ensure_migrations_table(conn)?;
68    let current = current_version(conn)?;
69    debug!(current_version = current, "running migrations");
70
71    for m in MIGRATIONS {
72        if m.version <= current {
73            continue;
74        }
75        info!(version = m.version, name = m.name, "applying migration");
76        let tx = conn.transaction().map_err(TgaError::from)?;
77        tx.execute_batch(m.sql).map_err(|e| {
78            TgaError::MigrationError(format!("migration {} ({}) failed: {e}", m.version, m.name))
79        })?;
80        tx.execute(
81            "INSERT INTO schema_migrations(version, name, applied_at) VALUES (?1, ?2, ?3)",
82            rusqlite::params![m.version, m.name, chrono::Utc::now().to_rfc3339()],
83        )
84        .map_err(TgaError::from)?;
85        tx.commit().map_err(TgaError::from)?;
86    }
87    Ok(())
88}