Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/src/backup/mod.rs

//! **The operational-state backup: export to text, restore from text.**
//!
//! The standby gunnar machine is reused as the destination: the state is sent
//! away to it, rather than to a backup server of monetize's own.
//!
//! This is the half that reuses what already works. monetize's operational state
//! is small and, exported as text, it is a git repository's natural food: push it
//! to gunnar over the machine principal monetize already holds, and **gunnar's
//! own §11.1 replication carries it to the standby** — measured
//! working, `pending 0, failures 0`. No new box, no new mechanism, no new
//! credential path, and it dogfoods gunnar.
//!
//! # A backup nobody has restored is not a backup
//!
//! That is why [`import`] exists in the same module as [`export`] and is tested
//! by round trip rather than by inspection: export, destroy the stores, import,
//! and assert the LEDGER FOLDS IDENTICALLY — not that the bytes match, which
//! would prove only that a file copy works.
//!
//! # Text, not the redb files
//!
//! Copying `ledger.redb` would be simpler and much worse. A binary blob does not
//! diff, so every snapshot is a whole new object in git history, and nobody can
//! read a backup to see what it holds. As JSON lines an append-only ledger
//! delta-compresses to almost nothing between snapshots, `git log -p` is an audit
//! trail, and a human can answer "was this tenant in the backup" with grep.
//!
//! Output is DETERMINISTIC — every table walked in key order, one record per
//! line. Two exports of unchanged state are byte-identical, so a git-backed
//! backup produces no commit when nothing happened, and a commit means something
//! did.
//!
//! # What is NOT in here, and why the manifest says so out loud
//!
//! No secrets. Not the signing key, not the gunnar identity, not the bearer, not
//! the vendor credentials. Those must not live in a repository that gunnar
//! replicates to a box whose contents cannot currently be verified — anyone with
//! read on that namespace would hold monetize's signing key.
//!
//! It is not, however, free of PERSONAL data, and that is a different word.
//! [`SIGNUPS`] carries the e-mail a customer typed on a vendor's checkout page,
//! because a signup left PENDING is retried against the sink after a restore and
//! the retry cannot ask again without it. A deployment that will not put a
//! customer's address into a namespace gunnar replicates must point
//! `backup_dir` somewhere else — the choice is an operator's, made once, in
//! `monetize.toml`, and it is said here so it is made knowingly.
//!
//! The manifest DOES record the signing key's PUBLIC half, which is not a secret
//! and is what makes a restore honest: every `EntitlementFact` in the ledger was
//! signed by that key, so restoring into a server holding a different one gives a
//! ledger full of facts this server cannot reproduce and products holding facts
//! it cannot re-sign. [`import`] refuses that by default and names it.

use std::path::{Path, PathBuf};

use monetize::ledger::Row;
use monetize::Ledger;

use crate::reconcile::ReconcileStore;
use crate::signup::{SignupRecord, SignupStore};
use crate::state::UsageStore;

