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 | } |