OXYGENHUB

Backups: a restore brings back the records, and release assets travel 5ef6cf72cd19

A restore brought back each repository's store and row; its issues,
proposals and the rest stayed in the run's records, and release assets,
which live beside the stores in <data>/assets, were never sent.

Records now carry _names: the username, organisation or org/team behind
every account, team and organisation id their rows point at. A restore
recreates the repository row with its settings, then every table of the
records parents first, each row with a new id and every reference
following it, accounts found again by name. A reference to an account
the hub lacks is blanked where the column allows (the author's name stays
in the row) and the row left out where it does not; the restore says how
many. Webhooks come back without secrets, each named to be entered again;
search is rebuilt. Release assets are sent once each as blobs and written
back checked against their hash. Inboxes, webhook deliveries and
runners' declines are left out on purpose.

Tested end to end: a fresh hub lacking one account gets the issue, its
comment, label, milestone and the assignee it has, the proposal with its
review and events, the release with its asset's bytes, the webhook named
for its secret, search, and numbering that carries on.

closes #175
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 02:55 UTC
Signature
✔ signed by Andy McCall
Hash
5ef6cf72cd194dde0ecdaf61058fbe11105e8b7f74cc00ed1eb3fee29c7d783d
Depends on
35fea4237cc9 6dbd999ad794 d7d182467382
Depended on by
9041db4e4d92 3b1bc27fe805 5d90e0e045e2 d3b048b27277 1e8a6e8bb91b
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 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
14 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/hub/src/backups.rs +318 −7
…
607 unchanged lines
608 608
    Ok(serde_json::Value::Object(out))
609 609
}
610 610
611 +
/// The key of a run's records naming the accounts, teams and organisations
612 +
/// their rows point at, by id: `{"accounts": {"7": "andy"}, …}`.
613 +
const NAMES: &str = "_names";
614 +
615 +
/// Tables a restore leaves out: the search index (rebuilt from what is
616 +
/// restored), and what is passing or personal rather than the
617 +
/// repository's: inboxes, webhook deliveries, runners' declines.
618 +
const NOT_RESTORED: [&str; 5] = ["search", "notifications", "deliveries", "flow_declines", NAMES];
619 +
620 +
async fn fk_list(pool: &Pool, table: &str) -> Result<Vec<(String, String, String)>> {
621 +
    Ok(sqlx::query_as(&format!("SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('{table}')")).fetch_all(pool).await?)
622 +
}
623 +
624 +
/// The names behind the account, team and organisation ids that `rec`'s
625 +
/// rows point at, so a restore on another hub finds them again by name.
626 +
async fn names_of(pool: &Pool, rec: &serde_json::Value) -> Result<serde_json::Value> {
627 +
    let mut ids: HashMap<String, std::collections::BTreeSet<i64>> = HashMap::new();
628 +
    for (table, rows) in rec.as_object().into_iter().flatten() {
629 +
        let Some(rows) = rows.as_array() else { continue };
630 +
        for (parent, from, _) in fk_list(pool, table).await? {
631 +
            if ["accounts", "teams", "organisations"].contains(&parent.as_str()) {
632 +
                ids.entry(parent).or_default().extend(rows.iter().filter_map(|r| r[&from].as_i64()));
633 +
            }
634 +
        }
635 +
    }
636 +
    let mut out = serde_json::Map::new();
637 +
    for (kind, sql) in [
638 +
        ("accounts", "SELECT id, username FROM accounts WHERE id = ?"),
639 +
        ("organisations", "SELECT id, name FROM organisations WHERE id = ?"),
640 +
        ("teams", "SELECT t.id, o.name || '/' || t.name FROM teams t JOIN organisations o ON o.id = t.org_id WHERE t.id = ?"),
641 +
    ] {
642 +
        let mut m = serde_json::Map::new();
643 +
        for id in ids.get(kind).into_iter().flatten() {
644 +
            if let Some((id, name)) = sqlx::query_as::<_, (i64, String)>(sql).bind(id).fetch_optional(pool).await? {
645 +
                m.insert(id.to_string(), name.into());
646 +
            }
647 +
        }
648 +
        out.insert(kind.into(), m.into());
649 +
    }
650 +
    Ok(out.into())
651 +
}
652 +
653 +
/// The id `name` has on this hub as one of `_names`' kinds.
654 +
async fn id_by_name(pool: &Pool, kind: &str, name: &str) -> Result<Option<i64>> {
655 +
    Ok(match kind {
656 +
        "accounts" => sqlx::query_scalar("SELECT id FROM accounts WHERE username = ?").bind(name).fetch_optional(pool).await?,
657 +
        "organisations" => sqlx::query_scalar("SELECT id FROM organisations WHERE name = ?").bind(name).fetch_optional(pool).await?,
658 +
        "teams" => match name.split_once('/') {
659 +
            Some((org, team)) => {
660 +
                sqlx::query_scalar("SELECT t.id FROM teams t JOIN organisations o ON o.id = t.org_id WHERE o.name = ? AND t.name = ?")
661 +
                    .bind(org)
662 +
                    .bind(team)
663 +
                    .fetch_optional(pool)
664 +
                    .await?
665 +
            }
666 +
            None => None,
667 +
        },
668 +
        _ => None,
669 +
    })
670 +
}
671 +
672 +
/// One column of a table on this hub.
673 +
struct Col {
674 +
    name: String,
675 +
    ty: String,
676 +
    notnull: bool,
677 +
    default: Option<String>,
678 +
    pk: i64,
679 +
}
680 +
681 +
async fn table_cols(pool: &Pool, table: &str) -> Result<Vec<Col>> {
682 +
    let rows: Vec<(String, String, i64, Option<String>, i64)> =
683 +
        sqlx::query_as(&format!("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info('{table}')"))
684 +
            .fetch_all(pool)
685 +
            .await?;
686 +
    Ok(rows.into_iter().map(|(name, ty, notnull, default, pk)| Col { name, ty, notnull: notnull != 0, default, pk }).collect())
687 +
}
688 +
689 +
/// Bind one value of a backed-up row: blobs were written as hex.
690 +
fn bind_json<'q>(
691 +
    q: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
