Skip to main content

assay_engine/migrate/
mod.rs

1//! Moving the engine's store from SQLite to Postgres.
2//!
3//! The store holds the workflow runtime, the auth module's users and
4//! keys, and the vault — master KEK included — so all of it is copied
5//! with ids and timestamps preserved. A target that already holds rows
6//! is refused: merging would have to reconcile ids unique to one side.
7
8mod copy;
9mod schema;
10mod value;
11
12use anyhow::{Context, Result, bail};
13
14pub use schema::Table;
15
16/// Where the store is moving from and to.
17pub struct Plan {
18    pub source_dir: String,
19    pub target_url: String,
20    pub dry_run: bool,
21}
22
23/// What a run copied, per table.
24pub struct Report {
25    pub counts: Vec<TableCount>,
26    /// Source tables Postgres has no counterpart for. `engine.lock` is
27    /// the standing case: SQLite serialises single-instance access
28    /// through a table, Postgres through an advisory lock.
29    pub skipped: Vec<TableCount>,
30    pub sequences: usize,
31    pub dry_run: bool,
32}
33
34pub struct TableCount {
35    pub table: Table,
36    pub source: i64,
37    pub target: i64,
38}
39
40/// Accept `sqlite:<dir>`, `sqlite://<dir>` or a bare directory path, so
41/// the flag takes either the config's backend URL or the data directory
42/// an operator can see on the volume.
43pub fn parse_source(raw: &str) -> Result<String> {
44    let dir = raw
45        .strip_prefix("sqlite://")
46        .or_else(|| raw.strip_prefix("sqlite:"))
47        .unwrap_or(raw);
48    if dir.is_empty() {
49        bail!("--from needs a data directory, e.g. sqlite:/var/lib/assay/data");
50    }
51    Ok(dir.to_string())
52}
53
54pub fn parse_target(raw: &str) -> Result<String> {
55    if !raw.starts_with("postgres://") && !raw.starts_with("postgresql://") {
56        bail!("--to must be a postgres:// URL; got {raw}");
57    }
58    Ok(raw.to_string())
59}
60
61pub async fn run(plan: Plan) -> Result<Report> {
62    let source = crate::init::sqlite_pool(&plan.source_dir).await?;
63    let tables = schema::source_tables(&source, crate::init::SQLITE_MODULE_DBS).await?;
64    if tables.is_empty() {
65        bail!(
66            "{} holds no engine tables — check the data directory",
67            plan.source_dir
68        );
69    }
70
71    let target = sqlx::PgPool::connect(&plan.target_url)
72        .await
73        .context("connect to the target postgres")?;
74    refuse_non_empty(&target).await?;
75
76    if plan.dry_run {
77        let mut counts = Vec::new();
78        for table in tables {
79            let source = schema::count_rows_sqlite(&source, &table).await?;
80            counts.push(TableCount {
81                table,
82                source,
83                target: 0,
84            });
85        }
86        return Ok(Report {
87            counts,
88            skipped: Vec::new(),
89            sequences: 0,
90            dry_run: true,
91        });
92    }
93
94    prepare_target(&target, &modules_holding_tables(&tables)).await?;
95
96    let mut migratable = Vec::new();
97    let mut skipped = Vec::new();
98    for table in tables {
99        if schema::target_columns(&target, &table).await?.is_empty() {
100            let source = schema::count_rows_sqlite(&source, &table).await?;
101            skipped.push(TableCount {
102                table,
103                source,
104                target: 0,
105            });
106        } else {
107            migratable.push(table);
108        }
109    }
110
111    let ordered = schema::insert_order(&target, &migratable).await?;
112    clear_bootstrap_rows(&target, &ordered).await?;
113    for table in &ordered {
114        copy::copy_table(&source, &target, table).await?;
115    }
116    let sequences = schema::resync_sequences(&target, &ordered).await?;
117
118    let mut counts = Vec::new();
119    for table in &ordered {
120        let source_rows = schema::count_rows_sqlite(&source, table).await?;
121        let target_rows = schema::count_rows_pg(&target, table).await?;
122        if source_rows != target_rows {
123            bail!("{table}: copied {target_rows} rows but the source holds {source_rows}");
124        }
125        counts.push(TableCount {
126            table: table.clone(),
127            source: source_rows,
128            target: target_rows,
129        });
130    }
131    counts.sort_by_key(|c| c.table.to_string());
132
133    Ok(Report {
134        counts,
135        skipped,
136        sequences,
137        dry_run: false,
138    })
139}
140
141/// Modules that have tables in the source. Deliberately not
142/// `engine.modules`: a module disabled there can still hold rows from
143/// when it was on, which the manifest would leave behind.
144fn modules_holding_tables(tables: &[Table]) -> Vec<String> {
145    let mut modules: Vec<String> = tables
146        .iter()
147        .map(|t| t.schema.clone())
148        .collect::<std::collections::HashSet<_>>()
149        .into_iter()
150        .collect();
151    modules.sort();
152    modules
153}
154
155async fn refuse_non_empty(target: &sqlx::PgPool) -> Result<()> {
156    let occupied = schema::non_empty_tables(target, crate::init::SQLITE_MODULE_DBS).await?;
157    if occupied.is_empty() {
158        return Ok(());
159    }
160    let listed = occupied
161        .iter()
162        .map(|(table, rows)| format!("  {table} ({rows} rows)"))
163        .collect::<Vec<_>>()
164        .join("\n");
165    bail!(
166        "the target already holds engine data and will not be migrated into:\n{listed}\n\
167         Point --to at an empty database."
168    );
169}
170
171/// Create every schema and table the target needs, exactly as a first
172/// boot would, so the copy has somewhere to land.
173async fn prepare_target(target: &sqlx::PgPool, modules: &[String]) -> Result<()> {
174    let schema = assay_domain::engine::PgEngineSchema::new(target.clone());
175    schema
176        .migrate()
177        .await
178        .map_err(|e| anyhow::anyhow!("engine schema migrate: {e}"))?;
179
180    let mut tx = target.begin().await.context("begin schema tx")?;
181    assay_domain::engine::acquire_schema_lock(&mut tx).await?;
182    for name in modules {
183        sqlx::query(&format!("CREATE SCHEMA IF NOT EXISTS {name}"))
184            .execute(&mut *tx)
185            .await
186            .with_context(|| format!("create schema {name}"))?;
187    }
188    tx.commit().await.context("commit schema tx")?;
189
190    if modules.iter().any(|m| m == "workflow") {
191        assay_workflow::PostgresStore::from_pool(target.clone())
192            .await
193            .map_err(|e| anyhow::anyhow!("workflow schema migrate: {e}"))?;
194    }
195    if modules.iter().any(|m| m == "auth") {
196        assay_auth::schema::migrate_postgres(target)
197            .await
198            .context("auth schema migrate")?;
199    }
200    if modules.iter().any(|m| m == "vault") {
201        prepare_vault(target).await?;
202    }
203    Ok(())
204}
205
206#[cfg(feature = "vault")]
207async fn prepare_vault(target: &sqlx::PgPool) -> Result<()> {
208    assay_vault::schema::migrate_postgres(target)
209        .await
210        .context("vault schema migrate")
211}
212
213/// Refusing here rather than skipping: the vault holds the store's
214/// secrets, and a build without it cannot carry them.
215#[cfg(not(feature = "vault"))]
216async fn prepare_vault(_target: &sqlx::PgPool) -> Result<()> {
217    bail!(
218        "the source store holds vault tables but this binary was \
219         built without vault support; migrate with a build that has it"
220    )
221}
222
223/// Empty the rows the schema bootstrap seeded — the `main` namespace and
224/// the migration ledger — so the source's own copies land on a clean
225/// table and the row counts can be compared.
226async fn clear_bootstrap_rows(target: &sqlx::PgPool, tables: &[Table]) -> Result<()> {
227    let list = tables
228        .iter()
229        .map(schema::quoted)
230        .collect::<Vec<_>>()
231        .join(", ");
232    sqlx::query(&format!("TRUNCATE {list} RESTART IDENTITY CASCADE"))
233        .execute(target)
234        .await
235        .context("clear the freshly created target tables")?;
236    Ok(())
237}
238
239impl Report {
240    /// Per-table counts, widest column first so the output lines up.
241    pub fn render(&self) -> String {
242        let width = self
243            .counts
244            .iter()
245            .map(|c| c.table.to_string().len())
246            .max()
247            .unwrap_or(5)
248            .max(5);
249        let mut out = String::new();
250        let header = if self.dry_run { "rows" } else { "copied" };
251        out.push_str(&format!("{:<width$}  {:>10}\n", "table", header));
252        out.push_str(&format!("{}  {}\n", "-".repeat(width), "-".repeat(10)));
253        let mut total = 0;
254        for count in &self.counts {
255            let rows = if self.dry_run { count.source } else { count.target };
256            total += rows;
257            out.push_str(&format!(
258                "{:<width$}  {rows:>10}\n",
259                count.table.to_string()
260            ));
261        }
262        out.push_str(&format!("{}  {}\n", "-".repeat(width), "-".repeat(10)));
263        out.push_str(&format!("{:<width$}  {total:>10}\n", "total"));
264        for skip in &self.skipped {
265            out.push_str(&format!(
266                "\nskipped {} ({} rows): no Postgres counterpart.\n",
267                skip.table, skip.source
268            ));
269        }
270        if self.dry_run {
271            out.push_str(
272                "\nDry run: nothing was written. Source tables with no Postgres \
273                 counterpart are skipped on the real run.\n",
274            );
275        } else {
276            out.push_str(&format!(
277                "\n{} sequences re-pointed past the copied ids.\n",
278                self.sequences
279            ));
280        }
281        out
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn source_accepts_a_url_or_a_bare_directory() {
291        assert_eq!(parse_source("sqlite:/var/lib/assay").unwrap(), "/var/lib/assay");
292        assert_eq!(parse_source("sqlite:///var/lib/assay").unwrap(), "/var/lib/assay");
293        assert_eq!(parse_source("./data").unwrap(), "./data");
294    }
295
296    #[test]
297    fn target_must_be_postgres() {
298        let err = parse_target("sqlite:/tmp/other").unwrap_err();
299        assert!(err.to_string().contains("postgres://"), "{err}");
300    }
301}