Skip to main content

max / goingson

4.4 KB · 116 lines History Blame Raw
1 //! Applied migrations are immutable: once a migration ships, its bytes are frozen.
2 //!
3 //! `sqlx::migrate!` records each file's checksum in `_sqlx_migrations` when it runs
4 //! and refuses to boot if a previously-applied file no longer hashes the same. The
5 //! hash covers raw file bytes, so comments count. Editing a shipped migration
6 //! therefore bricks every install that already ran it, while leaving fresh installs
7 //! and CI perfectly green: they have no recorded checksum to contradict. That
8 //! asymmetry is why this needs a test rather than review attention (see 534a7f6,
9 //! a comment-only edit to 047 that crashed every existing install at launch).
10 //!
11 //! Checksums come from sqlx's own `Migrator`, so they track sqlx's definition of the
12 //! hash rather than a reimplementation that could drift from it.
13 //!
14 //! Adding a migration: run the regenerate command below and commit the new line.
15 //! Changing an existing line means changing a shipped migration, which is the thing
16 //! this test exists to stop. Add a new migration instead.
17 //!
18 //! Regenerate: `UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite`
19
20 use std::collections::BTreeMap;
21 use std::fmt::Write as _;
22 use std::path::PathBuf;
23
24 /// Environment variable that rewrites the manifest instead of asserting against it.
25 const UPDATE_VAR: &str = "UPDATE_MIGRATION_CHECKSUMS";
26
27 fn manifest_path() -> PathBuf {
28 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../migrations/sqlite/CHECKSUMS")
29 }
30
31 /// Checksums of the migrations currently on disk, keyed by version.
32 fn current_checksums() -> BTreeMap<i64, String> {
33 sqlx::migrate!("../../migrations/sqlite")
34 .iter()
35 .map(|m| {
36 let hex = m.checksum.iter().fold(String::new(), |mut acc, b| {
37 let _ = write!(acc, "{b:02x}");
38 acc
39 });
40 (m.version, hex)
41 })
42 .collect()
43 }
44
45 fn render(checksums: &BTreeMap<i64, String>) -> String {
46 let mut out = String::from(
47 "# sha384 of each migration file, as computed by sqlx.\n\
48 # Regenerate: UPDATE_MIGRATION_CHECKSUMS=1 cargo test -p goingson-db-sqlite\n\
49 # A changed line means a shipped migration was edited. See migration_checksum_tests.rs.\n",
50 );
51 for (version, checksum) in checksums {
52 let _ = writeln!(out, "{version:03} {checksum}");
53 }
54 out
55 }
56
57 fn parse(raw: &str) -> BTreeMap<i64, String> {
58 raw.lines()
59 .map(str::trim)
60 .filter(|line| !line.is_empty() && !line.starts_with('#'))
61 .map(|line| {
62 let (version, checksum) = line
63 .split_once(char::is_whitespace)
64 .unwrap_or_else(|| panic!("malformed CHECKSUMS line: {line:?}"));
65 let version = version
66 .trim_start_matches('0')
67 .parse()
68 .unwrap_or_else(|e| panic!("bad version in {line:?}: {e}"));
69 (version, checksum.trim().to_string())
70 })
71 .collect()
72 }
73
74 #[test]
75 fn shipped_migrations_are_unmodified() {
76 let current = current_checksums();
77 let path = manifest_path();
78
79 if std::env::var_os(UPDATE_VAR).is_some() {
80 std::fs::write(&path, render(&current)).expect("write CHECKSUMS");
81 return;
82 }
83
84 let raw = std::fs::read_to_string(&path).expect("read CHECKSUMS");
85 let expected = parse(&raw);
86
87 // Checked before the whole-map comparison: a modified migration is the case that
88 // bricks installs, and it reads as an ordinary diff in the full assert output.
89 let modified: Vec<i64> = current
90 .iter()
91 .filter(|(version, checksum)| {
92 expected
93 .get(*version)
94 .is_some_and(|pinned| pinned != *checksum)
95 })
96 .map(|(version, _)| *version)
97 .collect();
98
99 assert!(
100 modified.is_empty(),
101 "migration(s) {modified:?} were edited after shipping.\n\
102 Every install that already ran them will abort at launch with \
103 \"migration N was previously applied but has been modified\".\n\
104 Revert the edit and put the change in a new migration. If these \
105 migrations have genuinely never shipped, regenerate with \
106 {UPDATE_VAR}=1."
107 );
108
109 assert_eq!(
110 expected, current,
111 "migration set changed. New migrations are expected; regenerate with \
112 {UPDATE_VAR}=1 and commit the result. A missing version means a \
113 migration file was deleted, which breaks installs that ran it."
114 );
115 }
116