692 +
    v: serde_json::Value,
693 +
    blob: bool,
694 +
) -> sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>> {
695 +
    match v {
696 +
        serde_json::Value::Null => q.bind(None::<i64>),
697 +
        serde_json::Value::Bool(b) => q.bind(b as i64),
698 +
        serde_json::Value::Number(n) => match n.as_i64() {
699 +
            Some(i) => q.bind(i),
700 +
            None => q.bind(n.as_f64().unwrap_or(0.0)),
701 +
        },
702 +
        serde_json::Value::String(s) if blob => q.bind(hex::decode(&s).unwrap_or_else(|_| s.into_bytes())),
703 +
        serde_json::Value::String(s) => q.bind(s),
704 +
        other => q.bind(other.to_string()),
705 +
    }
706 +
}
707 +
708 +
/// Restore one repository's records from a run into this hub, the
709 +
/// repository being `repo` here and `old` in the backup (docs/backups.md
710 +
/// §7): its issues, proposals, labels, milestones, releases and their
711 +
/// assets, checks, Flow runs and the rest, each table after the tables it
712 +
/// points at, every id mapped to its new one and every account, team and
713 +
/// organisation found again by name. A row pointing at an account this
714 +
/// hub lacks keeps a blank reference where it may, and is left out where
715 +
/// it may not. What needs re-entering (webhook secrets, never backed up)
716 +
/// and what was left out come back as notes.
717 +
async fn restore_records(
718 +
    app: &Shared,
719 +
    dest: &std::sync::Arc<dyn Destination>,
720 +
    rec: &serde_json::Value,
721 +
    old: i64,
722 +
    repo: i64,
723 +
) -> Result<Vec<String>> {
724 +
    let pool = &app.db;
725 +
    let obj = rec.as_object().context("the records are not an object")?;
726 +
    let mut by_name: HashMap<(String, i64), Option<i64>> = HashMap::new();
727 +
    for (kind, m) in obj.get(NAMES).and_then(|n| n.as_object()).into_iter().flatten() {
728 +
        for (id, name) in m.as_object().into_iter().flatten() {
729 +
            let (Ok(id), Some(name)) = (id.parse::<i64>(), name.as_str()) else { continue };
730 +
            by_name.insert((kind.clone(), id), id_by_name(pool, kind, name).await?);
731 +
        }
732 +
    }
733 +
    let here: std::collections::HashSet<String> = tables(pool).await?.into_iter().collect();
734 +
    let mut notes = Vec::new();
735 +
    let mut todo: Vec<String> = Vec::new();
736 +
    for t in obj.keys().filter(|t| t.as_str() != "repositories" && !NOT_RESTORED.contains(&t.as_str())) {
737 +
        if here.contains(t) {
738 +
            todo.push(t.clone());
739 +
        } else {
740 +
            notes.push(format!("{t}: not a table on this hub, left out"));
741 +
        }
742 +
    }
743 +
    let mut fks: HashMap<String, Vec<(String, String, String)>> = HashMap::new();
744 +
    for t in &todo {
745 +
        fks.insert(t.clone(), fk_list(pool, t).await?);
746 +
    }
747 +
    // Each table after the tables of the records it points at.
748 +
    let mut order = Vec::new();
749 +
    while !todo.is_empty() {
750 +
        let mut ready: Vec<String> =
751 +
            todo.iter().filter(|t| fks[*t].iter().all(|(p, _, _)| p == *t || !todo.contains(p))).cloned().collect();
752 +
        if ready.is_empty() {
753 +
            ready.push(todo[0].clone());
754 +
        }
755 +
        todo.retain(|t| !ready.contains(t));
756 +
        order.extend(ready);
757 +
    }
758 +
    let mut maps: HashMap<String, HashMap<i64, i64>> = HashMap::new();
759 +
    maps.insert("repositories".into(), [(old, repo)].into());
760 +
    let mut left_out: std::collections::BTreeMap<String, usize> = Default::default();
761 +
    let mut tx = pool.begin().await?;
762 +
    for table in &order {
763 +
        let cols = table_cols(pool, table).await?;
764 +
        let keys: Vec<&str> = cols.iter().filter(|c| c.pk > 0).map(|c| c.name.as_str()).collect();
765 +
        let by_id = keys == ["id"] && cols.iter().any(|c| c.name == "id" && c.ty.eq_ignore_ascii_case("INTEGER"));
766 +
        let verb = if keys == ["repo_id"] {
767 +
            "INSERT OR REPLACE"
768 +
        } else if by_id {
769 +
            "INSERT"
770 +
        } else {
771 +
            "INSERT OR IGNORE"
772 +
        };
773 +
        let mut rows: Vec<&serde_json::Map<String, serde_json::Value>> =
774 +
            obj[table].as_array().into_iter().flatten().filter_map(|r| r.as_object()).collect();
775 +
        rows.sort_by_key(|r| r.get("id").and_then(|v| v.as_i64()).unwrap_or(0));
776 +
        'row: for row in rows {
777 +
            let (mut names, mut values) = (Vec::new(), Vec::new());
778 +
            for c in &cols {
779 +
                if by_id && c.name == "id" {
780 +
                    continue;
781 +
                }
782 +
                let mut v = row.get(&c.name).cloned().filter(|v| !v.is_null());
783 +
                if let Some((parent, _, _)) = fks[table].iter().find(|(_, from, _)| *from == c.name) {
784 +
                    if let Some(was) = v.as_ref().and_then(|v| v.as_i64()) {
785 +
                        let now = match parent.as_str() {
786 +
                            "accounts" | "teams" | "organisations" => by_name.get(&(parent.clone(), was)).copied().flatten(),
787 +
                            p => maps.get(p).and_then(|m| m.get(&was)).copied(),
788 +
                        };
789 +
                        if now.is_none() && c.notnull {
790 +
                            *left_out.entry(table.clone()).or_default() += 1;
791 +
                            continue 'row;
792 +
                        }
793 +
                        v = now.map(serde_json::Value::from);
794 +
                    }
795 +
                }
796 +
                let blob = c.ty.to_ascii_uppercase().contains("BLOB");
797 +
                match v {
798 +
                    Some(v) => {
799 +
                        names.push(c.name.clone());
800 +
                        values.push((v, blob));
801 +
                    }
802 +
                    // Never backed up (a secret): blank here, to enter again.
803 +
                    None if c.notnull && c.default.is_none() => {
804 +
                        names.push(c.name.clone());
805 +
                        let blank = if c.ty.to_ascii_uppercase().contains("INT") { serde_json::json!(0) } else { serde_json::json!("") };
806 +
                        values.push((blank, false));
807 +
                    }
808 +
                    None => {}
809 +
                }
810 +
            }
811 +
            let sql = format!(
812 +
                "{verb} INTO \"{table}\" ({}) VALUES ({})",
813 +
                names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", "),
814 +
                vec!["?"; names.len()].join(", ")
815 +
            );
816 +
            let mut q = sqlx::query(&sql);
817 +
            for (v, blob) in values {
818 +
                q = bind_json(q, v, blob);
819 +
            }
820 +
            let done = q.execute(&mut *tx).await.with_context(|| format!("restore a row of {table}"))?;
821 +
            if let (true, Some(was)) = (by_id, row.get("id").and_then(|v| v.as_i64())) {
822 +
                maps.entry(table.clone()).or_default().insert(was, done.last_insert_rowid());
823 +
            }
824 +
        }