/// Files a backup directory holds. Named so a human reading the directory knows
/// what each one is without this doc.
pub const MANIFEST: &str = "MANIFEST.toml";
pub const LEDGER: &str = "ledger.jsonl";
/// **The ledger's current verdicts**, `{product, fact}` per line. Not a fold of
/// [`LEDGER`]: an operator's `Entitlement.Set`, a ladder move and a refund's
/// take-back change a verdict and write no step row, so a restore that
/// re-derived them from the rows gave a refunded customer the room back.
pub const ENTITLEMENTS: &str = "entitlements.jsonl";
/// **The pushes the product has not acknowledged**, `{product, pending}` per
/// line (`monetize::ledger::PendingPush`): the to-do that tells the product
/// what monetize already decided.
pub const PUSHES: &str = "pushes.jsonl";
pub const USAGE: &str = "usage.jsonl";
/// **The paid-signup store.** It is in the backup because it is the
/// IDEMPOTENCY KEY of the money path, not merely a record of it: lose it and
/// the vendor's next retry of a webhook it already delivered creates a second
/// account for one payment. The ledger cannot stand in for it — a signup holds
/// no reference, which is the whole reason it has its own store.
pub const SIGNUPS: &str = "signups.jsonl";
/// **Each vendor's last finished reconciliation pass.** `{"vendor":…,
/// "last_pass_unix_ms":…}` per line. The CASE ROWS of `reconcile.redb` are
/// deliberately not here (see `crate::reconcile`); these stamps are, because
/// they are where the next pass asks the vendor FROM. Without them a restored
/// box asks only `now − 48 h` and every payment the outage swallowed that is
/// older than that is never asked about by anything.
pub const PASSES: &str = "passes.jsonl";
/// **The signup refunds** (`SignupAdmin.Refund` / `Settle`, g-refund):
/// `{vendor, session, refund}` per line. Left out, a restored box put every
/// refunded signup back on the owed-back queue — an alarm about money already
/// given back, and a second refund one click away. (An ORDER's refund is a
/// ledger row and travels in [`LEDGER`].)
pub const REFUNDS: &str = "refunds.jsonl";
/// **The nonpay journal** (`crate::nonpay`): every lapse's fixed deletion date,
/// every warning sent, every cancellation. Left out, a restored front
/// rescheduled every deletion from the restore and mailed every lapsed
/// customer day 0 again. The rows carry their seq; the manifest holds the
/// count and the hash, like every other file's.
pub const NONPAY: &str = "nonpay.jsonl";

/// What an export writes into [`MANIFEST`].
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
    /// The agent string of the build that wrote it.
    pub agent: String,
    /// Rows in [`LEDGER`], readings in [`USAGE`] — so a truncated file is
    /// visible without parsing it.
    pub ledger_rows: usize,
    /// Lines in [`ENTITLEMENTS`] and [`PUSHES`].
    pub entitlements: usize,
    pub pending_pushes: usize,
    pub usage_readings: usize,
    /// Rows in [`SIGNUPS`], [`PASSES`], [`REFUNDS`], [`NONPAY`]. (A backup
    /// written before 2026-10-11 also names `reclaim_rows` and a
    /// `reclaim.jsonl`: the reclamation queue is gone, and both are ignored.)
    pub signups: usize,
    pub vendor_passes: usize,
    pub signup_refunds: usize,
    pub nonpay_rows: usize,
    /// **SHA-256 (hex) of every file**, by name. A count catches a lost line; only a
    /// hash catches a line edited in place. [`import`] refuses a backup whose files
    /// do not match, before it writes anything.
    pub files: std::collections::BTreeMap<String, String>,
    /// Hex of monetize's Ed25519 PUBLIC key. Not a secret. Every fact in the
    /// ledger was signed by its private half, which is why [`import`] compares.
    pub signing_public_key: String,
    /// Stated in the file itself so nobody restores this and wonders why
    /// signing fails.
    pub contains_no_secrets: bool,
}

#[derive(Debug, thiserror::Error)]
pub enum BackupError {
    #[error("backup io at {path}: {source}")]
    Io {
        path: String,
        source: std::io::Error,
    },
    #[error("backup encode/decode: {0}")]
    Codec(String),
    #[error("ledger: {0}")]
    Ledger(#[from] monetize::ledger::LedgerError),
    #[error("usage store: {0}")]
    Usage(String),
    #[error("signup store: {0}")]
    Signups(String),
    #[error(
        "this backup was written by monetize signing key {backup} but this server holds {current}. \
         Every EntitlementFact in it was signed by the backup's key, so restoring here would give a \
         ledger full of facts this server cannot reproduce and products holding facts it cannot \
         re-sign. Restore the key alongside the state, or pass the override if you have decided that \
         re-signing everything is what you want."
    )]
    KeyMismatch { backup: String, current: String },
    #[error("reconcile store: {0}")]
    Reconcile(String),
    #[error("nonpay journal: {0}")]
    Nonpay(String),
    /// A restore lands in empty stores or not at all: interleaving it with live
    /// history would make a state that was never true.
    #[error("restore into a {0} that already holds rows")]
    NotEmpty(&'static str),
    #[error("this server has no backup_dir configured, so there is nowhere to write")]
    NoBackupDir,
}

