lib

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

commit 462d0ddbc8a7f9f323a7e2bfabdcd18536a64bb0
parent a73eb2e5cc0f8a807fd505070773ec1d29156caa
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:32:45 +0000

core(storage): persist Nostr profile cache

- migrate bounded parsed kind-zero profile fields
- retain verified event identity and refresh status
- enforce replaceable-event ordering during upsert
- cascade cached public metadata with removed accounts

Diffstat:
Mcrates/studio_application/src/lib.rs | 4++--
Mcrates/studio_application/src/ports.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Acrates/studio_storage/migrations/V3__profile_cache.sql | 12++++++++++++
Mcrates/studio_storage/src/db.rs | 2+-
Mcrates/studio_storage/src/lib.rs | 1+
Acrates/studio_storage/src/profiles.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 341 insertions(+), 16 deletions(-)

diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -7,8 +7,8 @@ pub mod state_machine; pub use app_core::{AppCore, AppObserver, ObserverHandle}; pub use ports::{ - AccountNamespaceRepository, AccountRepository, AppStateRepository, BoxFuture, Clock, - NostrClient, ProfileRepository, SecretStore, + AccountNamespaceRepository, 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 @@ -2,12 +2,55 @@ use std::future::Future; use std::pin::Pin; use radroots_studio_domain::{ - AccountSummary, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError, - UnixTimestamp, + AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp, }; pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProfileRefreshStatus { + Success, + Offline, + InvalidData, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CachedProfile { + candidate: Kind0ProfileCandidate, + refreshed_at: UnixTimestamp, + refresh_status: ProfileRefreshStatus, +} + +impl CachedProfile { + #[must_use] + pub const fn new( + candidate: Kind0ProfileCandidate, + refreshed_at: UnixTimestamp, + refresh_status: ProfileRefreshStatus, + ) -> Self { + Self { + candidate, + refreshed_at, + refresh_status, + } + } + + #[must_use] + pub const fn candidate(&self) -> &Kind0ProfileCandidate { + &self.candidate + } + + #[must_use] + pub const fn refreshed_at(&self) -> UnixTimestamp { + self.refreshed_at + } + + #[must_use] + pub const fn refresh_status(&self) -> ProfileRefreshStatus { + self.refresh_status + } +} + pub trait AccountRepository: Send + Sync { /// Lists saved public account records in deterministic order. /// @@ -48,13 +91,24 @@ pub trait ProfileRepository: Send + Sync { /// # Errors /// /// Returns a safe storage error when the cache cannot be read. - fn load_profile(&self, public_key: PublicKey) -> Result<Option<ProfileMetadata>, SafeError>; + fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError>; /// Saves a verified kind-0 profile candidate. /// /// # Errors /// /// Returns a safe storage error when the cache cannot be committed. - fn save_profile(&self, candidate: &Kind0ProfileCandidate) -> Result<(), SafeError>; + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError>; + /// Records the result of a profile refresh without replacing cached metadata. + /// + /// # Errors + /// + /// Returns a safe storage error when the cache cannot be committed. + fn record_refresh_status( + &self, + public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError>; /// Removes cached profile metadata for an account. /// /// # Errors @@ -118,13 +172,12 @@ mod tests { use std::sync::Mutex; use radroots_studio_domain::{ - AccountSummary, Kind0ProfileCandidate, ProfileMetadata, PublicKey, RelayUrl, SafeError, - UnixTimestamp, + AccountSummary, Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, UnixTimestamp, }; use super::{ - AccountNamespaceRepository, AccountRepository, AppStateRepository, BoxFuture, Clock, - NostrClient, ProfileRepository, SecretStore, + AccountNamespaceRepository, AccountRepository, AppStateRepository, BoxFuture, + CachedProfile, Clock, NostrClient, ProfileRefreshStatus, ProfileRepository, SecretStore, }; #[derive(Default)] @@ -158,14 +211,20 @@ mod tests { } impl ProfileRepository for FakePorts { - fn load_profile( - &self, - _public_key: PublicKey, - ) -> Result<Option<ProfileMetadata>, SafeError> { + fn load_profile(&self, _public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { Ok(None) } - fn save_profile(&self, _candidate: &Kind0ProfileCandidate) -> Result<(), SafeError> { + fn save_profile(&self, _profile: &CachedProfile) -> Result<(), SafeError> { + Ok(()) + } + + fn record_refresh_status( + &self, + _public_key: PublicKey, + _refreshed_at: UnixTimestamp, + _status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { Ok(()) } diff --git a/crates/studio_storage/migrations/V3__profile_cache.sql b/crates/studio_storage/migrations/V3__profile_cache.sql @@ -0,0 +1,12 @@ +CREATE TABLE profile_cache ( + subject_pubkey TEXT PRIMARY KEY NOT NULL REFERENCES accounts(pubkey) ON DELETE CASCADE, + event_id TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + name TEXT, + display_name TEXT, + nip05 TEXT, + about TEXT, + picture TEXT, + refreshed_at INTEGER NOT NULL, + refresh_status TEXT NOT NULL CHECK (refresh_status IN ('success', 'offline', 'invalid_data')) +) STRICT; 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 = 2; +const LATEST_SCHEMA_VERSION: u32 = 3; mod migrations { use super::embed_migrations; diff --git a/crates/studio_storage/src/lib.rs b/crates/studio_storage/src/lib.rs @@ -2,5 +2,6 @@ pub mod accounts; pub mod db; +pub mod profiles; pub use db::Database; diff --git a/crates/studio_storage/src/profiles.rs b/crates/studio_storage/src/profiles.rs @@ -0,0 +1,253 @@ +use radroots_studio_application::{CachedProfile, ProfileRefreshStatus, ProfileRepository}; +use radroots_studio_domain::{ + EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, SafeErrorCode, + SafeMessage, UnixTimestamp, +}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::Database; + +impl ProfileRepository for Database { + fn load_profile(&self, public_key: PublicKey) -> Result<Option<CachedProfile>, SafeError> { + self.connection() + .query_row( + "SELECT event_id, event_created_at, name, display_name, nip05, about, picture, \ + refreshed_at, refresh_status FROM profile_cache WHERE subject_pubkey = ?1", + [public_key.to_hex()], + |row| decode_profile(row, public_key), + ) + .optional() + .map_err(|_| corrupt_storage_error()) + } + + fn save_profile(&self, profile: &CachedProfile) -> Result<(), SafeError> { + let candidate = profile.candidate(); + let metadata = candidate.metadata(); + self.connection() + .execute( + "INSERT INTO profile_cache (subject_pubkey, event_id, event_created_at, name, \ + display_name, nip05, about, picture, refreshed_at, refresh_status) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + ON CONFLICT(subject_pubkey) DO UPDATE SET \ + event_id = excluded.event_id, event_created_at = excluded.event_created_at, \ + name = excluded.name, display_name = excluded.display_name, nip05 = excluded.nip05, \ + about = excluded.about, picture = excluded.picture, \ + refreshed_at = excluded.refreshed_at, refresh_status = excluded.refresh_status \ + WHERE excluded.event_created_at > profile_cache.event_created_at \ + OR (excluded.event_created_at = profile_cache.event_created_at \ + AND excluded.event_id < profile_cache.event_id)", + params![ + candidate.author().to_hex(), + candidate.event_id().to_hex(), + candidate.created_at().as_seconds(), + metadata.name(), + metadata.display_name(), + metadata.nip05(), + metadata.about(), + metadata.picture(), + profile.refreshed_at().as_seconds(), + encode_refresh_status(profile.refresh_status()), + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn record_refresh_status( + &self, + public_key: PublicKey, + refreshed_at: UnixTimestamp, + status: ProfileRefreshStatus, + ) -> Result<(), SafeError> { + self.connection() + .execute( + "UPDATE profile_cache SET refreshed_at = ?2, refresh_status = ?3 \ + WHERE subject_pubkey = ?1", + params![ + public_key.to_hex(), + refreshed_at.as_seconds(), + encode_refresh_status(status) + ], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } + + fn remove_profile(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.connection() + .execute( + "DELETE FROM profile_cache WHERE subject_pubkey = ?1", + [public_key.to_hex()], + ) + .map(|_| ()) + .map_err(|_| storage_error()) + } +} + +fn decode_profile(row: &Row<'_>, author: PublicKey) -> rusqlite::Result<CachedProfile> { + let event_id = + EventId::from_hex(row.get::<_, String>(0)?.as_str()).map_err(|_| invalid_column(0))?; + let created_at = UnixTimestamp::from_seconds(row.get(1)?).ok_or_else(|| invalid_column(1))?; + let metadata = ProfileMetadata::new( + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + ) + .map_err(|_| invalid_column(2))?; + let refreshed_at = UnixTimestamp::from_seconds(row.get(7)?).ok_or_else(|| invalid_column(7))?; + let refresh_status = decode_refresh_status(row.get::<_, String>(8)?.as_str())?; + Ok(CachedProfile::new( + Kind0ProfileCandidate::new(event_id, author, created_at, metadata), + refreshed_at, + refresh_status, + )) +} + +const fn encode_refresh_status(status: ProfileRefreshStatus) -> &'static str { + match status { + ProfileRefreshStatus::Success => "success", + ProfileRefreshStatus::Offline => "offline", + ProfileRefreshStatus::InvalidData => "invalid_data", + } +} + +fn decode_refresh_status(value: &str) -> rusqlite::Result<ProfileRefreshStatus> { + match value { + "success" => Ok(ProfileRefreshStatus::Success), + "offline" => Ok(ProfileRefreshStatus::Offline), + "invalid_data" => Ok(ProfileRefreshStatus::InvalidData), + _ => Err(invalid_column(8)), + } +} + +fn invalid_column(index: usize) -> rusqlite::Error { + rusqlite::Error::InvalidColumnType( + index, + "cached Nostr profile".to_owned(), + rusqlite::types::Type::Text, + ) +} + +const fn storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The profile cache is unavailable."), + ) +} + +const fn corrupt_storage_error() -> SafeError { + SafeError::new( + SafeErrorCode::StorageCorrupt, + SafeMessage::new("The profile cache could not be read."), + ) +} + +#[cfg(test)] +mod tests { + use radroots_studio_application::{ + AccountRepository, CachedProfile, ProfileRefreshStatus, ProfileRepository, + }; + use radroots_studio_domain::{ + AccountCreatedAt, AccountSummary, EventId, KeyAvailability, Kind0ProfileCandidate, Npub, + ProfileMetadata, PublicKey, SignerKind, UnixTimestamp, + }; + + use crate::Database; + + const NPUB: &str = "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"; + + fn account(public_key: PublicKey) -> AccountSummary { + AccountSummary::new( + public_key, + Npub::from_encoded(NPUB.to_owned()).expect("npub"), + SignerKind::LocalSecret, + KeyAvailability::Available, + None, + AccountCreatedAt::new(UnixTimestamp::from_seconds(1).expect("time")), + None, + ) + } + + fn profile(public_key: PublicKey, id: u8, created_at: i64, name: &str) -> CachedProfile { + CachedProfile::new( + Kind0ProfileCandidate::new( + EventId::from_bytes([id; 32]), + public_key, + UnixTimestamp::from_seconds(created_at).expect("time"), + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None) + .expect("metadata"), + ), + UnixTimestamp::from_seconds(created_at + 1).expect("refresh time"), + ProfileRefreshStatus::Success, + ) + } + + #[test] + fn profile_cache_round_trips_and_records_refresh_status() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([1; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("save profile"); + database + .record_refresh_status( + public_key, + UnixTimestamp::from_seconds(20).expect("time"), + ProfileRefreshStatus::Offline, + ) + .expect("record status"); + + let loaded = database + .load_profile(public_key) + .expect("load profile") + .expect("cached profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Farm")); + assert_eq!(loaded.refreshed_at().as_seconds(), 20); + assert_eq!(loaded.refresh_status(), ProfileRefreshStatus::Offline); + } + + #[test] + fn profile_cache_keeps_newest_then_lowest_event_id() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([2; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 9, 20, "High ID")) + .expect("initial"); + database + .save_profile(&profile(public_key, 1, 20, "Low ID")) + .expect("equal newer candidate"); + database + .save_profile(&profile(public_key, 0, 10, "Older")) + .expect("older candidate"); + + let loaded = database + .load_profile(public_key) + .expect("load") + .expect("profile"); + assert_eq!(loaded.candidate().metadata().name(), Some("Low ID")); + assert_eq!(loaded.candidate().event_id(), EventId::from_bytes([1; 32])); + } + + #[test] + fn profile_cache_cascades_with_account_removal() { + let database = Database::in_memory().expect("database"); + let public_key = PublicKey::from_bytes([3; 32]); + database + .insert_account(&account(public_key)) + .expect("account"); + database + .save_profile(&profile(public_key, 1, 10, "Farm")) + .expect("profile"); + database.remove_account(public_key).expect("remove account"); + + assert_eq!(database.load_profile(public_key).expect("load"), None); + } +}