825 +
    }
826 +
    tx.commit().await?;
827 +
    for (table, n) in left_out {
828 +
        notes.push(format!("{table}: {n} row(s) left out: they name accounts or rows this hub lacks"));
829 +
    }
830 +
    // Search finds what came back.
831 +
    for (kind, table) in [("issue", "issues"), ("proposal", "proposals")] {
832 +
        let found: Vec<(i64, i64, String, String)> =
833 +
            sqlx::query_as(&format!("SELECT id, number, title, body FROM {table} WHERE repo_id = ?")).bind(repo).fetch_all(pool).await?;
834 +
        for (id, number, title, body) in found {
835 +
            db::notify::index(pool, kind, repo, id, number, &title, &body).await?;
836 +
        }
837 +
    }
838 +
    // Webhook secrets were never written: they are entered again.
839 +
    let hooks: Vec<String> =
840 +
        sqlx::query_scalar("SELECT url FROM hooks WHERE repo_id = ? AND secret = ''").bind(repo).fetch_all(pool).await?;
841 +
    for url in hooks {
842 +
        notes.push(format!("webhook {url}: enter its secret again (Settings › Webhooks)"));
843 +
    }
844 +
    // Release assets, each checked against its name.
845 +
    for hash in obj.get("release_assets").and_then(|a| a.as_array()).into_iter().flatten().filter_map(|a| a["hash"].as_str()) {
846 +
        if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
847 +
            continue;
848 +
        }
