commit 99bdc743961889f608ed2c1d7cb239bbc605263a
parent 76e17d3d85d07107fa71024c97586bb41e86d49e
Author: kernelkind <kernelkind@gmail.com>
Date: Thu, 19 Feb 2026 13:30:24 -0500
feat(scoped-subs): add owner slots and app-facing facade
Add host-owned owner-slot mapping plus ScopedSubApi/ScopedSubsState wrappers so app code can set, clear, and drop scoped subscriptions by stable owner+key identifiers without handling runtime internals.
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
4 files changed, 336 insertions(+), 0 deletions(-)
diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs
@@ -32,6 +32,9 @@ pub mod relay_debug;
pub mod relayspec;
mod result;
mod route;
+mod scoped_sub_api;
+mod scoped_sub_owners;
+mod scoped_sub_state;
mod scoped_subs;
mod setup;
pub mod storage;
@@ -91,6 +94,9 @@ pub use relay_debug::RelayDebugView;
pub use relayspec::RelaySpec;
pub use result::Result;
pub use route::{DrawerRouter, ReplacementType, Router};
+pub use scoped_sub_api::ScopedSubApi;
+pub use scoped_sub_owners::SubOwnerKeyBuilder;
+pub use scoped_sub_state::ScopedSubsState;
pub use scoped_subs::{
ClearSubResult, DropSlotResult, EnsureSubResult, RelaySelection, ScopedSubEoseStatus,
ScopedSubIdentity, ScopedSubLiveEoseStatus, SetSubResult, SubConfig, SubKey, SubKeyBuilder,
diff --git a/crates/notedeck/src/scoped_sub_api.rs b/crates/notedeck/src/scoped_sub_api.rs
@@ -0,0 +1,160 @@
+use enostr::Pubkey;
+
+use crate::scoped_sub_owners::ScopedSubOwners;
+use crate::scoped_subs::ScopedSubRuntime;
+use crate::{
+ Accounts, ClearSubResult, EnsureSubResult, Outbox, ScopedSubEoseStatus, ScopedSubIdentity,
+ SetSubResult, SubConfig, SubOwnerKey,
+};
+
+/// App-facing facade over scoped subscription owner/runtime operations.
+///
+/// This bundles host resources that are commonly passed together to avoid
+/// argument plumbing through app-layer helper functions.
+pub struct ScopedSubApi<'o, 'a> {
+ pool: &'o mut Outbox<'a>,
+ accounts: &'o Accounts,
+ owners: &'o mut ScopedSubOwners,
+ runtime: &'o mut ScopedSubRuntime,
+}
+
+impl<'o, 'a> ScopedSubApi<'o, 'a> {
+ pub(crate) fn new(
+ pool: &'o mut Outbox<'a>,
+ accounts: &'o Accounts,
+ owners: &'o mut ScopedSubOwners,
+ runtime: &'o mut ScopedSubRuntime,
+ ) -> Self {
+ Self {
+ pool,
+ accounts,
+ owners,
+ runtime,
+ }
+ }
+
+ pub fn selected_account_pubkey(&self) -> Pubkey {
+ *self.accounts.selected_account_pubkey()
+ }
+
+ /// Create or update one scoped remote subscription declaration.
+ ///
+ /// Thread example (recommended mental model):
+ /// - `identity.owner` = one thread view lifecycle (for example one open thread pane)
+ /// - `identity.key` = `replies-by-root(root_id)`
+ /// - `identity.scope` = `SubScope::Account`
+ ///
+ /// If two thread views open the same root on the same account, they should use:
+ /// - different `owner`
+ /// - same `key`
+ /// - same `scope`
+ ///
+ /// The runtime shares one live outbox subscription for that resolved `(scope, key)`.
+ ///
+ /// `set_sub(...)` is an upsert for the resolved `(scope, key)`:
+ /// - first call creates desired state
+ /// - repeated calls update/replace desired state and may modify the live outbox sub
+ ///
+ /// Use this when the remote config can change (filters and/or relays).
+ /// For thread reply subscriptions, prefer [`Self::ensure_sub`] unless the thread's
+ /// remote filters actually change.
+ ///
+ /// Account-scoped behavior (`SubScope::Account`):
+ /// - On switch away, the live outbox subscription is unsubscribed.
+ /// - Desired state is retained while owners still exist.
+ /// - On switch back, the live outbox subscription is restored from desired state.
+ /// - If owners are dropped while away, nothing is restored.
+ pub fn set_sub(&mut self, identity: ScopedSubIdentity, config: SubConfig) -> SetSubResult {
+ self.owners
+ .set_sub(self.runtime, self.pool, self.accounts, identity, config)
+ }
+
+ /// Create a scoped remote subscription declaration only if it is absent.
+ ///
+ /// Thread open path example:
+ /// - build `identity = { owner: thread-view, key: replies-by-root(root_id), scope: Account }`
+ /// - call `ensure_sub(identity, config)` when opening the thread
+ ///
+ /// Repeated calls with the same resolved `(scope, key)`:
+ /// - keep ownership attached
+ /// - do not modify desired state
+ /// - do not modify the live outbox subscription
+ ///
+ /// This is the preferred API for stable thread reply subscriptions because it is
+ /// idempotent and avoids unnecessary outbox subscription updates on repeats.
+ ///
+ /// Account-switch behavior matches [`Self::set_sub`].
+ pub fn ensure_sub(
+ &mut self,
+ identity: ScopedSubIdentity,
+ config: SubConfig,
+ ) -> EnsureSubResult {
+ self.owners
+ .ensure_sub(self.runtime, self.pool, self.accounts, identity, config)
+ }
+
+ /// Clear one scoped subscription declaration while keeping the owner alive.
+ ///
+ /// Thread example:
+ /// - This is less common than [`Self::drop_owner`].
+ /// - Use this only if a thread owner remains alive but should stop declaring one
+ /// specific thread remote sub key.
+ ///
+ /// Outbox behavior:
+ /// - If other owners still declare the same resolved `(scope, key)`, the shared live
+ /// outbox subscription remains active.
+ /// - If this was the last owner for that `(scope, key)`, the live outbox subscription
+ /// is unsubscribed (if active) and desired state is removed.
+ pub fn clear_sub(&mut self, identity: ScopedSubIdentity) -> ClearSubResult {
+ self.owners
+ .clear_sub(self.runtime, self.pool, self.accounts, identity)
+ }
+
+ /// Clear one account-scoped declaration for an explicit account (host-only).
+ ///
+ /// This exists for host cleanup paths (for example deleting a non-selected account's
+ /// retained scoped declarations). App code should use [`Self::clear_sub`], which resolves
+ /// account scope from the currently selected account.
+ pub(crate) fn clear_sub_for_account(
+ &mut self,
+ account_pubkey: Pubkey,
+ identity: ScopedSubIdentity,
+ ) -> ClearSubResult {
+ self.owners
+ .clear_sub_for_account(self.runtime, self.pool, account_pubkey, identity)
+ }
+
+ /// Query aggregate EOSE state for one scoped subscription declaration.
+ ///
+ /// Thread example:
+ /// - query the status of `{ owner: thread-view, key: replies-by-root(root_id), scope: Account }`
+ /// - the lookup uses the current selected account to resolve `SubScope::Account`
+ ///
+ /// If the same thread root is open in multiple thread views, each owner can query the same
+ /// shared outbox subscription status through its own identity.
+ ///
+ /// Account-switch behavior:
+ /// - Switch away: status typically becomes [`ScopedSubEoseStatus::Inactive`] because the
+ /// live outbox subscription is removed while desired state is retained.
+ /// - Switch back: status may return to `Live(...)` after restore.
+ pub fn sub_eose_status(&self, identity: ScopedSubIdentity) -> ScopedSubEoseStatus {
+ self.owners
+ .sub_eose_status(self.runtime, self.pool, self.accounts, identity)
+ }
+
+ /// Drop one owner lifecycle and release all scoped subscriptions declared by it.
+ ///
+ /// Thread example:
+ /// - `owner` is one thread view lifecycle token.
+ /// - If two thread views opened the same `replies-by-root(root_id)` on the same account,
+ /// dropping one owner keeps the shared live outbox subscription active.
+ /// - Dropping the last owner unsubscribes the live outbox subscription for that thread key
+ /// (if active) and removes the retained desired declaration.
+ ///
+ /// Account-scoped behavior:
+ /// - If the thread owner is dropped while switched away, there may be no live outbox sub to
+ /// unsubscribe, but the retained declaration is removed so nothing is restored on switch-back.
+ pub fn drop_owner(&mut self, owner: SubOwnerKey) -> bool {
+ self.owners.drop_owner(self.runtime, self.pool, owner)
+ }
+}
diff --git a/crates/notedeck/src/scoped_sub_owners.rs b/crates/notedeck/src/scoped_sub_owners.rs
@@ -0,0 +1,134 @@
+use enostr::Pubkey;
+use hashbrown::HashMap;
+
+use crate::{
+ scoped_subs::{ScopedSubRuntime, SubOwnerKey, SubSlotId},
+ Accounts, ClearSubResult, EnsureSubResult, Outbox, ScopedSubEoseStatus, ScopedSubIdentity,
+ SetSubResult, SubConfig, SubKeyBuilder, SubScope,
+};
+
+/// Incremental builder for stable owner keys.
+pub type SubOwnerKeyBuilder = SubKeyBuilder;
+
+/// Host-owned mapping from owner lifecycles to runtime slots.
+///
+/// This is intended to be held by host/container code (not app feature state)
+/// so slot ids are never stored in app modules.
+#[derive(Default)]
+pub(crate) struct ScopedSubOwners {
+ slots_by_owner: HashMap<SubOwnerKey, SubSlotId>,
+}
+
+impl ScopedSubOwners {
+ /// Ensure one runtime slot exists for this owner key.
+ pub(crate) fn ensure_slot(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ owner: SubOwnerKey,
+ ) -> SubSlotId {
+ if let Some(slot) = self.slots_by_owner.get(&owner).copied() {
+ return slot;
+ }
+
+ let slot = runtime.create_slot();
+ self.slots_by_owner.insert(owner, slot);
+ slot
+ }
+
+ /// Forward an upsert scoped-sub request for an owner lifecycle.
+ pub fn set_sub(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ pool: &mut Outbox<'_>,
+ accounts: &Accounts,
+ identity: ScopedSubIdentity,
+ config: SubConfig,
+ ) -> SetSubResult {
+ let slot = self.ensure_slot(runtime, identity.owner);
+ runtime.set_sub(pool, accounts, slot, identity.scope, identity.key, config)
+ }
+
+ /// Forward a create-if-absent scoped-sub request for an owner lifecycle.
+ pub fn ensure_sub(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ pool: &mut Outbox<'_>,
+ accounts: &Accounts,
+ identity: ScopedSubIdentity,
+ config: SubConfig,
+ ) -> EnsureSubResult {
+ let slot = self.ensure_slot(runtime, identity.owner);
+ runtime.ensure_sub(pool, accounts, slot, identity.scope, identity.key, config)
+ }
+
+ /// Clear one scoped subscription binding from an owner lifecycle.
+ pub fn clear_sub(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ pool: &mut Outbox<'_>,
+ accounts: &Accounts,
+ identity: ScopedSubIdentity,
+ ) -> ClearSubResult {
+ let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else {
+ return ClearSubResult::NotFound;
+ };
+
+ runtime.clear_sub(pool, accounts, slot, identity.key, identity.scope)
+ }
+
+ /// Clear one account-scoped subscription binding from an owner lifecycle for an explicit account.
+ pub fn clear_sub_for_account(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ pool: &mut Outbox<'_>,
+ account_pubkey: Pubkey,
+ identity: ScopedSubIdentity,
+ ) -> ClearSubResult {
+ debug_assert!(matches!(identity.scope, SubScope::Account));
+ let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else {
+ return ClearSubResult::NotFound;
+ };
+
+ runtime.clear_sub_with_selected(pool, account_pubkey, slot, identity.key, identity.scope)
+ }
+
+ /// Query aggregate EOSE state for one scoped subscription binding owned by `owner`.
+ pub fn sub_eose_status(
+ &self,
+ runtime: &ScopedSubRuntime,
+ pool: &Outbox<'_>,
+ accounts: &Accounts,
+ identity: ScopedSubIdentity,
+ ) -> ScopedSubEoseStatus {
+ let Some(slot) = self.slots_by_owner.get(&identity.owner).copied() else {
+ return ScopedSubEoseStatus::Missing;
+ };
+
+ runtime.sub_eose_status(pool, accounts, slot, identity.key, identity.scope)
+ }
+
+ /// Drop one owner lifecycle and release all its scoped subscriptions.
+ pub fn drop_owner(
+ &mut self,
+ runtime: &mut ScopedSubRuntime,
+ pool: &mut Outbox<'_>,
+ owner: SubOwnerKey,
+ ) -> bool {
+ let Some(slot) = self.slots_by_owner.remove(&owner) else {
+ return false;
+ };
+
+ let _ = runtime.drop_slot(pool, slot);
+ true
+ }
+
+ /// Number of tracked owner lifecycles.
+ pub fn len(&self) -> usize {
+ self.slots_by_owner.len()
+ }
+
+ /// Returns true if no owner lifecycles are tracked.
+ pub fn is_empty(&self) -> bool {
+ self.slots_by_owner.is_empty()
+ }
+}
diff --git a/crates/notedeck/src/scoped_sub_state.rs b/crates/notedeck/src/scoped_sub_state.rs
@@ -0,0 +1,36 @@
+use crate::scoped_sub_owners::ScopedSubOwners;
+use crate::scoped_subs::ScopedSubRuntime;
+use crate::{Accounts, Outbox, ScopedSubApi};
+
+/// Host-owned scoped subscription state.
+///
+/// This keeps scoped owner slots and runtime state together so they are
+/// managed as one unit by the host.
+#[derive(Default)]
+pub struct ScopedSubsState {
+ runtime: ScopedSubRuntime,
+ owners: ScopedSubOwners,
+}
+
+impl ScopedSubsState {
+ /// Borrow owner/runtime internals for legacy callsites that still expect
+ /// both references separately.
+ pub(crate) fn split_mut(&mut self) -> (&mut ScopedSubOwners, &mut ScopedSubRuntime) {
+ (&mut self.owners, &mut self.runtime)
+ }
+
+ /// Build the app-facing scoped subscription API bound to host resources.
+ pub fn api<'o, 'a>(
+ &'o mut self,
+ pool: &'o mut Outbox<'a>,
+ accounts: &'o Accounts,
+ ) -> ScopedSubApi<'o, 'a> {
+ let (owners, runtime) = self.split_mut();
+ ScopedSubApi::new(pool, accounts, owners, runtime)
+ }
+
+ /// Mutable access to runtime internals for host account-switch integration.
+ pub(crate) fn runtime_mut(&mut self) -> &mut ScopedSubRuntime {
+ &mut self.runtime
+ }
+}