Postgres, step 4: admin copy-db moves a SQLite hub's records into Postgres 4e2cad00a484
oxygenhub admin copy-db --from <old>/hub.db, in a postgres build, copies a stopped SQLite hub into this hub's empty database: every table in the order the schema made them (after what they point at), ids kept, a fork's parent filled in once every repository is there, rows streamed in batches of 500, all in one transaction. Then each id sequence moves past the copied ids, the search index is copied, and every table's count is checked before commit. It refuses a hub.db at another migration and a database with rows. tests/schema.rs copies a small hub (flags, names without case, a fork before its parent, a blob, a search entry) and checks each, and that new ids follow on. Also formats tests/schema.rs, which failed Flow run 315's fmt. Refs #211
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 13:17 UTC
- Signature
- ✔ signed by Claude Code
- Hash
4e2cad00a484ec04fc5edd10a0af8637f0ae6490552573304680eaed36906a89- Depends on
- 25ab8c73514a 3b1bc27fe805 42fa95540468 d23464c0ac60 d7d182467382
- Depended on by
- 1c52d940b4a0 dc71378db1dc
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 1 tree operation, 5 files
Browse the tree at this change
added crates/hub/src/db/copy.rs +222 −0
| 1 | + | //! Copy a SQLite hub's records into PostgreSQL (#211), for moving a hub: |
|
| 2 | + | //! `oxygenhub admin copy-db --from <old data>/hub.db` on the new hub, built |
|
| 3 | + | //! with the `postgres` feature and migrated, before it first serves. |
|
| 4 | + | //! |
|
| 5 | + | //! Everything is copied in one transaction, ids kept, so a failed copy |
|
| 6 | + | //! leaves the database as it was. The tables go in the order the schema |
|
| 7 | + | //! made them, which is after every table they point at; a column pointing |
|
| 8 | + | //! at its own table (a fork's parent) is filled in once all its rows are |
|
| 9 | + | //! there. Then each id sequence is moved past the copied ids, the search |
|
| 10 | + | //! index is copied, and every table's count is checked against SQLite's. |
|
| 11 | + | ||
| 12 | + | use super::Pool; |
|
| 13 | + | use anyhow::{bail, Context, Result}; |
|
| 14 | + | use sqlx::sqlite::{SqliteConnectOptions, SqliteRow}; |
|
| 15 | + | use sqlx::{Connection, Row}; |
|
| 16 | + | use std::path::Path; |
|
| 17 | + | ||
| 18 | + | /// Rows sent in one INSERT: well under Postgres's 65535 parameters for the |
|
| 19 | + | /// widest table. |
|
| 20 | + | const BATCH: usize = 500; |
|
| 21 | + | ||
| 22 | + | /// One column of a table here, and how to carry its values across. |
|
| 23 | + | struct Col { |
|
| 24 | + | name: String, |
|
| 25 | + | kind: Kind, |
|
| 26 | + | /// Points at its own table: copied after the rows. |
|
| 27 | + | self_ref: bool, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | #[derive(Clone, Copy, PartialEq)] |
|
| 31 | + | enum Kind { |
|
| 32 | + | Int, |
|
| 33 | + | Bool, |
|
| 34 | + | Real, |
|
| 35 | + | Blob, |
|
| 36 | + | Text, |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | enum Value { |
|
| 40 | + | Int(Option<i64>), |
|
| 41 | + | Bool(Option<bool>), |
|
| 42 | + | Real(Option<f64>), |
|
| 43 | + | Blob(Option<Vec<u8>>), |
|
| 44 | + | Text(Option<String>), |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// What was copied: `(table, rows)`, in copy order. |
|
| 48 | + | pub async fn from_sqlite(pool: &Pool, path: &Path) -> Result<Vec<(String, i64)>> { |
|
| 49 | + | let options = SqliteConnectOptions::new().filename(path).read_only(true); |
|
| 50 | + | let mut lite = sqlx::SqliteConnection::connect_with(&options).await.with_context(|| format!("open {}", path.display()))?; |
|
| 51 | + | up_to_date(&mut lite).await?; |
|
| 52 | + | let tables: Vec<String> = sqlx::query_scalar( |
|
| 53 | + | "SELECT CAST(relname AS TEXT) FROM pg_class WHERE relkind = 'r' AND relnamespace = CAST(current_schema() AS regnamespace) \ |
|
| 54 | + | AND relname NOT IN ('_sqlx_migrations', 'search') ORDER BY oid", |
|
| 55 | + | ) |
|
| 56 | + | .fetch_all(pool) |
|
| 57 | + | .await?; |
|
| 58 | + | for t in &tables { |
|
| 59 | + | let any: bool = sqlx::query_scalar(&format!("SELECT EXISTS (SELECT 1 FROM \"{t}\")")).fetch_one(pool).await?; |
|
| 60 | + | if any { |
|
| 61 | + | bail!("{t} already has rows here: copy into a new, empty database"); |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | let mut tx = pool.begin().await?; |
|
| 65 | + | let mut copied = Vec::new(); |
|
| 66 | + | for t in &tables { |
|
| 67 | + | let cols = columns(&mut tx, t).await?; |
|
| 68 | + | let n = copy_table(&mut lite, &mut tx, t, &cols).await.with_context(|| format!("copy {t}"))?; |
|
| 69 | + | copied.push((t.clone(), n)); |
|
| 70 | + | } |
|
| 71 | + | // Ids carry on after the copied ones. |
|
| 72 | + | let numbered: Vec<String> = sqlx::query_scalar( |
|
| 73 | + | "SELECT CAST(t.relname AS TEXT) FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \ |
|
| 74 | + | WHERE t.relnamespace = CAST(current_schema() AS regnamespace) AND a.attname = 'id' AND a.attidentity <> ''", |
|
| 75 | + | ) |
|
| 76 | + | .fetch_all(&mut *tx) |
|
| 77 | + | .await?; |
|
| 78 | + | for t in &numbered { |
|
| 79 | + | sqlx::query(&format!("SELECT setval(pg_get_serial_sequence($1, 'id'), COALESCE((SELECT MAX(id) FROM \"{t}\"), 0) + 1, false)")) |
|
| 80 | + | .bind(t) |
|
| 81 | + | .execute(&mut *tx) |
|
| 82 | + | .await?; |
|
| 83 | + | } |
|
| 84 | + | let search = copy_search(&mut lite, &mut tx).await.context("copy the search index")?; |
|
| 85 | + | copied.push(("search".into(), search)); |
|
| 86 | + | for (t, n) in &copied { |
|
| 87 | + | let there: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{t}\"")).fetch_one(&mut *tx).await?; |
|
| 88 | + | if there != *n { |
|
| 89 | + | bail!("{t}: {n} rows read but {there} here"); |
|
| 90 | + | } |
|
| 91 | + | } |
|
| 92 | + | tx.commit().await?; |
|
| 93 | + | lite.close().await?; |
|
| 94 | + | Ok(copied) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | /// The SQLite hub must have every migration this hub knows, and no more: |
|
| 98 | + | /// the two schemas match only then. |
|
| 99 | + | async fn up_to_date(lite: &mut sqlx::SqliteConnection) -> Result<()> { |
|
| 100 | + | let latest = sqlx::migrate!("./migrations").iter().map(|m| m.version).max().unwrap_or(0); |
|
| 101 | + | let have: Option<i64> = |
|
| 102 | + | sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success = TRUE").fetch_one(&mut *lite).await?; |
|
| 103 | + | match have { |
|
| 104 | + | Some(v) if v == latest => Ok(()), |
|
| 105 | + | Some(v) => bail!("the SQLite hub is at migration {v}, this one at {latest}: run the same version of the hub on both"), |
|
| 106 | + | None => bail!("not a hub's database: it has no migrations"), |
|
| 107 | + | } |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | async fn columns(tx: &mut sqlx::PgConnection, table: &str) -> Result<Vec<Col>> { |
|
| 111 | + | let rows: Vec<(String, String, bool)> = sqlx::query_as( |
|
| 112 | + | "SELECT CAST(a.attname AS TEXT), format_type(a.atttypid, a.atttypmod), \ |
|
| 113 | + | EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = t.oid AND k.confrelid = t.oid AND k.contype = 'f' AND a.attnum = ANY (k.conkey)) \ |
|
| 114 | + | FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \ |
|
| 115 | + | WHERE t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace) \ |
|
| 116 | + | AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = '' ORDER BY a.attnum", |
|
| 117 | + | ) |
|
| 118 | + | .bind(table) |
|
| 119 | + | .fetch_all(&mut *tx) |
|
| 120 | + | .await?; |
|
| 121 | + | Ok(rows |
|
| 122 | + | .into_iter() |
|
| 123 | + | .map(|(name, ty, self_ref)| { |
|
| 124 | + | let kind = match ty.as_str() { |
|
| 125 | + | "bigint" | "integer" | "smallint" => Kind::Int, |
|
| 126 | + | "boolean" => Kind::Bool, |
|
| 127 | + | "double precision" | "real" => Kind::Real, |
|
| 128 | + | "bytea" => Kind::Blob, |
|
| 129 | + | _ => Kind::Text, |
|
| 130 | + | }; |
|
| 131 | + | Col { name, kind, self_ref } |
|
| 132 | + | }) |
|
| 133 | + | .collect()) |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | fn read(row: &SqliteRow, i: usize, kind: Kind) -> Result<Value> { |
|
| 137 | + | Ok(match kind { |
|
| 138 | + | Kind::Int => Value::Int(row.try_get(i)?), |
|
| 139 | + | Kind::Bool => Value::Bool(row.try_get::<Option<i64>, _>(i)?.map(|v| v != 0)), |
|
| 140 | + | Kind::Real => Value::Real(row.try_get(i)?), |
|
| 141 | + | Kind::Blob => Value::Blob(row.try_get(i)?), |
|
| 142 | + | Kind::Text => Value::Text(row.try_get(i)?), |
|
| 143 | + | }) |
|
| 144 | + | } |
|
| 145 | + | ||
| 146 | + | fn list(cols: &[&Col]) -> String { |
|
| 147 | + | cols.iter().map(|c| format!("\"{}\"", c.name)).collect::<Vec<_>>().join(", ") |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | async fn copy_table(lite: &mut sqlx::SqliteConnection, tx: &mut sqlx::PgConnection, table: &str, cols: &[Col]) -> Result<i64> { |
|
| 151 | + | use futures::TryStreamExt; |
|
| 152 | + | let all: Vec<&Col> = cols.iter().collect(); |
|
| 153 | + | let now: Vec<&Col> = cols.iter().filter(|c| !c.self_ref).collect(); |
|
| 154 | + | let id_at = cols.iter().position(|c| c.name == "id"); |
|
| 155 | + | let sql = format!("SELECT {} FROM \"{table}\" ORDER BY rowid", list(&all)); |
|
| 156 | + | // Streamed, a batch at a time: a table (Flow's logs) can be large. |
|
| 157 | + | let mut rows = sqlx::query(&sql).fetch(&mut *lite); |
|
| 158 | + | let (mut n, mut batch, mut later) = (0i64, Vec::with_capacity(BATCH), Vec::new()); |
|
| 159 | + | loop { |
|
| 160 | + | let row = rows.try_next().await?; |
|
| 161 | + | if let Some(row) = &row { |
|
| 162 | + | let mut values = Vec::with_capacity(now.len()); |
|
| 163 | + | let mut refs = Vec::new(); |
|
| 164 | + | for (i, c) in cols.iter().enumerate() { |
|
| 165 | + | let v = read(row, i, c.kind).with_context(|| format!("read {table}.{}", c.name))?; |
|
| 166 | + | if c.self_ref { |
|
| 167 | + | if matches!(v, Value::Int(Some(_))) { |
|
| 168 | + | refs.push((c.name.clone(), v)); |
|
| 169 | + | } |
|
| 170 | + | } else { |
|
| 171 | + | values.push(v); |
|
| 172 | + | } |
|
| 173 | + | } |
|
| 174 | + | if !refs.is_empty() { |
|
| 175 | + | let Some(i) = id_at else { bail!("{table} points at itself but has no id") }; |
|
| 176 | + | later.push((read(row, i, Kind::Int)?, refs)); |
|
| 177 | + | } |
|
| 178 | + | batch.push(values); |
|
| 179 | + | n += 1; |
|
| 180 | + | } |
|
| 181 | + | if batch.len() == BATCH || (row.is_none() && !batch.is_empty()) { |
|
| 182 | + | let mut q = sqlx::QueryBuilder::<super::Db>::new(format!("INSERT INTO \"{table}\" ({}) ", list(&now))); |
|
| 183 | + | q.push_values(batch.drain(..), |mut b, values| { |
|
| 184 | + | for v in values { |
|
| 185 | + | match v { |
|
| 186 | + | Value::Int(v) => b.push_bind(v), |
|
| 187 | + | Value::Bool(v) => b.push_bind(v), |
|
| 188 | + | Value::Real(v) => b.push_bind(v), |
|
| 189 | + | Value::Blob(v) => b.push_bind(v), |
|
| 190 | + | Value::Text(v) => b.push_bind(v), |
|
| 191 | + | }; |
|
| 192 | + | } |
|
| 193 | + | }); |
|
| 194 | + | q.build().execute(&mut *tx).await?; |
|
| 195 | + | } |
|
| 196 | + | if row.is_none() { |
|
| 197 | + | break; |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | for (id, refs) in later { |
|
| 201 | + | let Value::Int(id) = id else { continue }; |
|
| 202 | + | for (name, v) in refs { |
|
| 203 | + | if let Value::Int(v) = v { |
|
| 204 | + | sqlx::query(&format!("UPDATE \"{table}\" SET \"{name}\" = $1 WHERE id = $2")).bind(v).bind(id).execute(&mut *tx).await?; |
|
| 205 | + | } |
|
| 206 | + | } |
|
| 207 | + | } |
|
| 208 | + | Ok(n) |
|
| 209 | + | } |
|
| 210 | + | ||
| 211 | + | async fn copy_search(lite: &mut sqlx::SqliteConnection, tx: &mut sqlx::PgConnection) -> Result<i64> { |
|
| 212 | + | let rows: Vec<(String, i64, i64, i64, String, String)> = |
|
| 213 | + | sqlx::query_as("SELECT kind, repo_id, target_id, number, title, body FROM search").fetch_all(&mut *lite).await?; |
|
| 214 | + | for chunk in rows.chunks(BATCH) { |
|
| 215 | + | let mut q = sqlx::QueryBuilder::<super::Db>::new("INSERT INTO search (kind, repo_id, target_id, number, title, body) "); |
|
| 216 | + | q.push_values(chunk, |mut b, (kind, repo_id, target_id, number, title, body)| { |
|
| 217 | + | b.push_bind(kind).push_bind(repo_id).push_bind(target_id).push_bind(number).push_bind(title).push_bind(body); |
|
| 218 | + | }); |
|
| 219 | + | q.build().execute(&mut *tx).await?; |
|
| 220 | + | } |
|
| 221 | + | Ok(rows.len() as i64) |
|
| 222 | + | } |
modified crates/hub/src/db/mod.rs +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | pub mod accounts; |
|
| 6 | 6 | pub mod activity; |
|
| 7 | + | #[cfg(feature = "postgres")] |
|
| 8 | + | pub mod copy; |
|
| 7 | 9 | pub mod flow; |
|
| 8 | 10 | pub mod hooks; |
|
| 9 | 11 | pub mod issues; |
|
| … | 157 unchanged lines |
modified crates/hub/src/main.rs +19 −0
| … | 57 unchanged lines |
||
| 58 | 58 | #[command(subcommand)] |
|
| 59 | 59 | cmd: BackupCmd, |
|
| 60 | 60 | }, |
|
| 61 | + | /// Copy a SQLite hub's records into this hub's empty PostgreSQL |
|
| 62 | + | /// database, to move a hub (docs/postgres.md) |
|
| 63 | + | #[cfg(feature = "postgres")] |
|
| 64 | + | CopyDb { |
|
| 65 | + | /// The old hub's `hub.db`, from a stopped hub (or a copy of one) |
|
| 66 | + | #[arg(long)] |
|
| 67 | + | from: PathBuf, |
|
| 68 | + | }, |
|
| 61 | 69 | } |
|
| 62 | 70 | ||
| 63 | 71 | #[derive(Subcommand)] |
|
| … | 105 unchanged lines |
||
| 169 | 177 | } |
|
| 170 | 178 | Ok(()) |
|
| 171 | 179 | } |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + | #[cfg(feature = "postgres")] |
|
| 183 | + | Cmd::Admin { cmd: AdminCmd::CopyDb { from } } => { |
|
| 184 | + | let config = Config::load(&cli.data)?; |
|
| 185 | + | let pool = db::open(&config).await?; |
|
| 186 | + | let copied = db::copy::from_sqlite(&pool, &from).await?; |
|
| 187 | + | for (table, n) in &copied { |
|
| 188 | + | println!("{n:>9} {table}"); |
|
| 172 | 189 | } |
|
| 190 | + | println!("copied {} rows in {} tables", copied.iter().map(|(_, n)| n).sum::<i64>(), copied.len()); |
|
| 191 | + | Ok(()) |
|
| 173 | 192 | } |
|
| 174 | 193 | Cmd::Admin { cmd: AdminCmd::User { cmd } } => { |
|
| 175 | 194 | let config = Config::load(&cli.data)?; |
|
| … | 173 unchanged lines |
modified crates/hub/tests/schema.rs +54 −2
| … | 18 unchanged lines |
||
| 19 | 19 | use sqlx::Connection; |
|
| 20 | 20 | let mut conn = sqlx::SqliteConnection::connect("sqlite::memory:").await.unwrap(); |
|
| 21 | 21 | sqlx::migrate!("./migrations").run(&mut conn).await.unwrap(); |
|
| 22 | - | let names: Vec<String> = |
|
| 23 | - | sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").fetch_all(&mut conn).await.unwrap(); |
|
| 22 | + | let names: Vec<String> = sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") |
|
| 23 | + | .fetch_all(&mut conn) |
|
| 24 | + | .await |
|
| 25 | + | .unwrap(); |
|
| 24 | 26 | let mut out = Tables::new(); |
|
| 25 | 27 | for t in names.into_iter().filter(|t| compared(t)) { |
|
| 26 | 28 | let cols: Vec<String> = |
|
| … | 35 unchanged lines |
||
| 62 | 64 | assert_eq!(only(&lite, &pg), Vec::<String>::new(), "in SQLite's migrations but not migrations-pg"); |
|
| 63 | 65 | assert_eq!(only(&pg, &lite), Vec::<String>::new(), "in migrations-pg but not SQLite's migrations"); |
|
| 64 | 66 | } |
|
| 67 | + | ||
| 68 | + | /// A SQLite hub with a few of each kind of value, copied into Postgres. |
|
| 69 | + | #[tokio::test] |
|
| 70 | + | async fn a_sqlite_hub_copies_into_postgres_ids_flags_forks_and_all() { |
|
| 71 | + | use sqlx::Connection; |
|
| 72 | + | let old = tempfile::tempdir().unwrap(); |
|
| 73 | + | let path = old.path().join("hub.db"); |
|
| 74 | + | let options = sqlx::sqlite::SqliteConnectOptions::new().filename(&path).create_if_missing(true); |
|
| 75 | + | let mut lite = sqlx::SqliteConnection::connect_with(&options).await.unwrap(); |
|
| 76 | + | sqlx::migrate!("./migrations").run(&mut lite).await.unwrap(); |
|
| 77 | + | for sql in [ |
|
| 78 | + | "INSERT INTO accounts (id, username, email, password_hash, session_salt, is_admin, created_ms) VALUES (7, 'Andy', '[email protected]', 'h', 's', 1, 5)", |
|
| 79 | + | "INSERT INTO accounts (id, username, email, password_hash, session_salt, banned, created_ms) VALUES (9, 'bob', '[email protected]', 'h', 's', 1, 6)", |
|
| 80 | + | // The fork comes first: its parent is filled in after. |
|
| 81 | + | "INSERT INTO repositories (id, owner, name, created_ms, updated_ms) VALUES (4, 'andy', 'game', 1, 1)", |
|
| 82 | + | "INSERT INTO repositories (id, owner, name, created_ms, updated_ms, archived) VALUES (3, 'bob', 'engine', 1, 1, 1)", |
|
| 83 | + | "UPDATE repositories SET forked_from = 3 WHERE id = 4", |
|
| 84 | + | "INSERT INTO flow_secrets (scope, scope_id, name, value, updated_ms) VALUES ('repo', 3, 'KEY', X'00FF10', 1)", |
|
| 85 | + | "INSERT INTO search (kind, repo_id, target_id, number, title, body) VALUES ('issue', 3, 1, 1, 'Sprites flicker', 'the sprites flicker on level two')", |
|
| 86 | + | ] { |
|
| 87 | + | sqlx::query(sql).execute(&mut lite).await.unwrap(); |
|
| 88 | + | } |
|
| 89 | + | lite.close().await.unwrap(); |
|
| 90 | + | ||
| 91 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 92 | + | let config = Config::load(dir.path()).unwrap(); |
|
| 93 | + | let pool = oxygenhub::db::open(&config).await.unwrap(); |
|
| 94 | + | let copied = oxygenhub::db::copy::from_sqlite(&pool, &path).await.map_err(|e| format!("{e:#}")).unwrap(); |
|
| 95 | + | let count = |t: &str| copied.iter().find(|(c, _)| c == t).map(|(_, n)| *n); |
|
| 96 | + | assert_eq!((count("accounts"), count("repositories"), count("search")), (Some(2), Some(2), Some(1))); |
|
| 97 | + | ||
| 98 | + | let flags: Vec<(String, bool, bool)> = |
|
| 99 | + | sqlx::query_as("SELECT username, is_admin, banned FROM accounts WHERE username = 'andy' OR username = 'BOB' ORDER BY id") |
|
| 100 | + | .fetch_all(&pool) |
|
| 101 | + | .await |
|
| 102 | + | .unwrap(); |
|
| 103 | + | assert_eq!(flags, [("Andy".to_string(), true, false), ("bob".to_string(), false, true)], "flags, and names without case"); |
|
| 104 | + | let game = oxygenhub::db::repos::get(&pool, "andy", "game").await.unwrap().unwrap(); |
|
| 105 | + | assert_eq!((game.id, game.forked_from), (4, Some(3))); |
|
| 106 | + | let secret: Vec<u8> = sqlx::query_scalar("SELECT value FROM flow_secrets WHERE name = 'KEY'").fetch_one(&pool).await.unwrap(); |
|
| 107 | + | assert_eq!(secret, [0x00, 0xff, 0x10]); |
|
| 108 | + | let hits = oxygenhub::db::notify::search(&pool, "flickering sprite", None, 5).await.unwrap(); |
|
| 109 | + | assert_eq!(hits.iter().map(|h| h.title.as_str()).collect::<Vec<_>>(), ["Sprites flicker"]); |
|
| 110 | + | // New rows take ids after the copied ones. |
|
| 111 | + | let next = oxygenhub::db::repos::create(&pool, "andy", "next", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 112 | + | assert_eq!(next.id, 5); |
|
| 113 | + | // Never into a database with rows. |
|
| 114 | + | let again = oxygenhub::db::copy::from_sqlite(&pool, &path).await.unwrap_err().to_string(); |
|
| 115 | + | assert!(again.contains("already has rows"), "{again}"); |
|
| 116 | + | } |
modified docs/postgres.md +15 −0
| … | 52 unchanged lines |
||
| 53 | 53 | one in `migrations-pg`. `tests/schema.rs` fails when the two disagree about a |
|
| 54 | 54 | table or column. |
|
| 55 | 55 | ||
| 56 | + | ## Moving a SQLite hub to Postgres |
|
| 57 | + | ||
| 58 | + | On the new hub (built with `postgres`, `[database]` set, never yet served), |
|
| 59 | + | with the old hub stopped and its `hub.db` copied across: |
|
| 60 | + | ||
| 61 | + | ```sh |
|
| 62 | + | oxygenhub --data /srv/hub admin copy-db --from /srv/old/hub.db |
|
| 63 | + | ``` |
|
| 64 | + | ||
| 65 | + | Both hubs must be the same version: the copy refuses a `hub.db` at another |
|
| 66 | + | migration, and a database that already has rows. It copies every table in |
|
| 67 | + | one transaction with ids kept, moves each id sequence past them, copies the |
|
| 68 | + | search index, and checks every table's count before committing. Then move the |
|
| 69 | + | data directory's `repos`, `wikis` and `keys` across as they are. |
|
| 70 | + | ||
| 56 | 71 | ## Testing on Postgres |
|
| 57 | 72 | ||
| 58 | 73 | ```sh |
|
| … | 7 unchanged lines |