849 +
        let path = crate::web::releases::blob_path(app, hash);
850 +
        if path.exists() {
851 +
            continue;
852 +
        }
853 +
        let got = tokio::task::block_in_place(|| dest.get(&blob_key(hash)));
854 +
        match got {
855 +
            Ok(bytes) if blake3::hash(&bytes).to_hex().as_str() == hash => {
856 +
                std::fs::create_dir_all(path.parent().context("no parent")?)?;
857 +
                std::fs::write(&path, &bytes)?;
858 +
            }
859 +
            Ok(_) => notes.push(format!("release asset {}: the copy does not match its hash", &hash[..12])),
860 +
            Err(_) => notes.push(format!("release asset {}: not in the backup", &hash[..12])),
861 +
        }
862 +
    }
863 +
    Ok(notes)
864 +
}
865 +
611 866
// ---------------------------------------------------------------- a run
612 867
613 868
fn run_name(ms: i64) -> String {
…
109 unchanged lines
723 978
        }
724 979
        let rec = async {
725 980
            let mut r = records(&app.db, "repo_id", row.id).await?;
726 -
            // The repository's own row, which restore recreates first.
981 +
            // The repository's own row, which restore recreates first, and
982 +
            // the names behind the accounts, teams and organisations its
983 +
            // rows point at, which another hub finds them by.
727 984
            let own = rows(&app.db, "repositories", &format!("id = {}", row.id)).await?;
985 +
            let names = names_of(&app.db, &r).await?;
728 986
            if let Some(m) = r.as_object_mut() {
729 987
                m.insert("repositories".into(), own);
988 +
                m.insert(NAMES.into(), names);
730 989
            }
731 990
            anyhow::Ok(r)
732 991
        };
