Skip to main content

miden_node_db/migration/
schema.rs

1use std::fmt;
2
3use anyhow::{Context, Result, ensure};
4use rusqlite::Connection;
5use sha2::{Digest, Sha256};
6
7/// A schema fingerprint computed from ordered entries in `sqlite_schema`.
8///
9/// The hash includes each non-internal schema object's type, name, table name, and normalized SQL.
10/// Normalization trims trailing semicolons and collapses whitespace, then entries are ordered by
11/// object type, name, and table name before hashing. This makes the hash stable across object
12/// creation order while still detecting changes to tables, indexes, views, triggers, constraints,
13/// and object names.
14///
15/// This is a drift-detection fingerprint, not a semantic SQLite schema model. It does not parse SQL
16/// or understand equivalent SQL forms. For example, two semantically equivalent declarations can
17/// hash differently if SQLite stores their SQL text differently, while behavior not represented in
18/// `sqlite_schema.sql` is outside the hash. SQLite-internal objects whose names start with
19/// `sqlite_` are intentionally ignored.
20#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
21pub struct SchemaHash([u8; 32]);
22
23impl SchemaHash {
24    /// Parses a schema hash from its hex representation.
25    ///
26    /// Expects exactly 64 hex characters and panics otherwise.
27    pub const fn from_hex(hex: &str) -> Self {
28        assert!(hex.len() == 64, "schema hash must be 64 hex characters");
29
30        let mut hash = [0_u8; 32];
31        let bytes = hex.as_bytes();
32        let mut idx = 0;
33        while idx < 32 {
34            let high = hex_digit(bytes[idx * 2]);
35            let low = hex_digit(bytes[idx * 2 + 1]);
36            hash[idx] = (high << 4) | low;
37            idx += 1;
38        }
39
40        Self(hash)
41    }
42
43    /// Computes the hash for the database schema.
44    ///
45    /// See [`SchemaHash`] for what is included and the limits of this fingerprint.
46    pub fn new(conn: &Connection) -> Result<Self> {
47        let mut stmt = conn
48            .prepare(
49                "SELECT type, name, tbl_name, sql FROM sqlite_schema \
50                 WHERE sql IS NOT NULL \
51                 AND name NOT LIKE 'sqlite_%' \
52                 ORDER BY type, name, tbl_name",
53            )
54            .context("failed to prepare sqlite_schema query")?;
55
56        let rows = stmt
57            .query_map([], |row| {
58                Ok(SchemaEntry {
59                    object_type: row.get(0)?,
60                    name: row.get(1)?,
61                    table_name: row.get(2)?,
62                    sql: normalize_sql(&row.get::<_, String>(3)?),
63                })
64            })
65            .context("failed to query sqlite_schema rows")?;
66
67        let schema_entries = rows
68            .collect::<rusqlite::Result<Vec<_>>>()
69            .context("failed to read sqlite_schema rows")?;
70
71        let mut hasher = Sha256::new();
72        hash_field(&mut hasher, "schema-hash-v1");
73        for entry in schema_entries {
74            hash_field(&mut hasher, &entry.object_type);
75            hash_field(&mut hasher, &entry.name);
76            hash_field(&mut hasher, &entry.table_name);
77            hash_field(&mut hasher, &entry.sql);
78        }
79
80        let digest = hasher.finalize();
81        let mut hash = [0_u8; 32];
82        hash.copy_from_slice(&digest);
83        Ok(Self(hash))
84    }
85}
86
87struct SchemaEntry {
88    object_type: String,
89    name: String,
90    table_name: String,
91    sql: String,
92}
93
94impl fmt::Display for SchemaHash {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        for byte in self.0 {
97            write!(f, "{byte:02x}")?;
98        }
99        Ok(())
100    }
101}
102
103#[derive(PartialEq)]
104pub struct SchemaHashes<'a>(pub &'a [SchemaHash]);
105
106impl fmt::Display for SchemaHashes<'_> {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        for hash in self.0 {
109            writeln!(f, "{hash}")?;
110        }
111        Ok(())
112    }
113}
114
115impl fmt::Debug for SchemaHashes<'_> {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        fmt::Display::fmt(self, f)
118    }
119}
120
121impl SchemaHashes<'_> {
122    pub fn len(&self) -> usize {
123        self.0.len()
124    }
125
126    pub fn is_empty(&self) -> bool {
127        self.0.is_empty()
128    }
129}
130
131fn normalize_sql(sql: &str) -> String {
132    sql.trim_end()
133        .trim_end_matches(';')
134        .split_whitespace()
135        .collect::<Vec<_>>()
136        .join(" ")
137}
138
139fn hash_field(hasher: &mut Sha256, field: &str) {
140    hasher.update(field.len().to_le_bytes());
141    hasher.update(field.as_bytes());
142}
143
144const fn hex_digit(byte: u8) -> u8 {
145    match byte {
146        b'0'..=b'9' => byte - b'0',
147        b'a'..=b'f' => byte - b'a' + 10,
148        b'A'..=b'F' => byte - b'A' + 10,
149        _ => panic!("invalid schema hash hex digit"),
150    }
151}
152
153pub fn get_version(conn: &Connection) -> Result<usize> {
154    let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
155    ensure!(version >= 0, "database user_version is negative: {version}");
156    usize::try_from(version).context("database user_version does not fit into usize")
157}
158
159pub fn set_version(conn: &Connection, version: usize) -> Result<()> {
160    let version = version_to_user_version(version)?;
161    conn.execute_batch(&format!("PRAGMA user_version = {version};"))?;
162    Ok(())
163}
164
165fn version_to_user_version(version: usize) -> Result<i32> {
166    i32::try_from(version).with_context(|| {
167        format!("migration version {version} exceeds SQLite user_version i32 range")
168    })
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn schema_hash_round_trips_as_hex() {
177        const HASH: SchemaHash = SchemaHash::from_hex(
178            "abababababababababababababababababababababababababababababababab",
179        );
180
181        assert_eq!(HASH.to_string(), "ab".repeat(32));
182    }
183
184    #[test]
185    fn schema_hash_is_stable_across_creation_order() -> Result<()> {
186        let left = Connection::open_in_memory()?;
187        left.execute_batch(
188            "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
189             CREATE TABLE notes (id INTEGER PRIMARY KEY, item_id INTEGER);
190             CREATE INDEX idx_notes_item_id ON notes(item_id);",
191        )?;
192
193        let right = Connection::open_in_memory()?;
194        right.execute_batch(
195            "CREATE TABLE notes (id INTEGER PRIMARY KEY, item_id INTEGER);
196             CREATE INDEX idx_notes_item_id ON notes(item_id);
197             CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);",
198        )?;
199
200        assert_eq!(SchemaHash::new(&left)?, SchemaHash::new(&right)?);
201        Ok(())
202    }
203
204    #[test]
205    fn schema_hash_changes_for_object_identity() -> Result<()> {
206        let left = Connection::open_in_memory()?;
207        left.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);")?;
208
209        let right = Connection::open_in_memory()?;
210        right.execute_batch("CREATE TABLE entries (id INTEGER PRIMARY KEY, value TEXT);")?;
211
212        assert_ne!(SchemaHash::new(&left)?, SchemaHash::new(&right)?);
213        Ok(())
214    }
215
216    #[test]
217    fn schema_hash_changes_for_views_triggers_indexes_and_constraints() -> Result<()> {
218        let base = Connection::open_in_memory()?;
219        base.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);")?;
220        let base_hash = SchemaHash::new(&base)?;
221
222        let with_index = Connection::open_in_memory()?;
223        with_index.execute_batch(
224            "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
225             CREATE INDEX idx_items_value ON items(value);",
226        )?;
227        assert_ne!(base_hash, SchemaHash::new(&with_index)?);
228
229        let with_view = Connection::open_in_memory()?;
230        with_view.execute_batch(
231            "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
232             CREATE VIEW item_values AS SELECT value FROM items;",
233        )?;
234        assert_ne!(base_hash, SchemaHash::new(&with_view)?);
235
236        let with_trigger = Connection::open_in_memory()?;
237        with_trigger.execute_batch(
238            "CREATE TABLE items (id INTEGER PRIMARY KEY, value INTEGER);
239             CREATE TRIGGER items_positive_value
240             BEFORE INSERT ON items
241             WHEN NEW.value < 0
242             BEGIN
243                 SELECT RAISE(ABORT, 'negative value');
244             END;",
245        )?;
246        assert_ne!(base_hash, SchemaHash::new(&with_trigger)?);
247
248        let with_constraints = Connection::open_in_memory()?;
249        with_constraints.execute_batch(
250            "CREATE TABLE items (
251                 id INTEGER PRIMARY KEY,
252                 value INTEGER UNIQUE CHECK (value > 0)
253             );",
254        )?;
255        assert_ne!(base_hash, SchemaHash::new(&with_constraints)?);
256
257        Ok(())
258    }
259}