/// **Every store a backup carries**, borrowed together.
#[derive(Clone, Copy)]
pub struct Stores<'a> {
    pub ledger: &'a Ledger,
    pub usage: &'a UsageStore,
    pub signups: &'a SignupStore,
    pub reconcile: &'a ReconcileStore,
    pub nonpay: &'a crate::nonpay::NonpayJournal,
}

impl<'a> Stores<'a> {
    /// The running server's own stores.
    pub fn of(state: &'a crate::state::AppState) -> Stores<'a> {
        Stores {
            ledger: &state.ledger,
            usage: &state.usage,
            signups: &state.signups,
            reconcile: &state.reconcile,
            nonpay: &state.nonpay,
        }
    }
}

/// Every file a backup holds, in the order [`import`] reads them.
pub const FILES: [&str; 8] = [
    LEDGER,
    ENTITLEMENTS,
    PUSHES,
    USAGE,
    SIGNUPS,
    REFUNDS,
    PASSES,
    NONPAY,
];

fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::Digest;
    sha2::Sha256::digest(bytes)
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect()
}

fn io(path: &Path) -> impl Fn(std::io::Error) -> BackupError + '_ {
    move |source| BackupError::Io {
        path: path.display().to_string(),
        source,
    }
}

/// One exported ledger row. Flat on purpose: `reference` is the key's first half
/// and belongs beside the row, not inside it.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct LedgerLine {
    reference: String,
    #[serde(flatten)]
    row: Row,
}

/// One exported verdict.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct EntitlementLine {
    product: String,
    fact: monetize_product::EntitlementFact,
}

/// One exported pending push.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct PushLine {
    product: String,
    pending: monetize::ledger::PendingPush,
}

/// One exported signup refund.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct RefundLine {
    vendor: String,
    session: String,
    refund: crate::signup::SignupRefund,
}

/// One exported vendor pass stamp.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct PassLine {
    vendor: String,
    last_pass_unix_ms: i64,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct UsageLine {
    product: String,
    tenant: String,
    usage: monetize_product::Usage,
}

/// **One export at a time per backup directory** (A22-O W11, F-T-08): the backup timer and
/// an operator's `Ledger.Export` (or the CLI) into the same directory share every file's
/// temp name, so two at once took each other's temps and a manifest could name another's
/// files. The second waits for the first. Keyed by the directory, so separate servers (and
/// tests) never wait on each other.
fn export_lock(dir: &Path) -> std::sync::Arc<std::sync::Mutex<()>> {
    static LOCKS: std::sync::Mutex<
        std::collections::BTreeMap<std::path::PathBuf, std::sync::Arc<std::sync::Mutex<()>>>,
    > = std::sync::Mutex::new(std::collections::BTreeMap::new());
    let key = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
    LOCKS
        .lock()
        .unwrap_or_else(|p| p.into_inner())
        .entry(key)
        .or_default()
        .clone()
}

