lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 75fac0f4ddeaabd8246c4a8e9732d8491cc32e9a
parent 462d0ddbc8a7f9f323a7e2bfabdcd18536a64bb0
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:40:18 +0000

core(storage): add account namespace persistence

- introduce a typed internal preference key boundary
- partition local values by canonical account public key
- verify selection resolves the correct account namespace
- cascade private local state with removed account metadata

Diffstat:
Mcrates/studio_application/src/lib.rs | 5+++--
Mcrates/studio_application/src/ports.rs | 36++++++++++++++++++++++++++++++------
Acrates/studio_storage/migrations/V4__account_namespace.sql | 6++++++
Acrates/studio_storage/src/account_namespace.rs | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/studio_storage/src/db.rs | 2+-
Mcrates/studio_storage/src/lib.rs | 1+
6 files changed, 205 insertions(+), 9 deletions(-)

diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -7,8 +7,9 @@ pub mod state_machine; pub use app_core::{AppCore, AppObserver, ObserverHandle}; pub use ports::{ - AccountNamespaceRepository, AccountRepository, AppStateRepository, BoxFuture, CachedProfile, - Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, SecretStore, + AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, + BoxFuture, CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, + SecretStore, }; pub use snapshot::{ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConfiguration, diff --git a/crates/studio_application/src/ports.rs b/crates/studio_application/src/ports.rs @@ -21,6 +21,11 @@ pub struct CachedProfile { refresh_status: ProfileRefreshStatus, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountPreferenceKey { + NamespaceProbe, +} + impl CachedProfile { #[must_use] pub const fn new( @@ -123,13 +128,22 @@ pub trait AccountNamespaceRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when the value cannot be read. - fn get_value(&self, owner: PublicKey, key: &str) -> Result<Option<String>, SafeError>; + fn get_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError>; /// Writes one internal non-secret account-scoped value. /// /// # Errors /// /// Returns a safe storage error when the value cannot be committed. - fn set_value(&self, owner: PublicKey, key: &str, value: &str) -> Result<(), SafeError>; + fn set_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + value: &str, + ) -> Result<(), SafeError>; /// Removes all internal values owned by an account. /// /// # Errors @@ -176,8 +190,9 @@ mod tests { }; use super::{ - AccountNamespaceRepository, AccountRepository, AppStateRepository, BoxFuture, - CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, SecretStore, + AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, + BoxFuture, CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, + SecretStore, }; #[derive(Default)] @@ -234,11 +249,20 @@ mod tests { } impl AccountNamespaceRepository for FakePorts { - fn get_value(&self, _owner: PublicKey, _key: &str) -> Result<Option<String>, SafeError> { + fn get_value( + &self, + _owner: PublicKey, + _key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError> { Ok(None) } - fn set_value(&self, _owner: PublicKey, _key: &str, _value: &str) -> Result<(), SafeError> { + fn set_value( + &self, + _owner: PublicKey, + _key: AccountPreferenceKey, + _value: &str, + ) -> Result<(), SafeError> { Ok(()) } diff --git a/crates/studio_storage/migrations/V4__account_namespace.sql b/crates/studio_storage/migrations/V4__account_namespace.sql @@ -0,0 +1,6 @@ +CREATE TABLE account_namespace ( + owner_pubkey TEXT NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, + preference_key TEXT NOT NULL CHECK (preference_key IN ('namespace_probe')), + preference_value TEXT NOT NULL CHECK (length(preference_value) <= 4096), + PRIMARY KEY (owner_pubkey, preference_key) +) STRICT; diff --git a/crates/studio_storage/src/account_namespace.rs b/crates/studio_storage/src/account_namespace.rs @@ -0,0 +1,164 @@ +use radroots_studio_application::{AccountNamespaceRepository, AccountPreferenceKey}; +use radroots_studio_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage}; +use rusqlite::{OptionalExtension, params}; + +use crate::Database; + +const MAX_VALUE_CHARS: usize = 4_096; + +impl AccountNamespaceRepository for Database { + fn get_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + ) -> Result<Option<String>, SafeError> { + self.connection() + .query_row( + "SELECT preference_value FROM account_namespace \ + WHERE owner_pubkey = ?1 AND preference_key = ?2", + params![owner.to_hex(), encode_key(key)], + |row| row.get(0), + ) + .optional() + .map_err(|_| storage_error()) + } + + fn set_value( + &self, + owner: PublicKey, + key: AccountPreferenceKey, + value: &str, + ) -> Result<(), SafeError> { + if value.chars().count() > MAX_VALUE_CHARS || value.chars().any(char::is_control) { + return Err(invalid_preference()); + } + self.connection() + .execute( + "INSERT INTO account_namespace (owner_pubkey, preference_key, preference_value) \ + VALUES (?1, ?2, ?3) ON CONFLICT(owner_pubkey, preference_key) DO UPDATE SET \ + preference_value = excluded.preference_value", + params![owner.to_hex(), encode_key(key), value], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn clear_owner(&self, owner: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM account_namespace WHERE owner_pubkey = ?1", + [owner.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +const fn encode_key(key: AccountPreferenceKey) -> &'static str { + match key { + AccountPreferenceKey::NamespaceProbe => "namespace_probe", + } +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The account preference is unavailable."), + ) +} + +const fn invalid_preference() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidAccountMetadata, + SafeMessage::new("The account preference is invalid."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountNamespaceRepository, AccountPreferenceKey, AccountRepository, AppStateRepository, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountSummary, KeyAvailability, Npub, PublicKey, SignerKind, + UnixTimestamp, + }; + + use crate::Database; + + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account(byte: u8) -> AccountSummary { + AccountSummary::new( + PublicKey::from_bytes([byte; 32]), + Npub::from_encoded(NPUB.to_owned()).expect("npub"), + SignerKind::LocalSecret, + KeyAvailability::Available, + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(i64::from(byte)).expect("time")), + None, + ) + } + + #[test] + fn namespace_partitions_same_typed_key_by_owner_and_selection() { + let database = Database::in_memory().expect("database"); + let owner_a = PublicKey::from_bytes([1; 32]); + let owner_b = PublicKey::from_bytes([2; 32]); + database.insert_account(&account(1)).expect("account a"); + database.insert_account(&account(2)).expect("account b"); + database + .set_value(owner_a, AccountPreferenceKey::NamespaceProbe, "A") + .expect("set a"); + database + .set_value(owner_b, AccountPreferenceKey::NamespaceProbe, "B") + .expect("set b"); + + database + .save_selected_account(Some(owner_b)) + .expect("select b"); + let selected = database + .load_selected_account() + .expect("selection") + .expect("selected owner"); + assert_eq!( + database + .get_value(selected, AccountPreferenceKey::NamespaceProbe) + .expect("selected value"), + Some("B".to_owned()) + ); + assert_eq!( + database + .get_value(owner_a, AccountPreferenceKey::NamespaceProbe) + .expect("owner a value"), + Some("A".to_owned()) + ); + } + + #[test] + fn namespace_updates_and_cascades_with_owner_removal() { + let database = Database::in_memory().expect("database"); + let owner = PublicKey::from_bytes([3; 32]); + database.insert_account(&account(3)).expect("account"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "before") + .expect("set"); + database + .set_value(owner, AccountPreferenceKey::NamespaceProbe, "after") + .expect("update"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("value"), + Some("after".to_owned()) + ); + + database.remove_account(owner).expect("remove"); + assert_eq!( + database + .get_value(owner, AccountPreferenceKey::NamespaceProbe) + .expect("deleted value"), + None + ); + } +} diff --git a/crates/studio_storage/src/db.rs b/crates/studio_storage/src/db.rs @@ -7,7 +7,7 @@ use refinery::embed_migrations; use rusqlite::{Connection, OpenFlags}; #[cfg(test)] -const LATEST_SCHEMA_VERSION: u32 = 3; +const LATEST_SCHEMA_VERSION: u32 = 4; mod migrations { use super::embed_migrations; diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -1,5 +1,6 @@ #![doc = "Radroots Studio persistence adapters."] +pub mod account_namespace; pub mod accounts; pub mod db; pub mod profiles;