OXYGENHUB

copy-db --replace: empty a rehearsal's or a fresh hub's rows first dc71378db1dc

The GCP hub's first boot made a hub (its tables and an init's rows), so
copy-db, which refuses a database with rows, couldn't be rehearsed on it.
--replace truncates every table (and the search index, identities restarted)
in the copy's own transaction, so a failed copy leaves the database as it
was; without it, rows are refused as before. Tested on Postgres: a replaced
copy is whole again and new ids follow on.

Refs #211, oxygenhub-gcp#15
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 21:49 UTC
Signature
✔ signed by Claude Code
Hash
dc71378db1dc95169f80a85c58c80bc86dbd44546a90607b3a49297ec2e1843e
Depends on
3b1bc27fe805 4e2cad00a484 d23464c0ac60
Depended on by
nothing yet
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
12 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/hub/src/db/copy.rs +14 −6
…
43 unchanged lines
44 44
    Text(Option<String>),
45 45
}
46 46
47 -
/// What was copied: `(table, rows)`, in copy order.
48 -
pub async fn from_sqlite(pool: &Pool, path: &Path) -> Result<Vec<(String, i64)>> {
47 +
/// What was copied: `(table, rows)`, in copy order. With `replace`, the
48 +
/// rows already here (a fresh hub's, a rehearsal's) are emptied first, in
49 +
/// the same transaction; without it, a database with rows is refused.
50 +
pub async fn from_sqlite(pool: &Pool, path: &Path, replace: bool) -> Result<Vec<(String, i64)>> {
49 51
    let options = SqliteConnectOptions::new().filename(path).read_only(true);
50 52
    let mut lite = sqlx::SqliteConnection::connect_with(&options).await.with_context(|| format!("open {}", path.display()))?;
51 53
    up_to_date(&mut lite).await?;
…
3 unchanged lines
55 57
    )
56 58
    .fetch_all(pool)
57 59
    .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");
60 +
    if !replace {
61 +
        for t in &tables {
62 +
            let any: bool = sqlx::query_scalar(&format!("SELECT EXISTS (SELECT 1 FROM \"{t}\")")).fetch_one(pool).await?;
63 +
            if any {
64 +
                bail!("{t} already has rows here: copy into a new, empty database, or give --replace to empty this one first");
65 +
            }
62 66
        }
63 67
    }
64 68
    let mut tx = pool.begin().await?;
69 +
    if replace {
70 +
        let all: Vec<String> = tables.iter().chain(std::iter::once(&"search".to_string())).map(|t| format!("\"{t}\"")).collect();
71 +
        sqlx::query(&format!("TRUNCATE {} RESTART IDENTITY CASCADE", all.join(", "))).execute(&mut *tx).await?;
72 +
    }
65 73
    let mut copied = Vec::new();
66 74
    for t in &tables {
67 75
        let cols = columns(&mut tx, t).await?;
…
155 unchanged lines
modified crates/hub/src/main.rs +5 −2
…
64 unchanged lines
65 65
        /// The old hub's `hub.db`, from a stopped hub (or a copy of one)
66 66
        #[arg(long)]
67 67
        from: PathBuf,
68 +
        /// Empty this database's tables first (a fresh hub's, a rehearsal's)
69 +
        #[arg(long)]
70 +
        replace: bool,
68 71
    },
69 72
}
70 73
…
109 unchanged lines
180 183
            }
181 184
        }
182 185
        #[cfg(feature = "postgres")]
183 -
        Cmd::Admin { cmd: AdminCmd::CopyDb { from } } => {
186 +
        Cmd::Admin { cmd: AdminCmd::CopyDb { from, replace } } => {
184 187
            let config = Config::load(&cli.data)?;
185 188
            let pool = db::open(&config).await?;
186 -
            let copied = db::copy::from_sqlite(&pool, &from).await?;
189 +
            let copied = db::copy::from_sqlite(&pool, &from, replace).await?;
187 190
            for (table, n) in &copied {
188 191
                println!("{n:>9}  {table}");
189 192
            }
…
178 unchanged lines
modified crates/hub/tests/schema.rs +9 −2
…
90 unchanged lines
91 91
    let dir = tempfile::tempdir().unwrap();
92 92
    let config = Config::load(dir.path()).unwrap();
93 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();
94 +
    let copied = oxygenhub::db::copy::from_sqlite(&pool, &path, false).await.map_err(|e| format!("{e:#}")).unwrap();
95 95
    let count = |t: &str| copied.iter().find(|(c, _)| c == t).map(|(_, n)| *n);
96 96
    assert_eq!((count("accounts"), count("repositories"), count("search")), (Some(2), Some(2), Some(1)));
97 97
…
13 unchanged lines
111 111
    let next = oxygenhub::db::repos::create(&pool, "andy", "next", "", hub_api::Visibility::Public).await.unwrap();
112 112
    assert_eq!(next.id, 5);
113 113
    // Never into a database with rows.
114 -
    let again = oxygenhub::db::copy::from_sqlite(&pool, &path).await.unwrap_err().to_string();
114 +
    let again = oxygenhub::db::copy::from_sqlite(&pool, &path, false).await.unwrap_err().to_string();
115 115
    assert!(again.contains("already has rows"), "{again}");
116 +
    // With --replace: the rows here (the "next" repository) are emptied and
117 +
    // the copy is whole again, ids from the start.
118 +
    let again = oxygenhub::db::copy::from_sqlite(&pool, &path, true).await.map_err(|e| format!("{e:#}")).unwrap();
119 +
    assert_eq!(again.iter().find(|(t, _)| t == "repositories").map(|(_, n)| *n), Some(2));
120 +
    assert!(oxygenhub::db::repos::get(&pool, "andy", "next").await.unwrap().is_none());
121 +
    let after = oxygenhub::db::repos::create(&pool, "andy", "after", "", hub_api::Visibility::Public).await.unwrap();
122 +
    assert_eq!(after.id, 5);
116 123
}