Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ env:
RUST_CACHE_KEY: rust-cache-2026.02.18

jobs:
migrations:
name: Migrations are append-only
runs-on: ubuntu-latest
timeout-minutes: 5
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
persist-credentials: false
- name: Check that no released migration was modified
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: ./scripts/check-migrations.sh
shell: bash

unused_deps:
name: Check for unused dependencies
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
* [FEATURE][rust,store] Added `NoteFilter::ScriptRoots` to query input notes by their note script root directly at the store level, without loading and screening unrelated notes. The filter doesn't apply to output notes: querying output notes with it returns an empty list ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)).
* [rust] Added `PartialBlockchainUpdates::block_headers_to_store`, which narrows the staged headers to the ones a sync must persist: those marked as relevant, genesis, and the block at the sync height. `block_headers` still yields all staged headers ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)).
* [rust] State sync now authenticates every relevant note block but only persists block headers and MMR authentication nodes for blocks containing notes that remain unspent or that a `NoteObserver` explicitly marks as relevant ([#2297](https://github.com/0xMiden/rust-sdk/pull/2297)).
* [store] The SQLite store's schema is now built from append-only migrations under `crates/sqlite-store/src/migrations/`, starting with the frozen `0001_init.sql`. An existing store is checked against the fingerprints pinned in `PINNED_SCHEMA_HASHES` rather than against a replay of the current migration files, so editing a released migration fails a test instead of silently redefining the schema those stores were built with. A CI job rejects any pull request that modifies an existing migration file ([#2346](https://github.com/0xMiden/rust-sdk/issues/2346)).

## 0.16.0-alpha.1 (2026-07-17)

Expand Down
31 changes: 31 additions & 0 deletions crates/sqlite-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,36 @@ miden-client = { version = "0.13" }
miden-client-sqlite-store = { version = "0.13" }
```

## Migrations

The schema is built by replaying the migrations listed in `MIGRATION_SCRIPTS`
(`src/db_management/utils.rs`), which include the files under `src/migrations/` in order. A file's
four-digit prefix is its schema version, which is the value SQLite records in `PRAGMA user_version`.

Migrations are **append-only**. Every store on a user's disk was built by replaying these exact
files, and the client verifies on open that the schema it finds matches `PINNED_SCHEMA_HASHES` for
the version the database claims. That constant, not a replay of the current migration files, is the
definition of what each version's schema is, so editing a released migration is caught rather than
silently redefining the schema those databases were supposed to have. Unlike chain state, a store
holds private notes and account seeds that cannot be recovered from the network.

Upgrades are forward-only. There are no down migrations.

### Adding a migration

1. Add `src/migrations/000N_short_name.sql` with the next unused prefix. Never edit an existing
file, including its comments.
2. Append `include_str!("../migrations/000N_short_name.sql")` to `MIGRATION_SCRIPTS` in
`src/db_management/utils.rs`. Nothing scans the directory, so a file that is not listed here is
never applied.
3. Append one entry to `PINNED_SCHEMA_HASHES` in the same file. Run
`cargo test -p miden-client-sqlite-store --lib migration_schema_hashes_are_stable` and take the
new hash from the failure output. Leave the existing entries alone. If they changed, the
migration edited the schema an older version built.
4. Add a `CHANGELOG.md` entry under `[store]`.

`scripts/check-migrations.sh` runs in CI and fails a pull request that modifies, renames or deletes
a file that already exists on the base branch.

## License
This project is licensed under the MIT License. See the [LICENSE](../../LICENSE) file for details.
41 changes: 35 additions & 6 deletions crates/sqlite-store/src/db_management/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,50 @@ use thiserror::Error;
#[derive(Debug, Error)]
pub enum SqliteStoreError {
#[error("Database error: {0}")]
DatabaseError(String),
Database(String),
#[error("Migration error: {0}")]
MigrationError(String),
#[error("Database schema does not match the schema expected for its migration version")]
SchemaHashMismatch,
Migration(String),
#[error(
"stored schema at version {version} does not match the schema this client builds for that version (expected {expected}, found {actual})"
)]
SchemaDrift {
version: u32,
expected: String,
actual: String,
},
#[error(
"store is at schema version {found}, which is newer than the highest version this client supports ({supported})"
)]
SchemaTooNew { found: u32, supported: u32 },
}

impl From<RusqliteError> for SqliteStoreError {
fn from(err: RusqliteError) -> Self {
SqliteStoreError::DatabaseError(err.to_string())
SqliteStoreError::Database(err.to_string())
}
}

impl From<MigrationError> for SqliteStoreError {
fn from(err: MigrationError) -> Self {
SqliteStoreError::MigrationError(err.to_string())
SqliteStoreError::Migration(describe_migration_error(&err))
}
}

/// Renders a migration failure without reproducing the migration script.
pub fn describe_migration_error(err: &MigrationError) -> String {
match err {
MigrationError::RusqliteError { err, .. } => describe_sqlite_error(err),
MigrationError::ForeignKeyCheck(violations) => {
format!("{} foreign key violation(s) after applying the migration", violations.len())
},
other => other.to_string(),
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Let's just inline this function where it's called (it's only one place AFAICT)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0e10af0


/// Renders a `SQLite` failure without reproducing the statement that caused it.
fn describe_sqlite_error(err: &RusqliteError) -> String {
match err {
RusqliteError::SqlInputError { msg, .. } => msg.clone(),
other => other.to_string(),
}
}
202 changes: 180 additions & 22 deletions crates/sqlite-store/src/db_management/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,36 +61,62 @@ type Hash = Blake3Digest<32>;

const SCHEMA_HASH_DOMAIN: &[u8] = b"miden-client-sqlite-schema-v1";

const MIGRATION_SCRIPTS: [&str; 1] = [include_str!("../store.sql")];
/// The migrations that build the store schema, in the order they are applied.
const MIGRATION_SCRIPTS: [&str; 1] = [include_str!("../migrations/0001_init.sql")];

/// The schema fingerprint each migration in [`MIGRATION_SCRIPTS`] produced when it was released.
const PINNED_SCHEMA_HASHES: [&str; MIGRATION_SCRIPTS.len()] =
["0x6300110a9f3efa3476fac4e736f94c33e07935ab7eedf357b38a50f55cabf140"];

static MIGRATIONS: LazyLock<Migrations> = LazyLock::new(prepare_migrations);
static EXPECTED_SCHEMA_HASHES: LazyLock<Vec<Hash>> = LazyLock::new(compute_expected_schema_hashes);

fn up(s: &'static str) -> M<'static> {
M::up(s).foreign_key_check()
}

/// Applies the migrations to the database.
/// Brings the database up to the latest schema version, creating it if it is empty.
pub fn apply_migrations(conn: &mut Connection) -> Result<(), SqliteStoreError> {
let version_before = MIGRATIONS.current_version(conn)?;

if let SchemaVersion::Inside(ver) = version_before {
let actual_hash = schema_hash(conn)?;
if actual_hash != EXPECTED_SCHEMA_HASHES[ver.get() - 1] {
return Err(SqliteStoreError::SchemaHashMismatch);
}
match MIGRATIONS.current_version(conn)? {
SchemaVersion::NoneSet => {},
SchemaVersion::Inside(ver) => {
let expected = PINNED_SCHEMA_HASHES[ver.get() - 1];
let actual = String::from(schema_hash(conn)?);
if actual != expected {
return Err(SqliteStoreError::SchemaDrift {
version: schema_version(ver.get()),
expected: expected.to_string(),
actual,
});
}
},
SchemaVersion::Outside(ver) => {
return Err(SqliteStoreError::SchemaTooNew {
found: schema_version(ver.get()),
supported: schema_version(MIGRATION_SCRIPTS.len()),
});
},
}

MIGRATIONS.to_latest(conn)?;

Ok(())
}

/// Narrows a migration index to the width schema versions are reported in.
///
/// `SQLite` stores the version in `PRAGMA user_version`, which is an `i32`, so a version that does
/// not fit is unreachable.
fn schema_version(version: usize) -> u32 {
u32::try_from(version).expect("schema version should fit in a u32")
}

fn prepare_migrations() -> Migrations<'static> {
Migrations::new(MIGRATION_SCRIPTS.map(up).to_vec())
}

/// Computes the schema fingerprint expected after each migration by replaying the migrations on an
/// Computes the schema fingerprint each migration produces by replaying the migrations on an
/// in-memory database.
#[cfg(test)]
fn compute_expected_schema_hashes() -> Vec<Hash> {
let mut conn =
Connection::open_in_memory().expect("in-memory database creation should not fail");
Expand Down Expand Up @@ -144,14 +170,83 @@ fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
buf.extend_from_slice(field);
}

/// Collapses runs of whitespace to single spaces and trims a trailing semicolon so cosmetic
/// differences in stored SQL text do not change the fingerprint.
/// Rewrites the SQL text stored for a schema object into a form that ignores differences `SQLite`
/// itself ignores, so cosmetic edits do not change the fingerprint.
fn normalize_sql(sql: &str) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's revert these changes. I don't think it's code worth maintaining as it becomes instantly quite more complicated. A solution could be to introduce a SQL linter so the normalization is even more trivial.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted

0e10af0

sql.trim_end()
.trim_end_matches(';')
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
let mut out = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();

while let Some(ch) = chars.next() {
match ch {
// A doubled quote inside a quoted region escapes itself and does not close it.
'\'' | '"' | '`' => {
out.push(ch);
while let Some(inner) = chars.next() {
out.push(inner);
if inner == ch {
if chars.peek() == Some(&ch) {
out.push(ch);
chars.next();
} else {
break;
}
}
}
},
// Bracketed identifiers do not nest and have no escape sequence.
'[' => {
out.push(ch);
for inner in chars.by_ref() {
out.push(inner);
if inner == ']' {
break;
}
}
},
// A comment collapses to a separator rather than to nothing, because `SQLite` does not
// require whitespace before `--` and fusing the tokens on either side of it would
// change what the text means.
'-' if chars.peek() == Some(&'-') => {
chars.next();
while chars.peek().is_some_and(|&inner| inner != '\n') {
chars.next();
}
push_separator(&mut out);
},
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut prev = '\0';
for inner in chars.by_ref() {
if prev == '*' && inner == '/' {
break;
}
prev = inner;
}
push_separator(&mut out);
},
_ if is_sql_whitespace(ch) => push_separator(&mut out),
_ => out.push(ch),
}
}

