Skip to main content

acme_proxy_store/
transfer.rs

1//! Copying every row from one backend to the other.
2//!
3//! `database.url`'s scheme picks the backend ([ADR
4//! 0014](../../../doc/src/dev/adr/0014-postgresql-beside-sqlite.md)), but an
5//! operator moving from a SQLite file to a PostgreSQL cluster cannot start
6//! empty: the order row is a certificate's only record, so a deployment that
7//! left it behind would leave every certificate it has ever issued impossible
8//! to revoke. That is the outcome `admin::live_certificates_refusal` exists to
9//! prevent, and this module is how the other half of it is avoided.
10//!
11//! ## What it is not
12//!
13//! Not a backup, not an upgrade, and **not safe against a running server**.
14//! Nothing here can detect one — a worker mid-issuance writes rows this copy
15//! has already walked past, and the result is a torn snapshot that looks fine.
16//! The caller asks the operator; `acme-proxy transfer` states it in the prompt.
17//!
18//! ## The manifest, and why it is declared
19//!
20//! [`TABLES`] names all 140 columns of all 15 tables, in dependency order. It
21//! is declared rather than read from the source at copy time because the seam
22//! decodes into a *known* Rust type — there is no "read this column as
23//! whatever it is", and inventing one would mean deciding at runtime whether
24//! SQLite's untyped BLOB is a `bytea` or a `uuid`, which is precisely the
25//! ambiguity [`crate::sql::NullKind`] exists to avoid.
26//!
27//! The declaration's hazard is the one ADR 0003 names for a table rebuild: *a
28//! forgotten column is dropped silently*. `the_manifest_names_every_column`
29//! answers it the same way, by introspecting the live schema on both backends
30//! and refusing a manifest that has drifted from it. **A migration that adds a
31//! column must add it here**, and that test is what says so.
32//!
33//! ## Order, and the two things that are not just rows
34//!
35//! Tables are copied parents first, because the six foreign keys are enforced
36//! on both backends — SQLite's because `open_sqlite` pins `foreign_keys(true)`.
37//!
38//! `audit_log.id` is the exception to "a row is a row". It is
39//! `GENERATED ALWAYS AS IDENTITY` on PostgreSQL, so an explicit id needs
40//! `OVERRIDING SYSTEM VALUE`, and the identity sequence has to be advanced
41//! afterwards or the first audit row written after the transfer collides. The
42//! id is one an operator types (`acme-proxy audit show <id>`), so preserving it
43//! is not optional.
44
45use crate::db::Database;
46use crate::sql::{self, Dialect, Value};
47
48/// The Rust type a column's values are carried as.
49///
50/// One per [`Value`] variant that a column can hold. The copy reads
51/// `Option<T>` and writes the `Value` back, so a `NULL` keeps the type its
52/// column declared and PostgreSQL accepts it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ColumnKind {
55    Uuid,
56    Text,
57    Blob,
58    I64,
59    Bool,
60}
61
62/// One table, and every column of it.
63#[derive(Debug, Clone, Copy)]
64pub struct TableSpec {
65    /// The table's name, which is also its name in both dialects.
66    pub name: &'static str,
67    /// The column(s) a batch is ordered and resumed by. Every table has a
68    /// primary key; `revocations` is the only composite one.
69    pub key: &'static [&'static str],
70    /// Every column, in the order the schema declares them.
71    pub columns: &'static [(&'static str, ColumnKind)],
72}
73
74use ColumnKind::{Blob, Bool, I64, Text, Uuid};
75
76/// Every table, **parents before children**.
77///
78/// The six foreign keys are `orders → accounts`, `authorizations → orders`,
79/// `challenges → authorizations`, `upstream_orders → orders`, and
80/// `admin_sessions`/`admin_recovery_codes → admin_users`. `_sqlx_migrations`
81/// is deliberately absent: each backend owns its own set and its own
82/// checksums, and copying one over the other would make the target's schema
83/// claim a history it does not have.
84pub const TABLES: &[TableSpec] = &[
85    // --- no parents -------------------------------------------------------
86    TableSpec {
87        name: "nonces",
88        key: &["value"],
89        columns: &[("value", Text), ("created_at", I64)],
90    },
91    TableSpec {
92        name: "eab_keys",
93        key: &["kid"],
94        columns: &[
95            ("kid", Uuid),
96            ("secret", Blob),
97            ("label", Text),
98            ("profile", Text),
99            ("status", Text),
100            ("created_at", I64),
101        ],
102    },
103    TableSpec {
104        name: "jobs",
105        key: &["id"],
106        columns: &[
107            ("id", Uuid),
108            ("kind", Text),
109            ("dedup_key", Text),
110            ("payload", Text),
111            ("status", Text),
112            ("run_at", I64),
113            ("attempts", I64),
114            ("max_attempts", I64),
115            ("deadline", I64),
116            ("lease_until", I64),
117            ("lease_owner", Text),
118            ("last_error", Text),
119            ("created_at", I64),
120            ("updated_at", I64),
121        ],
122    },
123    TableSpec {
124        name: "audit_log",
125        key: &["id"],
126        columns: &[
127            ("id", I64),
128            ("created_at", I64),
129            ("event", Text),
130            ("outcome", Text),
131            ("profile", Text),
132            ("actor_kind", Text),
133            ("actor_id", Text),
134            ("account_id", Text),
135            ("order_id", Text),
136            ("cert_serial", Text),
137            ("identifiers", Text),
138            ("client_ip", Text),
139            ("client_ptr", Text),
140            ("user_agent", Text),
141            ("request_id", Text),
142            ("reason", Text),
143            ("detail", Text),
144        ],
145    },
146    TableSpec {
147        name: "revocations",
148        key: &["issuer", "serial"],
149        columns: &[
150            ("issuer", Text),
151            ("serial", Text),
152            ("revoked_at", I64),
153            ("reason", I64),
154            ("not_after", I64),
155        ],
156    },
157    TableSpec {
158        name: "crls",
159        key: &["issuer"],
160        columns: &[
161            ("issuer", Text),
162            ("crl_number", I64),
163            ("der", Blob),
164            ("this_update", I64),
165            ("next_update", I64),
166        ],
167    },
168    TableSpec {
169        name: "http01_tokens",
170        key: &["token"],
171        columns: &[
172            ("token", Text),
173            ("key_authorization", Text),
174            ("created_at", I64),
175            ("expires_at", I64),
176        ],
177    },
178    TableSpec {
179        name: "admin_users",
180        key: &["id"],
181        columns: &[
182            ("id", Uuid),
183            ("username", Text),
184            ("password_hash", Text),
185            ("status", Text),
186            ("totp_secret", Blob),
187            ("totp_pending_secret", Blob),
188            ("totp_last_step", I64),
189            ("created_at", I64),
190            ("updated_at", I64),
191            ("last_login_at", I64),
192            ("role", Text),
193            ("contact_email", Text),
194            ("known_login_ips", Text),
195        ],
196    },
197    // --- children ---------------------------------------------------------
198    TableSpec {
199        name: "accounts",
200        key: &["id"],
201        columns: &[
202            ("id", Uuid),
203            ("profile", Text),
204            ("pubkey", Blob),
205            ("contact", Text),
206            ("status", Text),
207            ("created_at", I64),
208            ("created_ip", Text),
209            ("created_ptr", Text),
210            ("last_seen_at", I64),
211            ("last_seen_ip", Text),
212            ("last_seen_ptr", Text),
213            ("eab_kid", Uuid),
214            ("terms_of_service_agreed", Bool),
215        ],
216    },
217    TableSpec {
218        name: "orders",
219        key: &["id"],
220        columns: &[
221            ("id", Uuid),
222            ("profile", Text),
223            ("account_id", Uuid),
224            ("status", Text),
225            ("identifiers", Text),
226            ("expires", I64),
227            ("not_before", I64),
228            ("not_after", I64),
229            ("error", Text),
230            ("certificate", Text),
231            ("replaces", Text),
232            ("created_at", I64),
233            ("created_ip", Text),
234            ("created_ptr", Text),
235            ("cert_serial", Text),
236            ("cert_pubkey", Blob),
237            ("revoked_at", I64),
238            ("revocation_reason", I64),
239            ("cert_not_after", I64),
240        ],
241    },
242    TableSpec {
243        name: "authorizations",
244        key: &["id"],
245        columns: &[
246            ("id", Uuid),
247            ("order_id", Uuid),
248            ("identifier", Text),
249            ("status", Text),
250            ("expires", I64),
251            ("created_at", I64),
252        ],
253    },
254    TableSpec {
255        name: "challenges",
256        key: &["id"],
257        columns: &[
258            ("id", Uuid),
259            ("authz_id", Uuid),
260            ("type", Text),
261            ("token", Text),
262            ("status", Text),
263            ("validated", I64),
264            ("created_at", I64),
265            ("error", Text),
266        ],
267    },
268    TableSpec {
269        name: "upstream_orders",
270        key: &["order_id"],
271        columns: &[
272            ("order_id", Uuid),
273            ("upstream_order_url", Text),
274            ("upstream_finalize_url", Text),
275            ("upstream_certificate_url", Text),
276            ("csr_der", Blob),
277            ("status", Text),
278            ("error", Text),
279            ("created_at", I64),
280            ("updated_at", I64),
281            ("client_ip", Text),
282            ("client_ptr", Text),
283            ("user_agent", Text),
284            ("request_id", Text),
285        ],
286    },
287    TableSpec {
288        name: "admin_sessions",
289        key: &["token_hash"],
290        columns: &[
291            ("token_hash", Text),
292            ("user_id", Uuid),
293            ("csrf_token", Text),
294            ("state", Text),
295            ("mfa_attempts", I64),
296            ("created_at", I64),
297            ("expires_at", I64),
298            ("last_seen_at", I64),
299            ("created_ip", Text),
300            ("user_agent", Text),
301        ],
302    },
303    TableSpec {
304        name: "admin_recovery_codes",
305        key: &["id"],
306        columns: &[
307            ("id", Uuid),
308            ("user_id", Uuid),
309            ("code_hash", Text),
310            ("created_at", I64),
311            ("used_at", I64),
312        ],
313    },
314];
315
316/// How many rows are read and written per round trip.
317///
318/// The copy streams by key rather than by `OFFSET`, which over a large
319/// `audit_log` would be quadratic.
320const BATCH: i64 = 1000;
321
322/// What a table contributed, for the caller to print.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct TableCount {
325    pub table: &'static str,
326    pub rows: u64,
327}
328
329/// What the whole transfer moved.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct TransferReport {
332    pub tables: Vec<TableCount>,
333}
334
335impl TransferReport {
336    /// Every row copied, across every table.
337    #[must_use]
338    pub fn total(&self) -> u64 {
339        self.tables.iter().map(|table| table.rows).sum()
340    }
341}
342
343/// The tables of `database` that already hold rows.
344///
345/// What a caller checks before offering to copy into it. Returning the names
346/// rather than a bool is deliberate: "the target is not empty" is not a
347/// message an operator can act on, and "`accounts` holds 4 rows" is.
348pub async fn non_empty_tables(database: &Database) -> Result<Vec<TableCount>, sqlx::Error> {
349    let mut found = Vec::new();
350    for table in TABLES {
351        let rows = count(table.name, database).await?;
352        if rows > 0 {
353            found.push(TableCount {
354                table: table.name,
355                rows,
356            });
357        }
358    }
359    Ok(found)
360}
361
362/// How many rows `table` holds.
363async fn count(table: &'static str, database: &Database) -> Result<u64, sqlx::Error> {
364    let sql = format!("SELECT COUNT(*) FROM {table};");
365    let count: i64 = sql::query(sqlx::AssertSqlSafe(sql))
366        .fetch_one(database)
367        .await?
368        .try_get(0usize)?;
369    Ok(u64::try_from(count).unwrap_or(0))
370}
371
372impl Database {
373    /// Copies every row of this database into `target`.
374    ///
375    /// The whole copy is **one transaction on the target**, so a failure
376    /// anywhere leaves it exactly as it was rather than half-populated. The
377    /// source is only read.
378    ///
379    /// The caller is responsible for the two things this cannot check: that
380    /// `target`'s schema is current (ask [`Database::pending_migrations`]) and
381    /// that nothing is writing to the source. See the module doc.
382    pub async fn transfer_to(&self, target: &Database) -> Result<TransferReport, sqlx::Error> {
383        let mut tx = target.write_transaction().await?;
384        let mut tables = Vec::with_capacity(TABLES.len());
385
386        for spec in TABLES {
387            let rows = copy_table(spec, self, &mut tx).await?;
388            tables.push(TableCount {
389                table: spec.name,
390                rows,
391            });
392        }
393
394        // The identity sequence has to catch up with the ids just inserted, or
395        // the first audit row written after the transfer collides on the
396        // primary key. Inside the transaction, so a rollback takes it too.
397        if target.dialect() == Dialect::Postgres {
398            sql::query(
399                "SELECT setval(pg_get_serial_sequence('audit_log', 'id'), \
400                 (SELECT COALESCE(MAX(id), 1) FROM audit_log));",
401            )
402            .fetch_one(tx.conn())
403            .await?;
404        }
405
406        tx.commit().await?;
407        Ok(TransferReport { tables })
408    }
409}
410
411/// Reads `spec` from `source` in key order and writes it to `tx`.
412async fn copy_table(
413    spec: &TableSpec,
414    source: &Database,
415    tx: &mut crate::db::Tx,
416) -> Result<u64, sqlx::Error> {
417    let names = spec
418        .columns
419        .iter()
420        .map(|(name, _)| quote(name))
421        .collect::<Vec<_>>()
422        .join(", ");
423    let order = spec
424        .key
425        .iter()
426        .map(|name| quote(name))
427        .collect::<Vec<_>>()
428        .join(", ");
429
430    let mut after: Option<Vec<Value>> = None;
431    let mut copied = 0u64;
432
433    loop {
434        let batch = read_batch(spec, source, &names, &order, after.as_deref()).await?;
435        if batch.is_empty() {
436            return Ok(copied);
437        }
438
439        after = Some(key_of(spec, batch.last().expect("the batch is not empty")));
440        copied += batch.len() as u64;
441        write_batch(spec, &names, &batch, tx).await?;
442
443        if batch.len() < usize::try_from(BATCH).unwrap_or(usize::MAX) {
444            return Ok(copied);
445        }
446    }
447}
448
449/// One batch of rows, resumed after `after`'s key.
450///
451/// Keyset pagination rather than `LIMIT`/`OFFSET`: the tables this walks
452/// include `audit_log`, where an offset scan would re-read everything already
453/// copied on every batch. `(a, b) > (?, ?)` row comparison is the one spelling
454/// both dialects accept for the composite key.
455async fn read_batch(
456    spec: &TableSpec,
457    source: &Database,
458    names: &str,
459    order: &str,
460    after: Option<&[Value]>,
461) -> Result<Vec<Vec<Value>>, sqlx::Error> {
462    let table = spec.name;
463    let where_clause = match after {
464        None => String::new(),
465        Some(_) => {
466            let markers = vec!["?"; spec.key.len()].join(", ");
467            match spec.key.len() {
468                1 => format!(" WHERE {order} > {markers}"),
469                _ => format!(" WHERE ({order}) > ({markers})"),
470            }
471        }
472    };
473    let sql = format!("SELECT {names} FROM {table}{where_clause} ORDER BY {order} LIMIT {BATCH};");
474
475    let mut query = sql::query(sqlx::AssertSqlSafe(sql));
476    for value in after.unwrap_or(&[]) {
477        query = query.bind(value.clone());
478    }
479
480    let rows = query.fetch_all(source).await?;
481    rows.iter()
482        .map(|row| {
483            spec.columns
484                .iter()
485                .map(|(name, kind)| read(row, name, *kind))
486                .collect::<Result<Vec<_>, _>>()
487        })
488        .collect()
489}
490
491/// Writes one batch as a single multi-row `INSERT`.
492async fn write_batch(
493    spec: &TableSpec,
494    names: &str,
495    batch: &[Vec<Value>],
496    tx: &mut crate::db::Tx,
497) -> Result<(), sqlx::Error> {
498    let table = spec.name;
499    let row = format!("({})", vec!["?"; spec.columns.len()].join(", "));
500    let values = vec![row; batch.len()].join(", ");
501
502    // `audit_log.id` is `GENERATED ALWAYS AS IDENTITY` on PostgreSQL, which
503    // refuses an explicit id without this. The id is one an operator types, so
504    // it is not the server's to reassign.
505    let overriding = match (tx.conn().dialect(), table) {
506        (Dialect::Postgres, "audit_log") => " OVERRIDING SYSTEM VALUE",
507        _ => "",
508    };
509
510    let sql = format!("INSERT INTO {table} ({names}){overriding} VALUES {values};");
511    let mut query = sql::query(sqlx::AssertSqlSafe(sql));
512    for row in batch {
513        for value in row {
514            query = query.bind(value.clone());
515        }
516    }
517    query.execute(tx.conn()).await?;
518    Ok(())
519}
520
521/// The key columns of one already-read row, for the next batch to resume from.
522fn key_of(spec: &TableSpec, row: &[Value]) -> Vec<Value> {
523    spec.key
524        .iter()
525        .map(|key| {
526            let index = spec
527                .columns
528                .iter()
529                .position(|(name, _)| name == key)
530                .expect("a key column is one of the table's columns");
531            row[index].clone()
532        })
533        .collect()
534}
535
536/// Reads one column at the type its [`ColumnKind`] declares.
537fn read(row: &sql::Row, name: &str, kind: ColumnKind) -> Result<Value, sqlx::Error> {
538    Ok(match kind {
539        ColumnKind::Uuid => match row.try_get::<Option<uuid::Uuid>>(name)? {
540            Some(value) => Value::Uuid(value),
541            None => Value::Null(sql::NullKind::Uuid),
542        },
543        ColumnKind::Text => match row.try_get::<Option<String>>(name)? {
544            Some(value) => Value::Text(value),
545            None => Value::Null(sql::NullKind::Text),
546        },
547        ColumnKind::Blob => match row.try_get::<Option<Vec<u8>>>(name)? {
548            Some(value) => Value::Blob(value),
549            None => Value::Null(sql::NullKind::Blob),
550        },
551        ColumnKind::I64 => match row.try_get::<Option<i64>>(name)? {
552            Some(value) => Value::I64(value),
553            None => Value::Null(sql::NullKind::I64),
554        },
555        ColumnKind::Bool => match row.try_get::<Option<bool>>(name)? {
556            Some(value) => Value::Bool(value),
557            None => Value::Null(sql::NullKind::Bool),
558        },
559    })
560}
561
562/// Quotes an identifier, in the one spelling both dialects share.
563///
564/// `challenges.type` and `jobs.kind` are not reserved in either, but the copy
565/// builds its own SQL and a column named by the schema should not have to be
566/// checked against two keyword lists.
567fn quote(name: &str) -> String {
568    format!("\"{name}\"")
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use std::sync::Arc;
575
576    /// Every column of every table is in the manifest, and nothing else is.
577    ///
578    /// The guard the whole module rests on. A manifest that has fallen behind
579    /// the schema does not fail — it *succeeds*, having silently left a column
580    /// behind, which is ADR 0003's stated hazard for a table rebuild wearing
581    /// different clothes. So the manifest is checked against the live schema
582    /// rather than trusted, on whichever backend the test is running.
583    ///
584    /// Add a migration that adds a column and this is what tells you the copy
585    /// needs to know about it.
586    #[tokio::test]
587    async fn the_manifest_names_every_column() {
588        let database = Database::connect_for_test().await.unwrap();
589
590        for spec in TABLES {
591            let declared: Vec<&str> = spec.columns.iter().map(|(name, _)| *name).collect();
592            let live = live_columns(&database, spec.name).await;
593            assert_eq!(
594                live, declared,
595                "the manifest for `{}` has drifted from the schema; a column \
596                 missing here is a column the transfer would drop",
597                spec.name
598            );
599        }
600    }
601
602    /// And every table is in the manifest.
603    ///
604    /// The column check above only looks at tables the manifest already names,
605    /// so a whole new table would slip past it.
606    #[tokio::test]
607    async fn the_manifest_names_every_table() {
608        let database = Database::connect_for_test().await.unwrap();
609
610        let mut live = live_tables(&database).await;
611        let mut declared: Vec<String> = TABLES.iter().map(|s| s.name.to_string()).collect();
612        live.sort();
613        declared.sort();
614
615        assert_eq!(
616            live, declared,
617            "every table but `_sqlx_migrations` is copied; one missing here is \
618             one the transfer would leave behind"
619        );
620    }
621
622    /// A parent is always copied before its children.
623    ///
624    /// Both backends enforce the six foreign keys, so an order inserted before
625    /// its account fails. Checked against the manifest's own order rather than
626    /// the schema's, since that order is the thing under test.
627    #[test]
628    fn the_manifest_is_in_dependency_order() {
629        const EDGES: &[(&str, &str)] = &[
630            ("orders", "accounts"),
631            ("authorizations", "orders"),
632            ("challenges", "authorizations"),
633            ("upstream_orders", "orders"),
634            ("admin_sessions", "admin_users"),
635            ("admin_recovery_codes", "admin_users"),
636        ];
637
638        let position = |name: &str| {
639            TABLES
640                .iter()
641                .position(|spec| spec.name == name)
642                .unwrap_or_else(|| panic!("{name} should be in the manifest"))
643        };
644
645        for (child, parent) in EDGES {
646            assert!(
647                position(parent) < position(child),
648                "{parent} must be copied before {child}, or the foreign key refuses the row"
649            );
650        }
651    }
652
653    /// The key of every table is one of its own columns.
654    ///
655    /// `key_of` indexes the row by the key's position in `columns`, so a key
656    /// naming a column the manifest does not carry would panic mid-copy rather
657    /// than here.
658    #[test]
659    fn every_key_is_a_column_of_its_table() {
660        for spec in TABLES {
661            assert!(!spec.key.is_empty(), "{} declares no key", spec.name);
662            for key in spec.key {
663                assert!(
664                    spec.columns.iter().any(|(name, _)| name == key),
665                    "{}.{key} is a key but not a column",
666                    spec.name
667                );
668            }
669        }
670    }
671
672    /// The copy carries every table, and reads the source without writing it.
673    ///
674    /// The manifest guards above say the copy *names* every column; this says
675    /// it moves them. Both databases come from `connect_for_test`, so the same
676    /// test is SQLite to SQLite under `cargo nextest` and PostgreSQL to
677    /// PostgreSQL — two schemas of one server — under `TEST_POSTGRES_URL`,
678    /// which is the only way the `OVERRIDING SYSTEM VALUE` and `setval` arms
679    /// are ever executed.
680    #[tokio::test]
681    async fn a_copy_carries_every_table_and_leaves_the_source_alone() {
682        let source = Arc::new(Database::connect_for_test().await.unwrap());
683        let target = Database::connect_for_test().await.unwrap();
684
685        crate::testutil::seed_every_table(&source).await;
686        let before = crate::testutil::row_counts(&source).await;
687        assert!(
688            before.iter().all(|(_, rows)| *rows > 0),
689            "every table must be seeded or this proves nothing: {before:?}"
690        );
691        assert!(
692            non_empty_tables(&target)
693                .await
694                .expect("an empty target counts")
695                .is_empty(),
696            "the target starts empty"
697        );
698
699        let report = source
700            .transfer_to(&target)
701            .await
702            .expect("the copy should succeed");
703
704        assert_eq!(
705            report.total(),
706            before.iter().map(|(_, rows)| rows).sum::<u64>(),
707            "the report counts what the tables hold"
708        );
709        assert_eq!(
710            report.tables.len(),
711            TABLES.len(),
712            "every table is reported, including any that were empty"
713        );
714        assert_eq!(
715            crate::testutil::row_counts(&target).await,
716            before,
717            "the target holds what the source held"
718        );
719        assert_eq!(
720            crate::testutil::row_counts(&source).await,
721            before,
722            "the source is only read"
723        );
724
725        // The nullable `Bool` the seed sets on one account and leaves absent on
726        // the other. Counts cannot see this: a copy that carried `NULL` as
727        // `false` would match every number above.
728        let agreed = crate::sql::query(
729            "SELECT terms_of_service_agreed FROM accounts ORDER BY created_at, id;",
730        )
731        .fetch_all(&target)
732        .await
733        .expect("the accounts are readable")
734        .iter()
735        .map(|row| {
736            row.try_get::<Option<bool>>(0usize)
737                .expect("a nullable bool")
738        })
739        .collect::<Vec<_>>();
740        assert!(
741            agreed.contains(&Some(true)) && agreed.contains(&None),
742            "an agreement and its absence both survive: {agreed:?}"
743        );
744    }
745
746    /// A table longer than one batch arrives whole, and no row arrives twice.
747    ///
748    /// `copy_table` pages with a keyset rather than an offset, so the boundary
749    /// at [`BATCH`] is where a wrong comparison truncates the tail or repeats
750    /// the seam — and either failure looks like a successful copy.
751    ///
752    /// Two tables, because `read_batch` builds a different `WHERE` for each:
753    /// `nonces` resumes from one column, and `revocations` — the manifest's
754    /// only composite key — from the `(a, b) > (?, ?)` row comparison that is
755    /// the one spelling both dialects accept. Neither is reached at all by a
756    /// table that fits in a single batch.
757    #[tokio::test]
758    async fn a_table_longer_than_one_batch_is_copied_whole() {
759        let source = Arc::new(Database::connect_for_test().await.unwrap());
760        let target = Database::connect_for_test().await.unwrap();
761
762        let rows = u64::try_from(BATCH).expect("the batch size is positive") + 1;
763        for index in 0..rows {
764            crate::nonce::Nonce::new()
765                .save(&source)
766                .await
767                .expect("a nonce is storable");
768            crate::revocation::Revocation {
769                issuer: "a".repeat(64),
770                serial: format!("{index:08x}"),
771                revoked_at: 1,
772                reason: None,
773                not_after: None,
774            }
775            .insert_if_absent(&source)
776            .await
777            .expect("a revocation is storable");
778        }
779
780        let report = source
781            .transfer_to(&target)
782            .await
783            .expect("the copy should succeed");
784
785        for table in ["nonces", "revocations"] {
786            let reported = report
787                .tables
788                .iter()
789                .find(|entry| entry.table == table)
790                .unwrap_or_else(|| panic!("{table} is in the manifest"));
791            assert_eq!(reported.rows, rows, "{table}: one batch and one row");
792            assert_eq!(
793                count(table, &target).await.expect("a count"),
794                rows,
795                "{table}: and the target agrees"
796            );
797        }
798    }
799
800    /// A copy that fails partway leaves the target exactly as it was.
801    ///
802    /// The whole transfer is one transaction on the target, which is what lets
803    /// an operator retry after fixing the cause instead of hunting for how far
804    /// it got. `admin_users` is the eighth of fifteen tables and its username
805    /// is `UNIQUE`, so a target that already holds the seed's operator fails
806    /// there — after seven tables have been written and before the seven that
807    /// follow, `accounts` and `orders` among them.
808    #[tokio::test]
809    async fn a_failed_copy_leaves_the_target_as_it_was() {
810        let source = Arc::new(Database::connect_for_test().await.unwrap());
811        let target = Arc::new(Database::connect_for_test().await.unwrap());
812
813        crate::testutil::seed_every_table(&source).await;
814        crate::admin_user::AdminUser::create("alice", "hash", None, &target)
815            .await
816            .expect("the target has an operator of its own");
817
818        let error = source
819            .transfer_to(&target)
820            .await
821            .expect_err("the duplicate username must fail the copy");
822        assert!(
823            crate::sql::is_unique_violation(&error),
824            "the cause is the username, not something else: {error}"
825        );
826
827        for (table, rows) in crate::testutil::row_counts(&target).await {
828            let expected = u64::from(table == "admin_users");
829            assert_eq!(
830                rows, expected,
831                "`{table}` should hold {expected} row(s) after the rollback"
832            );
833        }
834    }
835
836    /// The tables the live schema holds, `_sqlx_migrations` aside.
837    async fn live_tables(database: &Database) -> Vec<String> {
838        let sql = match database.dialect() {
839            Dialect::Sqlite => {
840                "SELECT name FROM sqlite_master WHERE type = 'table' \
841                 AND name NOT LIKE 'sqlite_%' AND name <> '_sqlx_migrations';"
842            }
843            Dialect::Postgres => {
844                "SELECT table_name FROM information_schema.tables \
845                 WHERE table_schema = ANY (current_schemas(false)) \
846                 AND table_type = 'BASE TABLE' AND table_name <> '_sqlx_migrations';"
847            }
848        };
849        sql::query(sql)
850            .fetch_all(database)
851            .await
852            .expect("the catalog should be readable")
853            .iter()
854            .map(|row| row.try_get::<String>(0usize).expect("a name"))
855            .collect()
856    }
857
858    /// The columns of `table`, in declaration order.
859    async fn live_columns(database: &Database, table: &str) -> Vec<String> {
860        match database.dialect() {
861            Dialect::Sqlite => sql::query(sqlx::AssertSqlSafe(format!(
862                "SELECT name FROM pragma_table_info('{table}');"
863            )))
864            .fetch_all(database)
865            .await
866            .expect("the table should exist")
867            .iter()
868            .map(|row| row.try_get::<String>(0usize).expect("a name"))
869            .collect(),
870            Dialect::Postgres => sql::query(
871                "SELECT column_name FROM information_schema.columns \
872                 WHERE table_schema = ANY (current_schemas(false)) AND table_name = ? \
873                 ORDER BY ordinal_position;",
874            )
875            .bind(table)
876            .fetch_all(database)
877            .await
878            .expect("the table should exist")
879            .iter()
880            .map(|row| row.try_get::<String>(0usize).expect("a name"))
881            .collect(),
882        }
883    }
884}