use sqlx::Row;
use sqlx::postgres::PgPool;
#[derive(Debug, Clone)]
pub struct PgMigration {
pub version: u32,
pub description: &'static str,
pub sql: &'static str,
}
pub static BUILTIN_PG_MIGRATIONS: &[PgMigration] = &[
PgMigration {
version: 1,
description: "Initial schema: tasks table with indexes",
sql: "\
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
context_id TEXT NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);\
CREATE INDEX IF NOT EXISTS idx_tasks_context_id ON tasks(context_id);\
CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state)",
},
PgMigration {
version: 2,
description: "Add composite index on (context_id, state) for combined filter queries",
sql: "CREATE INDEX IF NOT EXISTS idx_tasks_context_id_state ON tasks(context_id, state)",
},
PgMigration {
version: 3,
description: "Add (updated_at, id) index for most-recently-updated-first list ordering",
sql: "CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks(updated_at DESC, id DESC)",
},
PgMigration {
version: 4,
description: "Add idempotency_keys: the index client-supplied send keys are claimed in",
sql: super::postgres_store::idempotency::CREATE_TABLE_SQL,
},
PgMigration {
version: 5,
description: "Add task_events: the per-task log of what the agent emitted",
sql: super::postgres_store::event_log::CREATE_TABLE_SQL,
},
PgMigration {
version: 6,
description: "Constrain task_events.seq to be positive",
sql: super::postgres_store::event_log::ADD_SEQ_POSITIVE_SQL,
},
];
#[derive(Debug, Clone)]
pub struct PgMigrationRunner {
pool: PgPool,
migrations: &'static [PgMigration],
}
impl PgMigrationRunner {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self {
pool,
migrations: BUILTIN_PG_MIGRATIONS,
}
}
#[must_use]
pub const fn with_migrations(pool: PgPool, migrations: &'static [PgMigration]) -> Self {
Self { pool, migrations }
}
async fn ensure_version_table(&self) -> Result<(), sqlx::Error> {
super::pg_schema::apply(
&self.pool,
&["CREATE TABLE IF NOT EXISTS schema_versions (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)"],
)
.await
}
pub async fn current_version(&self) -> Result<u32, sqlx::Error> {
self.ensure_version_table().await?;
let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
.fetch_one(&self.pool)
.await?;
let version: i32 = row.get("v");
#[allow(clippy::cast_sign_loss)]
Ok(version as u32)
}
pub async fn pending_migrations(&self) -> Result<Vec<&PgMigration>, sqlx::Error> {
let current = self.current_version().await?;
Ok(self
.migrations
.iter()
.filter(|m| m.version > current)
.collect())
}
pub async fn run_pending(&self) -> Result<Vec<u32>, sqlx::Error> {
self.ensure_version_table().await?;
let current = self.current_version().await?;
let mut applied = Vec::new();
for migration in self.migrations {
if migration.version <= current {
continue;
}
let mut tx = self.pool.begin().await?;
super::pg_schema::lock(&mut tx).await?;
sqlx::query("LOCK TABLE schema_versions IN EXCLUSIVE MODE")
.execute(&mut *tx)
.await?;
let row = sqlx::query("SELECT COALESCE(MAX(version), 0) AS v FROM schema_versions")
.fetch_one(&mut *tx)
.await?;
let current_in_tx: i32 = row.get("v");
#[allow(clippy::cast_sign_loss)]
if migration.version <= current_in_tx as u32 {
tx.rollback().await?;
continue;
}
for statement in migration.sql.split(';') {
let trimmed = statement.trim();
if trimmed.is_empty() {
continue;
}
sqlx::query(trimmed).execute(&mut *tx).await?;
}
#[allow(clippy::cast_possible_wrap)] let version_i32 = migration.version as i32;
sqlx::query("INSERT INTO schema_versions (version, description) VALUES ($1, $2)")
.bind(version_i32)
.bind(migration.description)
.execute(&mut *tx)
.await?;
tx.commit().await?;
applied.push(migration.version);
}
Ok(applied)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_module_doc_table_lists_every_migration() {
let doc = include_str!("pg_migration.rs");
for migration in BUILTIN_PG_MIGRATIONS {
let row = format!("//! | {} |", migration.version);
assert!(
doc.contains(&row),
"the module doc table has no row for migration {}",
migration.version
);
}
let rows = doc
.lines()
.filter(|l| l.starts_with("//! | ") && !l.starts_with("//! | Version"))
.count();
assert_eq!(
rows,
BUILTIN_PG_MIGRATIONS.len(),
"the doc table and BUILTIN_PG_MIGRATIONS disagree on how many migrations there are"
);
}
#[test]
fn migration_versions_are_unique_and_ascending() {
let versions: Vec<u32> = BUILTIN_PG_MIGRATIONS.iter().map(|m| m.version).collect();
let mut sorted = versions.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
versions, sorted,
"run_pending applies these in slice order and records MAX(version); \
a duplicate or an out-of-order entry silently never runs"
);
}
}