733 992
        match rec.await {
734 -
            Ok(r) => tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?,
993 +
            Ok(r) => {
994 +
                tokio::task::block_in_place(|| dest.put(&format!("runs/{at}/records/{}.json", row.name), &serde_json::to_vec(&r)?))?;
995 +
                // Release assets live beside the stores, named by their
996 +
                // BLAKE3: each sent once, as the stores' files are.
997 +
                for hash in r["release_assets"].as_array().into_iter().flatten().filter_map(|a| a["hash"].as_str()) {
998 +
                    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
999 +
                        continue;
1000 +
                    }
1001 +
                    let path = crate::web::releases::blob_path(app, hash);
1002 +
                    let key = blob_key(hash);
1003 +
                    let put = tokio::task::block_in_place(|| -> Result<Option<i64>> {
1004 +
                        if dest.exists(&key)? {
1005 +
                            return Ok(None);
1006 +
                        }
1007 +
                        let bytes = std::fs::read(&path).with_context(|| format!("read release asset {}", &hash[..12]))?;
1008 +
                        dest.put(&key, &bytes)?;
1009 +
                        Ok(Some(bytes.len() as i64))
1010 +
                    });
1011 +
                    match put {
1012 +
                        Ok(Some(n)) => {
1013 +
                            sent.files += 1;
1014 +
                            sent.sent += 1;
1015 +
                            sent.bytes += n;
1016 +
                        }
1017 +
                        Ok(None) => sent.files += 1,
1018 +
                        Err(e) => problems.push(format!("{}/{} release asset: {e:#}", row.owner, row.name)),
1019 +
                    }
1020 +
                }
1021 +
            }
735 1022
            Err(e) => problems.push(format!("{}/{} records: {e:#}", row.owner, row.name)),
736 1023
        }
737 1024
    }
…
174 unchanged lines
912 1199
}
913 1200
914 1201
/// Restore the whole run `at` (the latest when none) into this hub: each
915 -
/// repository's and wiki's store byte for byte, then its row, from the
916 -
/// run's records, so it shows on the hub as it was. Issues, proposals and
917 -
/// the rest stay in the run's records for now (docs/backups.md §7).
1202 +
/// repository's and wiki's store byte for byte, then its row and settings,
1203 +
/// then its records (issues, proposals, releases and the rest), so it shows
1204 +
/// on the hub as it was (docs/backups.md §7). What was restored, then any
1205 +
/// notes (`note: …`) on what needs entering again or was left out.
918 1206
pub async fn restore_into_hub(app: &Shared, dest: std::sync::Arc<dyn Destination>, at: Option<String>) -> Result<Vec<String>> {
919 1207
    let (repos_root, wikis_root) = (app.repos.root().to_path_buf(), app.wikis.root().to_path_buf());
920 1208
    let (d, a) = (dest.clone(), at.clone());
…
2 unchanged lines
923 1211
        Some(a) => a,
924 1212
        None => String::from_utf8_lossy(&tokio::task::block_in_place(|| dest.get("LATEST"))?).trim().to_string(),
925 1213
    };
1214 +
    let mut notes = Vec::new();
926 1215
    for name in tokio::task::block_in_place(|| dest.list(&format!("runs/{at}/records")))? {
927 1216
        let rec: serde_json::Value =
928 1217
            serde_json::from_slice(&tokio::task::block_in_place(|| dest.get(&format!("runs/{at}/records/{name}")))?)?;
…
8 unchanged lines
937 1226
            _ => hub_api::Visibility::Public,
938 1227
        };
939 1228
        let made = db::repos::create(&app.db, owner, repo, row["description"].as_str().unwrap_or_default(), visibility).await?;
940 -
        if let Some(b) = row["default_bookmark"].as_str().filter(|b| !b.is_empty()) {
941 -
            db::repos::set_default_bookmark(&app.db, made.id, b).await?;
1229 +
        // Its settings as they were: every column the row kept but its
1230 +
        // name, its id and a fork's parent (another hub's id).
1231 +
        let cols = table_cols(&app.db, "repositories").await?;
1232 +
        let set: Vec<(&Col, serde_json::Value)> = cols
1233 +
            .iter()
1234 +
            .filter(|c| !["id", "owner", "name", "forked_from"].contains(&c.name.as_str()))
1235 +
            .filter_map(|c| row.get(&c.name).filter(|v| !v.is_null()).map(|v| (c, v.clone())))
1236 +
            .collect();
1237 +
        if !set.is_empty() {
1238 +
            let sql = format!(
1239 +
                "UPDATE repositories SET {} WHERE id = ?",
1240 +
                set.iter().map(|(c, _)| format!("\"{}\" = ?", c.name)).collect::<Vec<_>>().join(", ")
1241 +
            );
1242 +
            let mut q = sqlx::query(&sql);
1243 +
            for (c, v) in set {
1244 +
                q = bind_json(q, v, c.ty.to_ascii_uppercase().contains("BLOB"));
1245 +
            }
1246 +
            q.bind(made.id).execute(&app.db).await?;
1247 +
        }
1248 +
        let old = row["id"].as_i64().unwrap_or(-1);
1249 +
        for n in restore_records(app, &dest, &rec, old, made.id).await.with_context(|| format!("restore {owner}/{repo}'s records"))? {
1250 +
            notes.push(format!("note: {owner}/{repo}: {n}"));
942 1251
        }
943 1252
    }
