app

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

commit 7bbef21298796ada832bc7d8a4aa6e9583c9100f
parent 8529800d2263ca7e9904b5b0884971376c505ab8
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:45:45 +0000

core(storage): wire persistence into AppCore bootstrap

- load durable accounts and selection into one ready snapshot
- restore every startup in an explicitly signed-out session
- publish safe fatal state for unreadable durable storage
- preserve corrupt database bytes without destructive recreation

Diffstat:
Mcore/crates/application/src/app_core.rs | 32+++++++++++++++++++++++++++++++-
Mcore/crates/application/src/snapshot.rs | 18++++++++++++++++++
Mcore/crates/application/src/state_machine.rs | 32++++++++++++++++++++++++++++++++
Acore/crates/storage/src/application_adapter.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/storage/src/db.rs | 2+-
Mcore/crates/storage/src/lib.rs | 2++
Mdocs/implementation/nostr-runtime-rcld.md | 6+++---
7 files changed, 220 insertions(+), 5 deletions(-)

diff --git a/core/crates/application/src/app_core.rs b/core/crates/application/src/app_core.rs @@ -3,7 +3,10 @@ use std::sync::{Arc, Mutex, MutexGuard}; use radroots_studio_domain::{SafeError, SafeErrorCode, SafeMessage}; -use crate::{AppSnapshot, RelayConfiguration, StateMachine, StateTransition}; +use crate::{ + AccountRepository, AppSnapshot, AppStateRepository, RelayConfiguration, StateMachine, + StateTransition, +}; pub trait AppObserver: Send + Sync { fn on_snapshot_changed(&self, snapshot: AppSnapshot); @@ -53,6 +56,33 @@ impl AppCore { self.apply_transition(StateTransition::Bootstrap) } + /// Loads the durable public registry and selection into a signed-out snapshot. + /// + /// # Errors + /// + /// Returns the safe persistence error after publishing a fatal snapshot when + /// durable state cannot be read or violates application invariants. + pub fn bootstrap_from( + &self, + accounts: &(impl AccountRepository + ?Sized), + app_state: &(impl AppStateRepository + ?Sized), + ) -> Result<AppSnapshot, SafeError> { + let loaded = accounts.list_accounts().and_then(|accounts| { + app_state + .load_selected_account() + .map(|selected| (accounts, selected)) + }); + match loaded { + Ok((accounts, selected)) => { + self.apply_transition(StateTransition::BootstrapRegistry { accounts, selected }) + } + Err(error) => { + self.apply_transition(StateTransition::Fatal(error))?; + Err(error) + } + } + } + #[must_use] pub fn snapshot(&self) -> AppSnapshot { self.lock_state().state_machine.snapshot().clone() diff --git a/core/crates/application/src/snapshot.rs b/core/crates/application/src/snapshot.rs @@ -153,6 +153,24 @@ impl AppSnapshot { } } + #[must_use] + pub fn fatal( + revision: SnapshotRevision, + relay_configuration: RelayConfiguration, + error: SafeError, + ) -> Self { + Self { + revision, + lifecycle: AppLifecycle::Fatal(error), + relay_configuration, + accounts: Vec::new(), + selected_account: None, + session: SessionState::SignedOut, + active_account: None, + recoverable_problem: None, + } + } + /// Constructs a ready immutable snapshot after validating state invariants. /// /// # Errors diff --git a/core/crates/application/src/state_machine.rs b/core/crates/application/src/state_machine.rs @@ -5,6 +5,11 @@ use crate::{ActiveAccountSnapshot, AppLifecycle, AppSnapshot, RelayConfiguration #[derive(Clone, Debug, Eq, PartialEq)] pub enum StateTransition { Bootstrap, + BootstrapRegistry { + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + }, + Fatal(SafeError), ReplaceRegistry { accounts: Vec<AccountSummary>, selected: Option<PublicKey>, @@ -61,6 +66,12 @@ impl StateMachine { let next = match transition { StateTransition::Bootstrap => self.bootstrap(next_revision, relay_configuration)?, + StateTransition::BootstrapRegistry { accounts, selected } => { + self.bootstrap_registry(next_revision, relay_configuration, accounts, selected)? + } + StateTransition::Fatal(error) => { + AppSnapshot::fatal(next_revision, relay_configuration.clone(), error) + } StateTransition::ReplaceRegistry { accounts, selected } => { self.replace_registry(next_revision, accounts, selected)? } @@ -106,6 +117,27 @@ impl StateMachine { ) } + fn bootstrap_registry( + &self, + revision: crate::SnapshotRevision, + relay_configuration: &RelayConfiguration, + accounts: Vec<AccountSummary>, + selected: Option<PublicKey>, + ) -> Result<AppSnapshot, SafeError> { + if !matches!(self.snapshot.lifecycle(), AppLifecycle::Booting) { + return Ok(self.snapshot.clone()); + } + AppSnapshot::ready( + revision, + relay_configuration.clone(), + accounts, + selected, + SessionState::SignedOut, + None, + None, + ) + } + fn replace_registry( &mut self, revision: crate::SnapshotRevision, diff --git a/core/crates/storage/src/application_adapter.rs b/core/crates/storage/src/application_adapter.rs @@ -0,0 +1,133 @@ +use std::path::Path; + +use radroots_studio_application::{AppCore, AppSnapshot, RelayConfiguration}; +use radroots_studio_domain::SafeError; + +use crate::Database; + +pub struct PersistentAppCore { + core: AppCore, + database: Database, +} + +impl PersistentAppCore { + /// Opens the application database without accessing credentials or relays. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be opened or migrated. + pub fn open(path: &Path, relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + Ok(Self { + core: AppCore::in_memory(relay_configuration), + database: Database::open(path)?, + }) + } + + /// Creates an isolated persistent-core adapter for tests. + /// + /// # Errors + /// + /// Returns a safe storage error when the database cannot be initialized. + pub fn in_memory(relay_configuration: RelayConfiguration) -> Result<Self, SafeError> { + Ok(Self { + core: AppCore::in_memory(relay_configuration), + database: Database::in_memory()?, + }) + } + + /// Restores public accounts and selection while keeping the session signed out. + /// + /// # Errors + /// + /// Returns a safe storage or application-state error after publishing a fatal + /// snapshot when durable state cannot be restored. + pub fn bootstrap(&self) -> Result<AppSnapshot, SafeError> { + self.core.bootstrap_from(&self.database, &self.database) + } + + #[must_use] + pub const fn core(&self) -> &AppCore { + &self.core + } + + #[must_use] + pub const fn database(&self) -> &Database { + &self.database + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use radroots_studio_application::{ + AccountRepository, AppLifecycle, AppStateRepository, RelayConfiguration, SessionState, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SafeErrorCode, + SignerKind, UnixTimestamp, + }; + use tempfile::tempdir; + + use super::PersistentAppCore; + + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account() -> AccountSummary { + AccountSummary::new( + PublicKey::from_bytes([4; 32]), + Npub::from_encoded(NPUB.to_owned()).expect("npub"), + SignerKind::LocalSecret, + KeyAvailability::Available, + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + } + + #[test] + fn persistent_bootstrap_handles_fresh_and_existing_signed_out_state() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + let public_key = account().public_key(); + { + let adapter = PersistentAppCore::open(&path, RelayConfiguration::default()) + .expect("open adapter"); + let fresh = adapter.bootstrap().expect("fresh bootstrap"); + assert!(fresh.accounts().is_empty()); + adapter + .database() + .insert_account(&account()) + .expect("account"); + adapter + .database() + .save_selected_account(Some(public_key)) + .expect("selection"); + } + + let adapter = + PersistentAppCore::open(&path, RelayConfiguration::default()).expect("reopen adapter"); + let restored = adapter.bootstrap().expect("restore"); + assert_eq!(restored.lifecycle(), AppLifecycle::Ready); + assert_eq!(restored.accounts().len(), 1); + assert_eq!(restored.selected_account(), Some(public_key)); + assert_eq!(restored.session(), SessionState::SignedOut); + assert!(restored.active_account().is_none()); + } + + #[test] + fn corrupt_database_fails_safely_without_recreation() { + let directory = tempdir().expect("directory"); + let path = directory.path().join("studio.sqlite3"); + fs::write(&path, b"not a sqlite database").expect("corrupt file"); + + let error = PersistentAppCore::open(&path, RelayConfiguration::default()) + .err() + .expect("safe failure"); + assert_eq!(error.code(), SafeErrorCode::StorageCorrupt); + assert_eq!( + fs::read(&path).expect("unchanged file"), + b"not a sqlite database" + ); + } +} diff --git a/core/crates/storage/src/db.rs b/core/crates/storage/src/db.rs @@ -32,7 +32,7 @@ impl Database { | OpenFlags::SQLITE_OPEN_NO_MUTEX; let mut connection = Connection::open_with_flags(path, flags).map_err(|_| storage_error())?; - configure(&connection)?; + configure(&connection).map_err(|_| corrupt_storage_error())?; migrations::migrations::runner() .run(&mut connection) .map_err(|_| corrupt_storage_error())?; diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs @@ -2,8 +2,10 @@ pub mod account_namespace; pub mod accounts; +pub mod application_adapter; pub mod db; pub mod journal; pub mod profiles; +pub use application_adapter::PersistentAppCore; pub use db::Database; diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -293,7 +293,7 @@ re-entry tests, and workspace regression tests. ### RCLD-04: SQLite persistence -Status: in progress. +Status: completed. Scope: checkpoints 15 through 20. Add bundled SQLite and migrations, account and selection persistence, profile cache, typed account-scoped partitioning, @@ -479,7 +479,7 @@ handoff commit sequence. - [x] 17. Implement profile cache persistence. - [x] 18. Implement typed account-scoped namespace persistence. - [x] 19. Add operation journal persistence. -- [ ] 20. Implement storage adapter wiring for AppCore bootstrap. +- [x] 20. Implement storage adapter wiring for AppCore bootstrap. ### RCLD-05 @@ -554,7 +554,7 @@ handoff commit sequence. - [x] RCLD-01: Authority and dependency baseline. - [x] RCLD-02: Rust workspace and domain. - [x] RCLD-03: Application state machine. -- [ ] RCLD-04: SQLite persistence. +- [x] RCLD-04: SQLite persistence. - [ ] RCLD-05: Credential boundary. - [ ] RCLD-06: Account generation and import. - [ ] RCLD-07: Account lifecycle and recovery.