commit b7922dee64ab48343a19341d735219fa463432e1
parent fa6319f81be4b526f4a7d8be1fe05629a8925d06
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:
7 files changed, 206 insertions(+), 10 deletions(-)
diff --git a/core/crates/application/src/lib.rs b/core/crates/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/core/crates/application/src/ports.rs b/core/crates/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/core/crates/storage/migrations/V4__account_namespace.sql b/core/crates/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/core/crates/storage/src/account_namespace.rs b/core/crates/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/core/crates/storage/src/db.rs b/core/crates/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/core/crates/storage/src/lib.rs b/core/crates/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;
diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md
@@ -477,7 +477,7 @@ handoff commit sequence.
- [x] 15. Create SQLite storage crate and migration runner.
- [x] 16. Implement account and selected-account persistence.
- [x] 17. Implement profile cache persistence.
-- [ ] 18. Implement typed account-scoped namespace persistence.
+- [x] 18. Implement typed account-scoped namespace persistence.
- [ ] 19. Add operation journal persistence.
- [ ] 20. Implement storage adapter wiring for AppCore bootstrap.