let normalized = out.trim_end().trim_end_matches(';').trim();
normalized.to_string()
}

/// Appends a single space unless one is already there, so adjacent separators do not stack up.
fn push_separator(out: &mut String) {
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
}

/// Returns whether `ch` separates tokens for `SQLite`.
///
/// This is deliberately narrower than [`char::is_whitespace`]. `SQLite` treats every byte above
/// the ASCII range as part of an identifier, so a Unicode space between two tokens makes them one
/// token and must not be normalized away.
fn is_sql_whitespace(ch: char) -> bool {
matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0c}')
}

pub fn get_setting<T: FromSql>(conn: &mut Connection, name: &str) -> Result<Option<T>, StoreError> {
Expand Down Expand Up @@ -196,9 +291,21 @@ pub fn list_setting_keys(conn: &Connection) -> Result<Vec<String>, StoreError> {
mod tests {
use rusqlite::Connection;

use super::{EXPECTED_SCHEMA_HASHES, MIGRATION_SCRIPTS, apply_migrations, schema_hash};
use super::{
MIGRATION_SCRIPTS,
PINNED_SCHEMA_HASHES,
apply_migrations,
compute_expected_schema_hashes,
schema_hash,
};
use crate::db_management::errors::SqliteStoreError;

fn hash_of(schema: &str) -> super::Hash {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(schema).unwrap();
schema_hash(&conn).unwrap()
}

#[test]
fn honest_database_reopens_without_error() {
let mut conn = Connection::open_in_memory().unwrap();
Expand All @@ -217,7 +324,28 @@ mod tests {
conn.execute("ALTER TABLE input_notes ADD COLUMN injected TEXT", []).unwrap();

let err = apply_migrations(&mut conn).unwrap_err();
assert!(matches!(err, SqliteStoreError::SchemaHashMismatch));
let SqliteStoreError::SchemaDrift { version, expected, actual } = err else {
panic!("drifted schema should be reported as drift, got {err:?}");
};
assert_eq!(version, 1);
assert_ne!(expected, actual);
}

#[test]
fn database_from_a_newer_client_is_rejected() {
let mut conn = Connection::open_in_memory().unwrap();
apply_migrations(&mut conn).unwrap();

// A version this client has no migration for, as written by a later release.
let ahead = MIGRATION_SCRIPTS.len() + 3;
conn.pragma_update(None, "user_version", ahead).unwrap();

let err = apply_migrations(&mut conn).unwrap_err();
let SqliteStoreError::SchemaTooNew { found, supported } = err else {
panic!("a database from a newer client should be reported as too new, got {err:?}");
};
assert_eq!(found as usize, ahead);
assert_eq!(supported as usize, MIGRATION_SCRIPTS.len());
}

#[test]
Expand All @@ -241,7 +369,37 @@ mod tests {
}

#[test]
fn expected_schema_hash_per_migration() {
assert_eq!(EXPECTED_SCHEMA_HASHES.len(), MIGRATION_SCRIPTS.len());
fn migration_schema_hashes_are_stable() {
let replayed = compute_expected_schema_hashes()
.into_iter()
.map(String::from)
.collect::<Vec<_>>();
let pinned = PINNED_SCHEMA_HASHES.map(str::to_string).to_vec();

assert_eq!(
replayed, pinned,
"a released migration builds a different schema than it did when it was pinned. \
Append a new migration instead of editing an existing one. If this is a new \
migration, append its hash rather than rewriting the entries before it."
);
}

#[test]
fn schema_hash_ignores_comment_edits() {
let documented = hash_of(
"CREATE TABLE items (
id INTEGER PRIMARY KEY, -- the identifier
/* the payload */
value TEXT
);",
);
let reworded = hash_of(
"CREATE TABLE items (
id INTEGER PRIMARY KEY, -- a completely different explanation
value TEXT
);",
);

assert_eq!(documented, reworded);
}
}
3 changes: 2 additions & 1 deletion crates/sqlite-store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ pub use builder::ClientBuilderSqliteExt;
/// Represents a pool of connections with an `SQLite` database. The pool is used to interact
/// concurrently with the underlying database in a safe and efficient manner.
///
/// Current table definitions can be found at `store.sql` migration file.
/// Current table definitions are the result of applying every migration under `migrations/` in
/// order.
pub struct SqliteStore {
pub(crate) pool: Pool,
database_filepath: String,
Expand Down
Loading
Loading