OXYGENHUB

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