1253 +
    let mut done = done;
1254 +
    done.extend(notes);
944 1255
    Ok(done)
945 1256
}
946 1257
…
43 unchanged lines
modified crates/hub/tests/backups.rs +140 −0
…
366 unchanged lines
367 367
    assert_eq!(refused.status, "failed");
368 368
    assert!(refused.detail.contains("roles/storage.objectAdmin") && refused.detail.contains("backup:bob"), "{}", refused.detail);
369 369
}
370 +
371 +
/// docs/backups.md §7 (BP1): a restore brings back the records as well as
372 +
/// the stores: issues with their comments, labels, milestone and
373 +
/// assignees, proposals with their reviews and events, releases with their
374 +
/// asset files, and webhooks without their secrets, every id mapped and
375 +
/// every account found again by name; what this hub lacks is said.
376 +
#[tokio::test(flavor = "multi_thread")]
377 +
async fn a_restore_brings_back_issues_proposals_and_releases() {
378 +
    let store = tempfile::tempdir().unwrap();
379 +
    let h = hub(store.path()).await;
380 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
381 +
    let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
382 +
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
383 +
    let work = tempfile::tempdir().unwrap();
384 +
    push(&format!("{}/andy/demo", h.base), work.path(), &[("README.md", "hello\n")]);
385 +
    let row = db::repos::get(&h.app.db, "andy", "demo").await.unwrap().unwrap();
386 +
    db::repos::set_diffusion(&h.app.db, row.id, "green").await.unwrap();
387 +
    // Ids on the first hub start further on than on the fresh one.
388 +
    for n in 0..3 {
389 +
        db::issues::upsert_label(&h.app.db, row.id, &format!("spare{n}"), "#000000", "").await.unwrap();
390 +
    }
391 +
    let bug = db::issues::upsert_label(&h.app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap();
392 +
    let m = db::issues::upsert_milestone(&h.app.db, row.id, "1.0", "the first", None).await.unwrap();
393 +
    let issue = db::issues::create(
394 +
        &h.app.db,
395 +
        db::issues::NewIssue { repo_id: row.id, title: "Sprites flicker", body: "on level two", author_id: Some(andy.id), author: "andy" },
396 +
    )
397 +
    .await
398 +
    .unwrap();
399 +
    db::issues::add_comment(&h.app.db, issue.id, Some(bob.id), "bob", "seen it too").await.unwrap();
400 +
    db::issues::set_issue_label(&h.app.db, issue.id, bug.id, true).await.unwrap();
401 +
    db::issues::set_milestone(&h.app.db, issue.id, Some(m.id)).await.unwrap();
402 +
    db::issues::set_assignee(&h.app.db, issue.id, andy.id, true).await.unwrap();
403 +
    db::issues::set_assignee(&h.app.db, issue.id, bob.id, true).await.unwrap();
404 +
    let p = db::proposals::create(
405 +
        &h.app.db,
406 +
        db::proposals::NewProposal {
407 +
            repo_id: row.id,
408 +
            title: "Double-buffer the sprites",
409 +
            body: "fixes the flicker",
410 +
            author_id: Some(bob.id),
411 +
            author: "bob",
412 +
            draft: false,
413 +
            target: "main",
414 +
            source_kind: "bookmark",
415 +
            source_repo_id: Some(row.id),
416 +
            source_bookmark: "fix",
417 +
            source_changes: "",
418 +
        },
419 +
    )
420 +
    .await
421 +
    .unwrap();
422 +
    db::proposals::add_event(&h.app.db, p.id, "bob", "opened", "").await.unwrap();
423 +
    db::proposals::add_review(&h.app.db, p.id, andy.id, "andy", "approve", "nice").await.unwrap();
424 +
    let release = db::releases::create(&h.app.db, row.id, "v1.0", "One", "notes", false, Some((andy.id, "andy"))).await.unwrap();
425 +
    let asset = b"a game image".to_vec();
426 +
    let hash = blake3::hash(&asset).to_hex().to_string();
427 +
    let path = h.app.config.data.join("assets").join(&hash[..2]).join(&hash);
428 +
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
429 +
    std::fs::write(&path, &asset).unwrap();
430 +
    db::releases::add_asset(&h.app.db, release.id, "game.prg", asset.len() as i64, &hash, "application/octet-stream").await.unwrap();
431 +
    db::hooks::create_hook(&h.app.db, Some(row.id), None, "https://example.com/hook", "s3cret-value", "push").await.unwrap();
432 +
433 +
    let mut s = Setting::off("andy", "user");
434 +
    s.every = "hourly".into();
435 +
    s.target = store.path().display().to_string();
436 +
    backups::save(&h.app.db, &s).await.unwrap();
437 +
    let run = backups::run(&h.app, "andy", "now").await.unwrap();
438 +
    assert_eq!(run.status, "ok", "{}", run.detail);
439 +
    let dest = Directory { root: store.path().join("andy") };
440 +
    let text = String::from_utf8_lossy(&dest.get(&format!("runs/{}/records/demo.json", run.at)).unwrap()).into_owned();
441 +
    assert!(!text.contains("s3cret-value"), "never a secret");
442 +
    assert!(text.contains("\"_names\"") && text.contains("\"bob\""), "accounts by name");
443 +
444 +
    // A fresh hub that knows andy but not bob.
445 +
    let empty = tempfile::tempdir().unwrap();
446 +
    let fresh = hub(empty.path()).await;
447 +
    let andy2 = db::accounts::create_account(&fresh.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
448 +
    let dest: Arc<dyn Destination> = Arc::new(Directory { root: store.path().join("andy") });
449 +
    let done = backups::restore_into_hub(&fresh.app, dest, None).await.unwrap();
450 +
    assert_eq!(done[0], "andy/demo (repos)");
451 +
    let notes = done[1..].join("\n");
452 +
    assert!(notes.contains("webhook https://example.com/hook: enter its secret again"), "{notes}");
453 +
    assert!(notes.contains("issue_assignees: 1 row(s) left out"), "bob is not on this hub: {notes}");
454 +
    let r2 = db::repos::get(&fresh.app.db, "andy", "demo").await.unwrap().unwrap();
455 +
    assert_eq!(r2.diffusion, "green", "settings come back");
456 +
    // The issue, its comment, label, milestone and the assignee this hub has.
457 +
    let issues: Vec<(i64, i64, String, Option<i64>)> = sqlx::query_as("SELECT id, number, title, author_id FROM issues WHERE repo_id = ?")
458 +
        .bind(r2.id)
459 +
        .fetch_all(&fresh.app.db)
460 +
        .await
461 +
        .unwrap();
462 +
    assert_eq!(issues.len(), 1);
463 +
    let (iid, number, title, author) = issues[0].clone();
464 +
    assert_eq!((number, title.as_str(), author), (issue.number, "Sprites flicker", Some(andy2.id)));
465 +
    let comments = db::issues::comments(&fresh.app.db, iid).await.unwrap();
466 +
    assert_eq!(comments.len(), 1);
467 +
    assert_eq!((comments[0].author.as_str(), comments[0].body.as_str()), ("bob", "seen it too"));
468 +
    let labels: Vec<String> =
469 +
        sqlx::query_scalar("SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?")
470 +
            .bind(iid)
471 +
            .fetch_all(&fresh.app.db)
472 +
            .await
473 +
            .unwrap();
474 +
    assert_eq!(labels, vec!["bug"], "the label, its id mapped");
475 +
    let milestone: Option<String> =
476 +
        sqlx::query_scalar("SELECT m.title FROM issues i JOIN milestones m ON m.id = i.milestone_id WHERE i.id = ?")
477 +
            .bind(iid)
478 +
            .fetch_one(&fresh.app.db)
479 +
            .await
480 +
            .unwrap();
481 +
    assert_eq!(milestone.as_deref(), Some("1.0"));
482 +
    assert_eq!(db::issues::assignees(&fresh.app.db, iid).await.unwrap(), vec!["andy"]);
483 +
    // The proposal, its review and its timeline.
484 +
    let p2 = db::proposals::get(&fresh.app.db, r2.id, p.number).await.unwrap().expect("the proposal");
485 +
    assert_eq!((p2.title.as_str(), p2.author.as_str(), p2.author_id), ("Double-buffer the sprites", "bob", None));
486 +
    let reviews: Vec<(String, String)> = sqlx::query_as("SELECT author, verdict FROM proposal_reviews WHERE proposal_id = ?")
487 +
        .bind(p2.id)
488 +
        .fetch_all(&fresh.app.db)
489 +
        .await
490 +
        .unwrap();
491 +
    assert_eq!(reviews, vec![("andy".to_string(), "approve".to_string())]);
492 +
    assert!(db::proposals::events(&fresh.app.db, p2.id).await.unwrap().iter().any(|e| e.event == "opened"));
493 +
    // The release and its asset's bytes.
494 +
    let rel = db::releases::get(&fresh.app.db, r2.id, "v1.0").await.unwrap().expect("the release");
495 +
    let assets = db::releases::assets(&fresh.app.db, rel.id).await.unwrap();
496 +
    assert_eq!(assets.len(), 1);
497 +
    assert_eq!(std::fs::read(fresh.app.config.data.join("assets").join(&hash[..2]).join(&hash)).unwrap(), asset);
498 +
    // Search finds what came back.
499 +
    let hits = db::notify::search(&fresh.app.db, "flicker", Some(r2.id), 10).await.unwrap();
500 +
    assert!(hits.iter().any(|h| h.kind == "issue" && h.target_id == iid), "{:?}", hits.iter().map(|h| &h.title).collect::<Vec<_>>());
501 +
    // New issues number on from where the old hub was.
502 +
    let next = db::issues::create(
503 +
        &fresh.app.db,
504 +
        db::issues::NewIssue { repo_id: r2.id, title: "After", body: "", author_id: Some(andy2.id), author: "andy" },
505 +
    )
506 +
    .await
507 +
    .unwrap();
508 +
    assert!(next.number > p.number, "{} after {}", next.number, p.number);
509 +
}
modified docs/backups.md +24 −3
…
3 unchanged lines
4 4
settings, the hourly and daily schedule with "back up now" and TEST,
5 5
directory destinations and keyless GCS destinations, every repository's
6 6
and wiki's store byte for byte, the records as JSON, retention, failure
7 -
notices, and restore of the stores and repository rows
8 -
(`oxygenhub admin backup run|restore`). Next: restoring issues and
9 -
proposals from the records, Admin › Backups, per-repository settings.
7 +
notices, and restore (`oxygenhub admin backup run|restore`). Since
8 +
2026-10-14 (oxygenhub#175) a restore brings back the records too (§7) and
9 +
release assets travel with the stores. Next: Admin › Backups,
10 +
per-repository settings, S3.
10 11
11 12
## 1. Why
12 13
…
139 unchanged lines
152 153
153 154
## 7. Restore
154 155
156 +
**As built (oxygenhub#175).** `oxygenhub admin backup restore --from DIR
157 +
[--run AT]` into a hub that lacks the repositories: each store and wiki
158 +
byte for byte (every file checked against its hash), then the
159 +
repository's row with its settings as they were (visibility, default
160 +
bookmark, Flow and Diffusion settings and the rest), then its records.
161 +
Each run's records carry `_names`, the username, organisation or
162 +
`org/team` behind every account, team and organisation id their rows
163 +
point at, so the restore finds them again by name on any hub. Tables go
164 +
in parents first; every row gets a new id and every reference follows
165 +
it. A reference to an account this hub lacks is left blank where the
166 +
column allows it (the author's name stays in the row) and the row left
167 +
out where it does not (an assignment, a review request); the restore
168 +
says how many. Webhooks come back without their secrets, each named to be
169 +
entered again. Release assets, which live beside the stores rather than
170 +
in them, are sent once each as blobs and written back checked against
171 +
their hash. Search is rebuilt for what came back. Left out on purpose:
172 +
inboxes (notifications), webhook deliveries, runners' declines.
173 +
174 +
The design as first written:
175 +
155 176
`oxygenhub admin restore --from gs://BUCKET/PREFIX [--run TIME] [--repo NAME]`
156 177
(or a directory) on any hub:
157 178
…
19 unchanged lines