assay_engine/migrate/
mod.rs1mod copy;
9mod schema;
10mod value;
11
12use anyhow::{Context, Result, bail};
13
14pub use schema::Table;
15
16pub struct Plan {
18 pub source_dir: String,
19 pub target_url: String,
20 pub dry_run: bool,
21}
22
23pub struct Report {
25 pub counts: Vec<TableCount>,
26 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
40pub 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
141fn 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
171async 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#[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
223async 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 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}