Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -23,6 +23,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`. Opening a store verifies its schema against a fingerprint derived by replaying the migrations, both before migrating and after. A store that is behind is copied before it is migrated and put back from that copy if the migration fails, so a failed upgrade leaves it as it was. A pinned snapshot of the fingerprints and a CI job together reject any pull request that modifies an existing migration file ([#2346](https://github.com/0xMiden/rust-sdk/issues/2346)).
* Improved the output of the `miden-client init` command when a configuration already exists ([#](https://github.com/0xMiden/rust-sdk/pull/2357)).

## 0.16.0-alpha.1 (2026-07-17)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sqlite-store/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ workspace = true
# Enable client testing utilities only for tests
miden-client = { features = ["testing"], workspace = true }
miden-standards = { features = ["testing"], workspace = true }
tempfile = { workspace = true }
42 changes: 42 additions & 0 deletions crates/sqlite-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,47 @@ 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. On open the client replays the migrations against an in-memory database to derive the
fingerprint each version should have, and verifies that the schema it finds on disk matches the one
for the version the database claims. A store that was altered outside the migrations is rejected
rather than migrated further. 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 that file's test module. 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.

### Migrations that transform data

Some upgrades cannot be expressed in SQL. The store holds serialized protocol objects as blobs, so
a change to how an account, note or transaction is encoded has to be applied by decoding each row
with the old type and re-encoding it with the new one. SQLite has no way to do that.

`rusqlite_migration` covers this with `up_with_hook`, where the hook is a Rust
closure taking the migration's `&Transaction`. Per migration the library runs the SQL, then the
foreign key check, then the hook, and all of it is inside the transaction the whole upgrade commits
at the end, so a hook that returns an error rolls back the migration exactly like failing SQL does.

## License
This project is licensed under the MIT License. See the [LICENSE](../../LICENSE) file for details.
177 changes: 177 additions & 0 deletions crates/sqlite-store/src/db_management/backup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};

use rusqlite::{Connection, params};

use super::errors::SqliteStoreError;

// PRE-MIGRATION BACKUP
// ================================================================================================

/// Suffix appended to the store's filename to name its pre-migration backup.
const BACKUP_SUFFIX: &str = ".pre-migration-backup";

/// Files `SQLite` keeps next to the database, which describe the database they were written for and
/// must not outlive it.
const SIDECAR_SUFFIXES: [&str; 3] = ["-journal", "-wal", "-shm"];

/// Returns the path of the backup taken before migrating the store at `database_filepath`.
pub fn backup_path(database_filepath: &Path) -> PathBuf {
let mut path = OsString::from(database_filepath);
path.push(BACKUP_SUFFIX);
PathBuf::from(path)
}

/// Copies the database into `backup_filepath`, replacing a backup left behind by an earlier run.
///
/// `VACUUM INTO` writes a consistent snapshot even while the connection is open, so this does not
/// depend on the caller quiescing the store.
pub fn create_backup(conn: &Connection, backup_filepath: &Path) -> Result<(), SqliteStoreError> {
// A backup that is still here was left by a run that died mid-migration. Its database was
// already restored or abandoned, and `VACUUM INTO` refuses to write to a file that exists.
discard_backup(backup_filepath)?;

conn.execute("VACUUM INTO ?1", params![path_argument(backup_filepath)?])?;

Ok(())
}

/// Puts the backup back in place of the database, consuming the backup.
///
/// The caller must have closed every connection to the database first. Restoring under an open
/// connection would leave that connection reading a file that no longer exists, and on Windows the
/// replacement cannot happen at all.
pub fn restore_backup(
database_filepath: &Path,
backup_filepath: &Path,
) -> Result<(), SqliteStoreError> {
// These describe the database being replaced, not the backup, so leaving one behind would let
// `SQLite` apply it to the restored file.
for suffix in SIDECAR_SUFFIXES {
discard_backup(&sidecar_path(database_filepath, suffix))?;
}

std::fs::rename(backup_filepath, database_filepath).map_err(|err| {
SqliteStoreError::BackupRestoreFailed {
backup: backup_filepath.display().to_string(),
reason: err.to_string(),
}
})
}

/// Removes `backup_filepath` if it exists.
pub fn discard_backup(backup_filepath: &Path) -> Result<(), SqliteStoreError> {
match std::fs::remove_file(backup_filepath) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(SqliteStoreError::BackupFailed {
backup: backup_filepath.display().to_string(),
reason: err.to_string(),
}),
}
}

/// Renders a path for `SQLite`, which takes filenames as text.
fn path_argument(path: &Path) -> Result<&str, SqliteStoreError> {
path.to_str().ok_or_else(|| SqliteStoreError::BackupFailed {
backup: path.display().to_string(),
reason: String::from("backup path is not valid UTF-8"),
})
}

/// Returns the path of the `SQLite` sidecar file for `database_filepath` with the given suffix.
fn sidecar_path(database_filepath: &Path, suffix: &str) -> PathBuf {
let mut path = OsString::from(database_filepath);
path.push(suffix);
PathBuf::from(path)
}

// TESTS
// ================================================================================================

#[cfg(test)]
mod tests {
Comment thread
gabrielbosio marked this conversation as resolved.
use rusqlite::Connection;

use super::{backup_path, create_backup, restore_backup, sidecar_path};

fn table_names(conn: &Connection) -> Vec<String> {
let mut stmt = conn
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name")
.unwrap();
stmt.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
}

#[test]
fn backup_restores_the_database_as_it_was() {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("store.sqlite3");
let backup = backup_path(&database);

let conn = Connection::open(&database).unwrap();
conn.execute_batch(
"CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT);
INSERT INTO items (id, value) VALUES (1, 'before');",
)
.unwrap();

create_backup(&conn, &backup).unwrap();
assert!(backup.exists());

// The change the restore is meant to undo.
conn.execute_batch(
"DROP TABLE items;
CREATE TABLE migrated (id INTEGER PRIMARY KEY);",
)
.unwrap();
drop(conn);

restore_backup(&database, &backup).unwrap();
assert!(!backup.exists(), "a consumed backup should not be left behind");

let conn = Connection::open(&database).unwrap();
assert_eq!(table_names(&conn), vec![String::from("items")]);
let value: String = conn
.query_row("SELECT value FROM items WHERE id = 1", [], |row| row.get(0))
.unwrap();
assert_eq!(value, "before");
}

#[test]
fn backup_replaces_one_left_by_an_earlier_run() {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("store.sqlite3");
let backup = backup_path(&database);
std::fs::write(&backup, b"not a database").unwrap();

let conn = Connection::open(&database).unwrap();
conn.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY);").unwrap();

create_backup(&conn, &backup).unwrap();

let restored = Connection::open(&backup).unwrap();
assert_eq!(table_names(&restored), vec![String::from("items")]);
}

#[test]
fn restore_removes_sidecars_of_the_replaced_database() {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("store.sqlite3");
let backup = backup_path(&database);

let conn = Connection::open(&database).unwrap();
conn.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY);").unwrap();
create_backup(&conn, &backup).unwrap();
drop(conn);

let journal = sidecar_path(&database, "-journal");
std::fs::write(&journal, b"stale").unwrap();

restore_backup(&database, &backup).unwrap();

assert!(!journal.exists(), "a sidecar of the replaced database must not survive it");
}
}
56 changes: 50 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,65 @@ 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 },
#[error(
"migrating to schema version {version} produced a schema this client does not expect (expected {expected}, found {actual})"
)]
MigratedSchemaMismatch {
version: u32,
expected: String,
actual: String,
},
#[error(
"the database is not empty and does not record a schema version, so it was not created by this client and will not be migrated into a store"
)]
NotAClientStore,
#[error("failed to back up the store to {backup} before migrating it: {reason}")]
BackupFailed { backup: String, reason: String },
#[error(
"migrating the store failed and it could not be restored from its backup at {backup}: {reason}. The backup holds the store as it was before migrating"
)]
BackupRestoreFailed { backup: String, reason: String },
}

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 {
/// Renders a migration failure without reproducing the migration script.
fn from(err: MigrationError) -> Self {
SqliteStoreError::MigrationError(err.to_string())
let message = match &err {
MigrationError::RusqliteError {
err: RusqliteError::SqlInputError { msg, .. },
..
} => msg.clone(),
MigrationError::RusqliteError { err, .. } => err.to_string(),
MigrationError::ForeignKeyCheck(violations) => {
format!(
"{} foreign key violation(s) after applying the migration",
violations.len()
)
},
other => other.to_string(),
};

SqliteStoreError::Migration(message)
}
}
14 changes: 11 additions & 3 deletions crates/sqlite-store/src/db_management/migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ fn schema_present_at_version_zero_fails() {
));

let err = apply_fixture_migrations(&mut conn).unwrap_err();
assert!(matches!(err, SqliteStoreError::MigrationError(_)));
assert!(matches!(err, SqliteStoreError::NotAClientStore));
}

#[test]
Expand All @@ -124,7 +124,11 @@ fn user_version_beyond_migrations_fails() {
.expect("user_version should update");

let err = apply_fixture_migrations(&mut conn).unwrap_err();
assert!(matches!(err, SqliteStoreError::MigrationError(_)));
let SqliteStoreError::SchemaTooNew { found, supported } = err else {
panic!("a version beyond the migrations should be reported as too new, got {err:?}");
};
assert_eq!(found as usize, FIXTURE_MIGRATION_COUNT + 1);
assert_eq!(supported as usize, FIXTURE_MIGRATION_COUNT);
}

#[test]
Expand All @@ -143,7 +147,11 @@ fn partial_migration_schema_drift_is_rejected() {
.expect("manual schema change should apply");

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

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/sqlite-store/src/db_management/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod backup;
pub(crate) mod errors;
pub(crate) mod pool_manager;
pub(crate) mod utils;
Expand Down
Loading
Loading