use anyhow::{anyhow, Context, Result};
use rusqlite::{params, Connection};
pub use ulid::Ulid;
pub mod m001_node_id_ulid;
pub mod m002_registry_id_ulid;
pub mod m003_bundle_installs_fk_relax;
pub trait Migration: Sync {
fn id(&self) -> &'static str;
fn description(&self) -> &'static str;
fn up(&self, conn: &Connection) -> Result<()>;
}
pub static ALL: &[&'static dyn Migration] = &[
&m001_node_id_ulid::MIGRATION,
&m002_registry_id_ulid::MIGRATION,
&m003_bundle_installs_fk_relax::MIGRATION,
];
pub struct Runner<'a> {
conn: &'a Connection,
}
impl<'a> Runner<'a> {
pub fn new(conn: &'a Connection) -> Self {
Self { conn }
}
pub fn ensure_table(&self) -> Result<()> {
self.conn
.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
description TEXT NOT NULL,
applied_at INTEGER NOT NULL
);",
)
.context("create schema_migrations")?;
Ok(())
}
pub fn status(&self) -> Result<Status> {
self.ensure_table()?;
let mut stmt = self.conn.prepare(
"SELECT version, description, applied_at FROM schema_migrations \
ORDER BY applied_at",
)?;
let applied: Vec<AppliedRow> = stmt
.query_map([], |r| {
Ok(AppliedRow {
version: r.get(0)?,
description: r.get(1)?,
applied_at: r.get(2)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
let applied_ids: std::collections::HashSet<String> =
applied.iter().map(|r| r.version.clone()).collect();
let pending: Vec<&'static dyn Migration> = ALL
.iter()
.copied()
.filter(|m| !applied_ids.contains(m.id()))
.collect();
Ok(Status { applied, pending })
}
pub fn up(&self, target: Option<&str>) -> Result<Vec<AppliedNow>> {
let status = self.status()?;
if let Some(t) = target {
if !ALL.iter().any(|m| m.id() == t) {
return Err(anyhow!("unknown migration: {t}"));
}
}
let mut applied_now = Vec::new();
for m in status.pending {
self.apply_one(m)?;
applied_now.push(AppliedNow {
version: m.id().to_string(),
description: m.description().to_string(),
});
if target.is_some_and(|t| t == m.id()) {
break;
}
}
Ok(applied_now)
}
pub fn apply(&self, id: &str) -> Result<()> {
let m = ALL
.iter()
.copied()
.find(|m| m.id() == id)
.ok_or_else(|| anyhow!("unknown migration: {id}"))?;
let already = self.status()?.applied.iter().any(|r| r.version == id);
if already {
return Err(anyhow!("migration already applied: {id}"));
}
self.apply_one(m)
}
fn apply_one(&self, m: &'static dyn Migration) -> Result<()> {
self.conn
.execute_batch("PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;")
.context("open migration transaction")?;
let res: Result<()> = (|| {
m.up(self.conn)
.with_context(|| format!("migration {} body", m.id()))?;
self.conn
.execute(
"INSERT INTO schema_migrations(version, description, applied_at) \
VALUES (?1, ?2, ?3)",
params![m.id(), m.description(), epoch_ms()?],
)
.with_context(|| format!("record schema_migrations row for {}", m.id()))?;
self.conn
.execute_batch("PRAGMA foreign_keys = ON;")
.context("re-enable foreign_keys")?;
let mut stmt = self.conn.prepare("PRAGMA foreign_key_check;")?;
let violations: Vec<(String, i64, String, i64)> = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, i64>(3)?,
))
})?
.collect::<rusqlite::Result<_>>()?;
if !violations.is_empty() {
return Err(anyhow!(
"foreign_key_check violations after {}: {:?}",
m.id(),
violations
));
}
Ok(())
})();
match res {
Ok(()) => {
self.conn.execute("COMMIT", [])?;
Ok(())
}
Err(e) => {
let _ = self.conn.execute("ROLLBACK", []);
Err(e.context(format!("migration {} aborted; rolled back", m.id())))
}
}
}
}
fn epoch_ms() -> Result<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
let d = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system time")?;
Ok(d.as_millis() as i64)
}
#[derive(Clone)]
pub struct Status {
pub applied: Vec<AppliedRow>,
pub pending: Vec<&'static dyn Migration>,
}
impl std::fmt::Debug for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Status")
.field("applied", &self.applied)
.field("pending_ids", &self.pending.iter().map(|m| m.id()).collect::<Vec<_>>())
.finish()
}
}
#[derive(Debug, Clone)]
pub struct AppliedRow {
pub version: String,
pub description: String,
pub applied_at: i64,
}
#[derive(Debug, Clone)]
pub struct AppliedNow {
pub version: String,
pub description: String,
}
pub(crate) use Ulid as MigrationUlid;
#[cfg(test)]
mod tests {
use super::*;
fn seed_v0_6(conn: &Connection) {
conn.execute_batch(
r#"
CREATE TABLE type_registry (
name TEXT PRIMARY KEY,
kind TEXT NOT NULL,
schema_json TEXT,
severity_allowed TEXT
);
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL REFERENCES type_registry(name),
sot_ref TEXT, confidence REAL, applicability TEXT,
last_verified_at INTEGER, review_due INTEGER,
version INTEGER NOT NULL DEFAULT 1,
prev_id TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
);
CREATE TABLE edges (
id TEXT PRIMARY KEY,
src_node TEXT NOT NULL REFERENCES nodes(id),
tgt_node TEXT NOT NULL REFERENCES nodes(id),
kind TEXT NOT NULL REFERENCES type_registry(name),
severity TEXT,
metadata TEXT NOT NULL DEFAULT '{}',
version INTEGER NOT NULL DEFAULT 1,
prev_id TEXT
);
CREATE TABLE versions (
target_kind TEXT NOT NULL,
target_id TEXT NOT NULL,
version INTEGER NOT NULL,
diff TEXT NOT NULL DEFAULT '{}',
ts INTEGER NOT NULL,
author TEXT,
PRIMARY KEY (target_kind, target_id, version)
);
CREATE TABLE specifications (
name TEXT PRIMARY KEY,
expr_json TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE projections (
name TEXT PRIMARY KEY,
spec_ref TEXT NOT NULL,
template TEXT NOT NULL,
target_form TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT 0,
template_engine TEXT,
projection_kind TEXT,
projection_config TEXT
);
INSERT INTO type_registry(name, kind) VALUES
('persona', 'node'), ('outline_node', 'node'), ('routes_to', 'edge');
INSERT INTO nodes(id, type, version) VALUES
('alpha', 'persona', 1),
('alpha.active', 'outline_node', 1);
INSERT INTO edges(id, src_node, tgt_node, kind, version) VALUES
('e.alpha.active', 'alpha', 'alpha.active', 'routes_to', 1);
INSERT INTO specifications(name, expr_json) VALUES
('active_personas', '{"TypeIs":"persona"}');
INSERT INTO projections(name, spec_ref, template, target_form) VALUES
('alpha.section.active', 'active_personas', '## {{name}}', 'markdown');
"#,
)
.unwrap();
}
#[test]
fn runner_up_applies_all_pending_then_skips_on_rerun() {
let conn = Connection::open_in_memory().unwrap();
seed_v0_6(&conn);
let runner = Runner::new(&conn);
let s = runner.status().unwrap();
assert_eq!(s.applied.len(), 0);
assert_eq!(s.pending.len(), 3);
let now = runner.up(None).unwrap();
assert_eq!(now.len(), 3);
let s2 = runner.status().unwrap();
assert_eq!(s2.applied.len(), 3);
assert!(s2.pending.is_empty());
let now2 = runner.up(None).unwrap();
assert!(now2.is_empty());
let bad_nodes: i64 = conn
.query_row("SELECT COUNT(*) FROM nodes WHERE length(id) != 26", [], |r| r.get(0))
.unwrap();
assert_eq!(bad_nodes, 0);
let bad_specs: i64 = conn
.query_row("SELECT COUNT(*) FROM specifications WHERE length(id) != 26", [], |r| r.get(0))
.unwrap();
assert_eq!(bad_specs, 0);
}
#[test]
fn runner_apply_specific_id_then_repeat_errors() {
let conn = Connection::open_in_memory().unwrap();
seed_v0_6(&conn);
let runner = Runner::new(&conn);
runner.apply("001_node_id_ulid").unwrap();
let s = runner.status().unwrap();
assert_eq!(s.applied.len(), 1);
assert_eq!(s.pending.len(), 2);
assert!(runner.apply("001_node_id_ulid").is_err());
assert!(runner.apply("999_nope").is_err());
}
#[test]
fn runner_target_stops_at_named_migration() {
let conn = Connection::open_in_memory().unwrap();
seed_v0_6(&conn);
let runner = Runner::new(&conn);
let now = runner.up(Some("001_node_id_ulid")).unwrap();
assert_eq!(now.len(), 1);
assert_eq!(now[0].version, "001_node_id_ulid");
let s = runner.status().unwrap();
assert_eq!(s.pending.len(), 2);
assert_eq!(s.pending[0].id(), "002_registry_id_ulid");
assert_eq!(s.pending[1].id(), "003_bundle_installs_fk_relax");
}
}