app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 0197b5fd728b99950805ab7049515ce9c5009fbf
parent 7b3825d12fe9240e0d92232f8d09c51e8174cd26
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 22:44:31 +0000

storage: migrate V5 runtime data append-only

- validate legacy public identities before applying migrations
- copy accounts bindings profiles selection and journals into normalized tables
- preserve canonical database and credential-store coordinates
- cover a populated V5 database migrating intact to schema version 7

Diffstat:
Mcore/crates/ffi/src/commands.rs | 2+-
Acore/crates/storage/migrations/V7__migrate_v5_runtime_data.sql | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/storage/src/db.rs | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
3 files changed, 170 insertions(+), 4 deletions(-)

diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -397,7 +397,7 @@ mod tests { assert_eq!(property("baseline.id"), Some("studio-runtime-v5")); assert_eq!(property("schema.version"), Some("5")); - assert_eq!(CURRENT_SCHEMA_VERSION, 6); + assert_eq!(CURRENT_SCHEMA_VERSION, 7); assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1")); assert_eq!(property("ffi.snapshot.schema"), Some("1")); assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha")); diff --git a/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql b/core/crates/storage/migrations/V7__migrate_v5_runtime_data.sql @@ -0,0 +1,68 @@ +INSERT INTO account_identities ( + public_key, + npub, + label, + created_at, + last_used_at +) +SELECT pubkey, npub, label, created_at, last_used_at +FROM accounts; + +INSERT INTO local_signer_bindings ( + account_public_key, + binding_public_key, + binding_kind, + availability +) +SELECT pubkey, pubkey, 'local_secret', key_availability +FROM accounts; + +UPDATE runtime_state +SET selected_public_key = ( + SELECT selected_pubkey FROM app_state WHERE singleton = 1 +) +WHERE singleton = 1; + +INSERT INTO profile_cache_v6 ( + subject_public_key, + event_id, + event_created_at, + name, + display_name, + nip05, + about, + picture, + refreshed_at, + refresh_status +) +SELECT + subject_pubkey, + event_id, + event_created_at, + name, + display_name, + nip05, + about, + picture, + refreshed_at, + refresh_status +FROM profile_cache; + +INSERT INTO durable_operations ( + request_id, + operation_kind, + account_public_key, + binding_public_key, + phase, + updated_at, + diagnostic_code +) +SELECT + 'legacy-v5-' || operation_id, + CASE operation_kind WHEN 'add' THEN 'create' ELSE operation_kind END, + subject_pubkey, + subject_pubkey, + phase, + updated_at, + diagnostic_code +FROM operation_journal; diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs @@ -5,11 +5,11 @@ use std::sync::{Mutex, MutexGuard}; use std::time::Duration; use fs2::FileExt; -use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; +use radroots_studio_domain::{AccountIdentity, PublicKey, SafeError, SafeErrorCode, SafeMessage}; use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; -pub const CURRENT_SCHEMA_VERSION: u32 = 6; +pub const CURRENT_SCHEMA_VERSION: u32 = 7; mod migrations { use super::embed_migrations; @@ -49,6 +49,7 @@ impl Database { let mut connection = Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; configure(&connection).map_err(|_| corrupt_storage_error())?; + validate_legacy_account_identities(&connection)?; migrations::migrations::runner() .run(&mut connection) .map_err(|_| corrupt_storage_error())?; @@ -160,6 +161,48 @@ fn configure(connection: &Connection) -> Result<(), SafeError> { .map_err(|_| storage_error()) } +fn validate_legacy_account_identities(connection: &Connection) -> Result<(), SafeError> { + let has_accounts = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'accounts')", + [], + |row| row.get::<_, bool>(0), + ) + .map_err(|_| corrupt_storage_error())?; + if !has_accounts { + return Ok(()); + } + + let mut statement = connection + .prepare("SELECT pubkey, npub, signer_kind, key_availability FROM accounts") + .map_err(|_| corrupt_storage_error())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|_| corrupt_storage_error())?; + for row in rows { + let (public_key, npub, signer_kind, availability) = + row.map_err(|_| corrupt_storage_error())?; + let public_key = PublicKey::from_hex(&public_key).map_err(|_| corrupt_storage_error())?; + AccountIdentity::verify(public_key, npub).map_err(|_| corrupt_storage_error())?; + if signer_kind != "local_secret" + || !matches!( + availability.as_str(), + "available" | "credential_missing" | "store_unavailable" + ) + { + return Err(corrupt_storage_error()); + } + } + Ok(()) +} + fn restrict_sqlite_sidecars(path: &Path) -> Result<(), SafeError> { for suffix in ["-wal", "-shm"] { let sidecar = PathBuf::from(format!("{}{suffix}", path.display())); @@ -221,7 +264,12 @@ mod tests { use tempfile::tempdir; - use super::{CURRENT_SCHEMA_VERSION, Database}; + use radroots_studio_application::{AccountRepository, AppStateRepository}; + use radroots_studio_domain::PublicKey; + use refinery::Target; + use rusqlite::Connection; + + use super::{CURRENT_SCHEMA_VERSION, Database, configure, migrations}; #[test] fn migration_opens_fresh_memory_database_once() { @@ -301,6 +349,56 @@ mod tests { } #[test] + fn v5_data_migrates_append_only_with_identity_profile_and_selection() { + let directory = tempdir().expect("temporary directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = "07".repeat(32); + { + let mut connection = Connection::open(&path).expect("legacy database"); + configure(&connection).expect("configuration"); + migrations::migrations::runner() + .set_target(Target::Version(5)) + .run(&mut connection) + .expect("V5 schema"); + connection + .execute( + "INSERT INTO accounts (pubkey, npub, signer_kind, key_availability, created_at) VALUES (?1, ?2, 'local_secret', 'available', 10)", + [&public_key, "npub1qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qurswpc8qursnvjvl7"], + ) + .expect("legacy account"); + connection + .execute( + "UPDATE app_state SET selected_pubkey = ?1 WHERE singleton = 1", + [&public_key], + ) + .expect("legacy selection"); + connection + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, refreshed_at, refresh_status) VALUES (?1, ?2, 11, 'Farm', 12, 'success')", + [&public_key, &"01".repeat(32)], + ) + .expect("legacy profile"); + } + + let database = Database::open(&path).expect("migrated database"); + assert_eq!(database.schema_version().expect("version"), 7); + assert_eq!(database.list_accounts().expect("accounts").len(), 1); + assert_eq!( + database.load_selected_account().expect("selection"), + Some(PublicKey::from_bytes([7; 32])) + ); + let connection = database.connection(); + let migrated: (i64, i64, i64) = connection + .query_row( + "SELECT (SELECT COUNT(*) FROM account_identities), (SELECT COUNT(*) FROM local_signer_bindings), (SELECT COUNT(*) FROM profile_cache_v6)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("migrated inventory"); + assert_eq!(migrated, (1, 1, 1)); + } + + #[test] fn migration_persists_schema_version_across_file_reopen() { let directory = tempdir().expect("temporary directory"); let path = directory.path().join("studio.sqlite3");