/// **Write the operational state to `dir` as text.** Deterministic: the same
/// state exports to the same bytes. One export at a time per directory ([`export_lock`]).
pub fn export(
    dir: &Path,
    stores: &Stores<'_>,
    signing_public_key: &str,
) -> Result<Manifest, BackupError> {
    std::fs::create_dir_all(dir).map_err(io(dir))?;
    let lock = export_lock(dir);
    let _one = lock.lock().unwrap_or_else(|p| p.into_inner());
    let Stores {
        ledger,
        usage,
        signups,
        reconcile,
        nonpay,
    } = *stores;
    std::fs::create_dir_all(dir).map_err(io(dir))?;
    let mut files = std::collections::BTreeMap::new();
    let mut file = |name: &str, text: &str| -> Result<(), BackupError> {
        write(&dir.join(name), text)?;
        files.insert(name.to_owned(), sha256_hex(text.as_bytes()));
        Ok(())
    };

    // The ledger's three tables at ONE instant (see [`Ledger::snapshot`]).
    // The other stores are files of their own and are read one after another.
    let monetize::ledger::LedgerSnapshot {
        rows,
        entitlements,
        pushes,
    } = ledger.snapshot()?;
    let mut ledger_text = String::new();
    for (reference, row) in &rows {
        let line = LedgerLine {
            reference: reference.clone(),
            row: row.clone(),
        };
        ledger_text.push_str(
            &serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        ledger_text.push('\n');
    }
    file(LEDGER, &ledger_text)?;

    let mut text = String::new();
    for (product, fact) in &entitlements {
        text.push_str(
            &serde_json::to_string(&EntitlementLine {
                product: product.clone(),
                fact: fact.clone(),
            })
            .map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        text.push('\n');
    }
    file(ENTITLEMENTS, &text)?;

    let mut text = String::new();
    for (product, pending) in &pushes {
        text.push_str(
            &serde_json::to_string(&PushLine {
                product: product.clone(),
                pending: pending.clone(),
            })
            .map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        text.push('\n');
    }
    file(PUSHES, &text)?;

    let readings = usage
        .export_all()
        .map_err(|e| BackupError::Usage(e.to_string()))?;
    let mut usage_text = String::new();
    for (product, tenant, u) in &readings {
        let line = UsageLine {
            product: product.clone(),
            tenant: tenant.clone(),
            usage: u.clone(),
        };
        usage_text.push_str(
            &serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        usage_text.push('\n');
    }
    file(USAGE, &usage_text)?;

    // The signups. One record per line, in key order, whole — including the
    // e-mail, which is the one place in this backup it appears. It has to be:
    // a PENDING signup is retried against the sink after a restore, and the
    // retry cannot ask again without the thing it was asked about. That is a
    // customer's address in a repository gunnar replicates, and it is stated
    // here rather than discovered later; a deployment that will not have that
    // must not point `backup_dir` at a replicated namespace.
    let records = signups
        .export_all()
        .map_err(|e| BackupError::Signups(e.to_string()))?;
    let mut signup_text = String::new();
    for rec in &records {
        signup_text
            .push_str(&serde_json::to_string(rec).map_err(|e| BackupError::Codec(e.to_string()))?);
        signup_text.push('\n');
    }
    file(SIGNUPS, &signup_text)?;

    // The vendor pass stamps, in key order.
    let passes = reconcile
        .export_passes()
        .map_err(|e| BackupError::Reconcile(e.to_string()))?;
    let mut passes_text = String::new();
    for (vendor, at) in &passes {
        let line = PassLine {
            vendor: vendor.clone(),
            last_pass_unix_ms: *at,
        };
        passes_text.push_str(
            &serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        passes_text.push('\n');
    }
    file(PASSES, &passes_text)?;

    // The signup refunds, in key order.
    let refunds = signups
        .export_refunds()
        .map_err(|e| BackupError::Signups(e.to_string()))?;
    let mut refunds_text = String::new();
    for (vendor, session, refund) in &refunds {
        let line = RefundLine {
            vendor: vendor.clone(),
            session: session.clone(),
            refund: refund.clone(),
        };
        refunds_text.push_str(
            &serde_json::to_string(&line).map_err(|e| BackupError::Codec(e.to_string()))?,
        );
        refunds_text.push('\n');
    }
    file(REFUNDS, &refunds_text)?;

    // The nonpay journal, whole and in file order; its rows carry their seq.
    let nonpay_rows = nonpay.entries();
    let mut nonpay_text = String::new();
    for e in &nonpay_rows {
        nonpay_text
            .push_str(&serde_json::to_string(e).map_err(|e| BackupError::Codec(e.to_string()))?);
        nonpay_text.push('\n');
    }
    file(NONPAY, &nonpay_text)?;

    // The price lists and the policy are `monetize.toml`'s — an operator
    // document, backed up by hand with the config; there is no store of them
    // here to export. (The plan catalogue's store, `plans.toml`, went with the
    // catalogue on 2026-09-05; a backup directory holding one is old.)
    let _ = std::fs::remove_file(dir.join("plans.toml"));

    let manifest = Manifest {
        agent: monetize_proto::AGENT.to_string(),
        ledger_rows: rows.len(),
        entitlements: entitlements.len(),
        pending_pushes: pushes.len(),
        usage_readings: readings.len(),
        signups: records.len(),
        vendor_passes: passes.len(),
        signup_refunds: refunds.len(),
        nonpay_rows: nonpay_rows.len(),
        files,
        signing_public_key: signing_public_key.to_owned(),
        contains_no_secrets: true,
    };
    let header = "# monetize OPERATIONAL-STATE backup. Text on purpose: an append-only ledger\n\
                  # delta-compresses in git and `git log -p` is an audit trail.\n\
                  #\n\
                  # IT CONTAINS NO SECRETS — not the signing key, not a product's identity, not the\n\
                  # bearer, not a vendor credential. Those must never enter a repository that a forge\n\
                  # replicates, and they are backed up separately by hand.\n\
                  #\n\
                  # `signing_public_key` is the PUBLIC half, recorded so a restore can tell whether\n\
                  # the key that signed this ledger is the key the target server holds.\n";
    let body = toml::to_string_pretty(&manifest).map_err(|e| BackupError::Codec(e.to_string()))?;
    write(&dir.join(MANIFEST), &format!("{header}{body}"))?;
    Ok(manifest)
}

/// Write only when the content differs, so an unchanged export does not touch
/// mtimes — a git-backed backup then has nothing to commit and nothing to push.
fn write(path: &Path, content: &str) -> Result<(), BackupError> {
    if std::fs::read_to_string(path).is_ok_and(|old| old == content) {
        return Ok(());
    }
    // A sibling temp, fsync, rename over, fsync the directory: a crash leaves
    // the last backup's file or this one, never half of either.
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    let tmp = path.with_file_name(format!("{name}.tmp"));
    let written = std::fs::File::create(&tmp)
        .map_err(io(&tmp))
        .and_then(|mut f| {
            put(&mut f, content.as_bytes(), &tmp)?;
            f.sync_all().map_err(io(&tmp))
        });
    if let Err(e) = written {
        let _ = std::fs::remove_file(&tmp);
        return Err(e);
    }
    std::fs::rename(&tmp, path).map_err(io(path))?;
    if let Some(dir) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
        std::fs::File::open(dir)
            .and_then(|d| d.sync_all())
            .map_err(io(dir))?;
    }
    Ok(())
}

fn put(f: &mut std::fs::File, bytes: &[u8], path: &Path) -> Result<(), BackupError> {
    use std::io::Write;
    #[cfg(test)]
    if let Some(n) = CRASH_AFTER.with(|c| c.take()) {
        let _ = f.write_all(&bytes[..n.min(bytes.len())]);
        return Err(io(path)(std::io::Error::other("crashed mid-write (test)")));
    }
    f.write_all(bytes).map_err(io(path))
}

#[cfg(test)]
thread_local! {
    /// Tests: the next file written stops after this many bytes, as a crash would.
    static CRASH_AFTER: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}

/// **Read a backup back into empty stores — all of it, or none of it.**
///
/// Two phases. FIRST every file is read, its SHA-256 checked against the
/// manifest, every line parsed and every count checked, and every target store
/// checked empty; any refusal here has written nothing. THEN the stores are
/// written. What can still fail in the second phase is the disk itself, not the
/// backup.
///
/// `allow_key_mismatch` exists because the honest default is to refuse: see
/// [`BackupError::KeyMismatch`]. Passing it is a decision, not a convenience.
pub fn import(
    dir: &Path,
    stores: &Stores<'_>,
    current_signing_public_key: &str,
    allow_key_mismatch: bool,
) -> Result<Manifest, BackupError> {
    let Stores {
        ledger,
        usage,
        signups,
        reconcile,
        nonpay,
    } = *stores;
    let manifest = read_manifest(dir)?;

    if manifest.signing_public_key != current_signing_public_key && !allow_key_mismatch {
        return Err(BackupError::KeyMismatch {
            backup: manifest.signing_public_key.clone(),
            current: current_signing_public_key.to_owned(),
        });
    }

    // Phase 1: read and check everything.
    let Contents {
        rows,
        entitlements,
        pushes,
        readings,
        records,
        refunds,
        passes,
        nonpay_rows,
    } = read_contents(dir, &manifest)?;

    let held = ledger.snapshot()?;
    if !(held.rows.is_empty() && held.entitlements.is_empty() && held.pushes.is_empty()) {
        return Err(BackupError::NotEmpty("ledger"));
    }
    if !signups
        .export_all()
        .map_err(|e| BackupError::Signups(e.to_string()))?
        .is_empty()
        || !signups
            .export_refunds()
            .map_err(|e| BackupError::Signups(e.to_string()))?
            .is_empty()
    {
        return Err(BackupError::NotEmpty("signup store"));
    }
    if !nonpay.entries().is_empty() {
        return Err(BackupError::NotEmpty("nonpay journal"));
    }

    // Phase 2: write.
    ledger.restore(&rows, &entitlements, &pushes)?;
    for l in &readings {
        usage
            .put(&l.product, &l.tenant, &l.usage)
            .map_err(|e| BackupError::Usage(e.to_string()))?;
    }
    signups
        .restore(&records)
        .map_err(|e| BackupError::Signups(e.to_string()))?;
    for r in &refunds {
        signups
            .put_refund(&r.vendor, &r.session, &r.refund)
            .map_err(|e| BackupError::Signups(e.to_string()))?;
    }
    reconcile
        .restore_passes(&passes)
        .map_err(|e| BackupError::Reconcile(e.to_string()))?;
    nonpay
        .restore(&nonpay_rows)
        .map_err(|e| BackupError::Nonpay(e.to_string()))?;
    Ok(manifest)
}

/// [`MANIFEST`], parsed.
fn read_manifest(dir: &Path) -> Result<Manifest, BackupError> {
    let manifest_path = dir.join(MANIFEST);
    toml::from_str(&std::fs::read_to_string(&manifest_path).map_err(io(&manifest_path))?)
        .map_err(|e| BackupError::Codec(format!("{}: {e}", manifest_path.display())))
}

/// **Everything a backup holds**, read whole: every file's hash checked
/// against the manifest, every line parsed, every count checked. Shared by
/// [`import`]; nothing here writes.
struct Contents {
    rows: Vec<(String, Row)>,
    entitlements: Vec<(String, monetize_product::EntitlementFact)>,
    pushes: Vec<(String, monetize::ledger::PendingPush)>,
    readings: Vec<UsageLine>,
    records: Vec<SignupRecord>,
    refunds: Vec<RefundLine>,
    passes: Vec<(String, i64)>,
    nonpay_rows: Vec<crate::nonpay::Entry>,
}

fn read_contents(dir: &Path, manifest: &Manifest) -> Result<Contents, BackupError> {
    let read = |name: &str| read_file(dir, manifest, name);
    Ok(Contents {
        rows: lines::<LedgerLine>(&read(LEDGER)?, manifest.ledger_rows, "rows")?
            .into_iter()
            .map(|l| (l.reference, l.row))
            .collect(),
        entitlements: lines::<EntitlementLine>(
            &read(ENTITLEMENTS)?,
            manifest.entitlements,
            "verdict(s)",
        )?
        .into_iter()
        .map(|l| (l.product, l.fact))
        .collect(),
        pushes: lines::<PushLine>(&read(PUSHES)?, manifest.pending_pushes, "pending push(es)")?
            .into_iter()
            .map(|l| (l.product, l.pending))
            .collect(),
        readings: lines::<UsageLine>(&read(USAGE)?, manifest.usage_readings, "readings")?,
        records: lines::<SignupRecord>(&read(SIGNUPS)?, manifest.signups, "signups")?,
        refunds: lines::<RefundLine>(&read(REFUNDS)?, manifest.signup_refunds, "refund(s)")?,
        passes: lines::<PassLine>(&read(PASSES)?, manifest.vendor_passes, "vendor pass(es)")?
            .into_iter()
            .map(|p| (p.vendor, p.last_pass_unix_ms))
            .collect(),
        nonpay_rows: lines::<crate::nonpay::Entry>(
            &read(NONPAY)?,
            manifest.nonpay_rows,
            "nonpay rows",
        )?,
    })
}

/// One backup file as read: its text, and whether its SHA-256 is the one the
/// manifest holds.
struct Read {
    path: PathBuf,
    text: String,
    hash_matches: bool,
}

fn read_file(dir: &Path, manifest: &Manifest, name: &str) -> Result<Read, BackupError> {
    let path = dir.join(name);
    let want = manifest.files.get(name).ok_or_else(|| {
        BackupError::Codec(format!(
            "the manifest has no hash for {name}: it is not a whole backup"
        ))
    })?;
    let bytes = std::fs::read(&path).map_err(io(&path))?;
    let hash_matches = &sha256_hex(&bytes) == want;
    let text = String::from_utf8(bytes)
        .map_err(|e| BackupError::Codec(format!("{}: {e}", path.display())))?;
    Ok(Read {
        path,
        text,
        hash_matches,
    })
}

/// Every line of a read file, parsed; refused unless there are exactly
/// `expected` and the hash matches. The count is checked first because it says
/// more: a truncated file is the failure most likely to go unnoticed. The hash
/// then catches a line edited in place, which keeps the count.
fn lines<T: serde::de::DeserializeOwned>(
    read: &Read,
    expected: usize,
    what: &str,
) -> Result<Vec<T>, BackupError> {
    let path = read.path.display();
    let mut out = Vec::new();
    for (n, line) in read
        .text
        .lines()
        .enumerate()
        .filter(|(_, l)| !l.trim().is_empty())
    {
        out.push(
            serde_json::from_str(line)
                .map_err(|e| BackupError::Codec(format!("{path}:{}: {e}", n + 1)))?,
        );
    }
    if out.len() != expected {
        return Err(BackupError::Codec(format!(
            "{path} holds {} {what} but the manifest says {expected}: the backup is truncated or the manifest is stale",
            out.len()
        )));
    }
    if !read.hash_matches {
        return Err(BackupError::Codec(format!("{path} does not match the manifest's hash: the file was changed or damaged after the export")));
    }
    Ok(out)
}

/// **One tick of the backup timer** (gap G17), and the body of `Ledger.Export`:
/// the whole state into the server's own `backup_dir`.
pub fn scheduled_export(state: &crate::state::AppState) -> Result<Manifest, BackupError> {
    let dir = state
        .backup_dir
        .as_deref()
        .ok_or(BackupError::NoBackupDir)?;
    let pubkey: String = state
        .keys
        .root()
        .public_key()
        .iter()
        .map(|b| format!("{b:02x}"))
        .collect();
    export(dir, &Stores::of(state), &pubkey)
}

/// **The backup timer** (gap G17): [`scheduled_export`] every `every`, the first
/// one `every` after start. Each tick says how it went on the functional status.
pub async fn run(state: std::sync::Arc<crate::state::AppState>, every: std::time::Duration) {
    loop {
        tokio::time::sleep(every).await;
        let s = state.clone();
        let (well, line) = match tokio::task::spawn_blocking(move || scheduled_export(&s)).await {
            Ok(Ok(m)) => (
                true,
                format!(
                    "{} ledger row(s), {} signup(s), {} nonpay row(s)",
                    m.ledger_rows, m.signups, m.nonpay_rows
                ),
            ),
            Ok(Err(e)) => (false, e.to_string()),
            Err(e) => (false, format!("the export task died: {e}")),
        };
        if !well {
            eprintln!("monetize-server/backup: scheduled export FAILED: {line}");
        }
        skidbladnir::functional_status("monetize-server/backup", "scheduled", well, &line);
    }
}

#[cfg(test)]
mod tests;