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:
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.