Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* [BREAKING][rename][cli] Renamed the `token_symbol_map.toml` Bech32 field from `id` to `address` ([#2377](https://github.com/0xMiden/rust-sdk/pull/2377)).
* [BREAKING][type][rust] Added the `NoteFilter::ScriptRoots` variant, so exhaustive matches on `NoteFilter` in `Store` implementations must handle it ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)).
* [BREAKING][behavior][rpc] The `SyncNotes` response now carries a reduced note metadata message: instead of the note's attachments commitment it carries one entry per attachment, with single-word attachments sent verbatim and larger ones sent as commitments. The client reconstructs the protocol-level `NoteMetadata` from those entries, so it requires a node that speaks this format.
* [BREAKING][behavior][store] The SQLite base schema now declares an index on `input_notes(script_root)`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaHashMismatch` and existing stores must be recreated ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)).
* [BREAKING][behavior][store] The SQLite base schema now declares an index on `input_notes(script_root)`. This changes the schema fingerprint, so opening a database created before this change fails with `SchemaDrift` and existing stores must be recreated ([#2335](https://github.com/0xMiden/rust-sdk/pull/2335)).

### Enhancements

Expand All @@ -35,6 +35,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)).
* [FEATURE][cli] Added `account --inspect <ID>[:<PROCEDURE>]` to list the procedures an account exposes, grouped into resolved procedures (with their names and signatures) and unresolved ones (listed by MAST root). Names and signatures are resolved from the `.masp` packages in the configured packages directory plus any passed via `--package` (`-p`). `--verbose` prints each procedure's MASM disassembly. ([#2312](https://github.com/0xMiden/rust-sdk/issues/2312)).
* Improved the output of the `miden-client init` command when a configuration already exists ([#2357](https://github.com/0xMiden/rust-sdk/pull/2357)).
* [FEATURE][cli] Added DAP-based transaction debugging with offline record/replay. `miden-client exec` and `consume-notes` accept `--start-debug-adapter <ADDR>` to run a transaction — script, kernel, note scripts, and account code — under a DAP client (e.g. the `miden-debug` TUI) instead of proving and submitting it (`consume-notes` is backed by a new `Client::execute_transaction_with_dap`). During the session the advice mutations produced by the transaction host's event handlers are recorded — readable via the handle from `DapConfig::record_event_mutations()`, and reported by the CLI — and `--record <FILE>` writes a self-contained replay snapshot (program, inputs, resolved code, and event log) that can be replayed offline with `miden-debug --replay <FILE>`, with no node, client, or account state. This uses the `miden-debug` 0.9.2 release ([#2306](https://github.com/0xMiden/rust-sdk/pull/2306)).
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 }
43 changes: 43 additions & 0 deletions crates/sqlite-store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,48 @@ miden-client = { version = "0.16.0-alpha.1" }
miden-client-sqlite-store = { version = "0.16.0-alpha.1" }
```

## Migrations

The schema is built by replaying the migrations listed in `MIGRATION_SCRIPTS`
(`src/db_management/migration.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/migration.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.
198 changes: 198 additions & 0 deletions crates/sqlite-store/src/db_management/backup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::string::{String, ToString};

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"];

/// A copy of a store, taken before migrating it.
#[derive(Debug)]
pub(crate) struct SqliteBackup {
database_filepath: PathBuf,
backup_filepath: PathBuf,
}

impl SqliteBackup {
/// Copies the database into its backup path, replacing a copy 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(crate) fn create(
conn: &Connection,
database_filepath: PathBuf,
) -> Result<Self, SqliteStoreError> {
let backup_filepath = Self::path_for(&database_filepath);

// Remove any previous hanged migration
remove_file(&backup_filepath)?;

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

Ok(Self { database_filepath, backup_filepath })
}

/// Returns the path of the backup of the store at `database_filepath`.
pub(crate) fn path_for(database_filepath: &Path) -> PathBuf {
with_suffix(database_filepath, BACKUP_SUFFIX)
}

/// Puts the copy back in place of the database, consuming it.
///
/// The caller must have closed every connection to the database first.
pub(crate) fn restore(self) -> Result<(), SqliteStoreError> {
for suffix in SIDECAR_SUFFIXES {
remove_file(&with_suffix(&self.database_filepath, suffix))?;
}

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

/// Removes the copy, consuming it.
pub(crate) fn discard(self) -> Result<(), SqliteStoreError> {
remove_file(&self.backup_filepath)
}

/// Removes the backup of the store at `database_filepath`, if there is one.
pub(crate) fn discard_for(database_filepath: &Path) -> Result<(), SqliteStoreError> {
remove_file(&Self::path_for(database_filepath))
}
}

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

/// Returns `path` with `suffix` appended to its filename.
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut suffixed = OsString::from(path);
suffixed.push(suffix);

PathBuf::from(suffixed)
}

/// 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"),
})
}

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

#[cfg(test)]
mod tests {
Comment thread
gabrielbosio marked this conversation as resolved.
// `tempfile` rather than `create_test_store_path`: `TempDir` removes the database and any
// backup left behind on drop, so repeated runs do not accumulate files in the system temp
// directory.

use rusqlite::Connection;

use super::{SqliteBackup, with_suffix};

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 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();

let backup = SqliteBackup::create(&conn, database.clone()).unwrap();
assert!(SqliteBackup::path_for(&database).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);

backup.restore().unwrap();
assert!(
!SqliteBackup::path_for(&database).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_filepath = SqliteBackup::path_for(&database);
std::fs::write(&backup_filepath, b"not a database").unwrap();

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

SqliteBackup::create(&conn, database).unwrap();

let restored = Connection::open(&backup_filepath).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 conn = Connection::open(&database).unwrap();
conn.execute_batch("CREATE TABLE items (id INTEGER PRIMARY KEY);").unwrap();
let backup = SqliteBackup::create(&conn, database.clone()).unwrap();
drop(conn);

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

backup.restore().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: usize,
expected: String,
actual: String,
},
#[error(
"store is at schema version {found}, which is newer than the highest version this client supports ({supported})"
)]
SchemaTooNew { found: usize, supported: usize },
#[error(
"migrating to schema version {version} produced a schema this client does not expect (expected {expected}, found {actual})"
)]
MigratedSchemaMismatch {
version: usize,
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)
}
}
Loading
Loading