#![cfg(feature = "migrate")]
use std::collections::BTreeMap;
use boatramp_core::sql::{
LedgerOrigin, MigrateDdlError, MigrationAction, MigrationStep, MigrationSubstrate,
SubstrateStepOutcome,
};
use boatramp_node::config::ExternalDatabaseConfig;
use boatramp_node::managed_sql::LibsqlMigrationRunner;
const DB: &str = "app";
fn sql_step(id: &str, script: &str) -> MigrationStep {
MigrationStep {
id: id.to_string(),
action: MigrationAction::Sql {
script: script.to_string(),
no_transaction: false,
},
}
}
fn ext_step(id: &str, name: &str) -> MigrationStep {
MigrationStep {
id: id.to_string(),
action: MigrationAction::Extension {
name: name.to_string(),
},
}
}
fn runner_for() -> (LibsqlMigrationRunner, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"boatramp-migrate-libsql-live-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("app.db");
let mut databases = BTreeMap::new();
databases.insert(
DB.to_string(),
ExternalDatabaseConfig {
kind: "libsql".to_string(),
path: Some(path.clone()),
..Default::default()
},
);
(LibsqlMigrationRunner::new(databases), path)
}
async fn apply(
sub: &LibsqlMigrationRunner,
step: &MigrationStep,
ordinal: usize,
) -> SubstrateStepOutcome {
let eff = step.content_hash();
sub.apply_substrate_step("default", DB, step, ordinal, &eff)
.await
.expect("substrate step (infra)")
}
fn applied_ids(applied: &[boatramp_core::sql::AppliedMigration]) -> Vec<String> {
applied.iter().map(|a| a.id.clone()).collect()
}
async fn table_exists(path: &std::path::Path, table: &str) -> bool {
use boatramp_core::sql::SqlBackend;
let db = boatramp_storage::LibsqlSql::open_local(path).await.unwrap();
let rows = db
.run_query(&format!(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='{table}'"
))
.await
.unwrap();
matches!(
rows.rows.first().and_then(|r| r.first()),
Some(boatramp_core::sql::SqlValue::Integer(n)) if *n == 1
)
}
#[tokio::test]
async fn migrate_substrate_libsql_parity_on_a_real_embedded_file() {
let (sub, path) = runner_for();
let applied = sub.preflight("default", DB).await.unwrap();
assert!(applied.is_empty(), "empty ledger");
assert!(
table_exists(&path, "boatramp_migrations_schema_migrations").await,
"preflight created the reserved libsql ledger table"
);
let s1 = sql_step(
"0001_widget",
"CREATE TABLE widget (id integer primary key, name text)",
);
let s2 = sql_step("0002_seed", "INSERT INTO widget (id, name) VALUES (1, 'a')");
assert!(matches!(
apply(&sub, &s1, 0).await,
SubstrateStepOutcome::Applied
));
assert!(matches!(
apply(&sub, &s2, 1).await,
SubstrateStepOutcome::Applied
));
let applied = sub.preflight("default", DB).await.unwrap();
assert_eq!(applied_ids(&applied), vec!["0001_widget", "0002_seed"]);
assert!(
applied.iter().all(|a| a.origin == "apply"),
"applied rows carry origin=apply"
);
assert_eq!(
applied[0].content_hash,
s1.content_hash(),
"the recorded hash is the effective hash the orchestrator supplied (immutability signal)"
);
let atomic = sql_step(
"0003_atomic",
"CREATE TABLE gadget (id integer primary key); \
CREATE TABLE gadget (id integer primary key)", );
match apply(&sub, &atomic, 2).await {
SubstrateStepOutcome::Failed(_) => {}
other => panic!("expected a failed step for the mid-script error, got {other:?}"),
}
let after = sub.preflight("default", DB).await.unwrap();
assert!(
!after.iter().any(|a| a.id == "0003_atomic"),
"a rolled-back step must not be recorded"
);
assert!(
!table_exists(&path, "gadget").await,
"the 1st DDL of a failed multi-statement step is ROLLED BACK on libsql (transactional DDL)"
);
let fix = sql_step(
"0003_atomic",
"CREATE TABLE gadget (id integer primary key)",
);
assert!(
matches!(apply(&sub, &fix, 2).await, SubstrateStepOutcome::Applied),
"retry after a clean rollback applies (would fail if the 1st CREATE had leaked)"
);
match apply(&sub, &ext_step("0004_ext", "spellfix"), 3).await {
SubstrateStepOutcome::Failed(msg) => assert!(
msg.contains("libsql") || msg.contains("SQLite"),
"an extension step is refused on libsql: {msg}"
),
other => panic!("expected extension step refused on libsql, got {other:?}"),
}
assert!(matches!(
apply(
&sub,
&sql_step("0004_rawext", "CREATE EXTENSION IF NOT EXISTS whatever"),
3
)
.await,
SubstrateStepOutcome::Failed(_)
));
match apply(
&sub,
&sql_step("0004_txn", "BEGIN; CREATE TABLE sneaky (x integer); COMMIT"),
3,
)
.await
{
SubstrateStepOutcome::Failed(msg) => assert!(
msg.contains("BEGIN") || msg.contains("transaction"),
"a transactional sql step may not carry its own BEGIN/COMMIT: {msg}"
),
other => panic!("expected txn-control refusal, got {other:?}"),
}
assert!(!table_exists(&path, "sneaky").await);
assert!(matches!(
apply(
&sub,
&sql_step(
"0004_ledger",
"INSERT INTO boatramp_migrations_schema_migrations (id) VALUES ('x')"
),
3
)
.await,
SubstrateStepOutcome::Failed(_)
));
let hidden_commit = sql_step(
"0004_nestcomment",
"CREATE TABLE victim (x integer); /* a /* b */ COMMIT -- */",
);
match apply(&sub, &hidden_commit, 3).await {
SubstrateStepOutcome::Failed(_) => {}
other => {
panic!("a nested-comment-hidden COMMIT must be REFUSED (txn control), got {other:?}")
}
}
assert!(
!table_exists(&path, "victim").await,
"a refused nested-comment step must never run its CREATE (the hidden COMMIT is flagged)"
);
let ddl = sub.owner_ddl("default", DB).await.unwrap();
assert!(matches!(
ddl.exec("SELECT * FROM boatramp_migrations_schema_migrations")
.await
.unwrap_err(),
MigrateDdlError::LedgerProtected
));
assert!(matches!(
ddl.exec("DROP TABLE `boatramp_migrations_schema_migrations`")
.await
.unwrap_err(),
MigrateDdlError::LedgerProtected
));
assert!(matches!(
ddl.exec("BEGIN; CREATE TABLE x(i integer); COMMIT")
.await
.unwrap_err(),
MigrateDdlError::TxnControl
));
assert!(matches!(
ddl.exec("DROP TABLE IF EXISTS y; COMMIT -- sneak")
.await
.unwrap_err(),
MigrateDdlError::TxnControl
));
assert!(matches!(
ddl.exec("/* a /* b */ COMMIT -- */").await.unwrap_err(),
MigrateDdlError::TxnControl
));
assert!(matches!(
ddl.exec("/* a /* b */ DROP TABLE boatramp_migrations_schema_migrations -- */")
.await
.unwrap_err(),
MigrateDdlError::LedgerProtected
));
ddl.exec("CREATE TABLE IF NOT EXISTS owner_made (n integer)")
.await
.unwrap();
ddl.exec("INSERT INTO owner_made (n) VALUES (7)")
.await
.unwrap();
let rows = ddl
.query("SELECT n FROM owner_made ORDER BY n")
.await
.unwrap();
assert_eq!(
rows.rows.len(),
1,
"the host-mediated seam sees the row it just wrote"
);
let baselined = sql_step("0007_baselined", "CREATE TABLE baselined (id integer)");
sub.record(
"default",
DB,
&baselined,
3,
&baselined.content_hash(),
LedgerOrigin::Baseline,
)
.await
.unwrap();
let after = sub.preflight("default", DB).await.unwrap();
let row = after
.iter()
.find(|a| a.id == "0007_baselined")
.expect("baselined row present");
assert_eq!(row.origin, "baseline", "a baselined row is marked as such");
assert!(
!table_exists(&path, "baselined").await,
"baseline records without running the step"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
println!("MIGRATE LIBSQL PARITY OK");
}