From 907347d002c90163771864a7138153347b1f6841 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:01:55 +0700 Subject: [PATCH] update --- Cargo.lock | 1 + crates/community/src/lib.rs | 3 +- crates/community/src/sync.rs | 38 +------ crates/concord/Cargo.toml | 1 + crates/concord/src/cords/cord02/guestbook.rs | 101 ++----------------- crates/concord/src/cords/cord03.rs | 95 ++--------------- crates/concord/src/cords/cord04/mod.rs | 8 +- crates/concord/src/cords/cord04/roles.rs | 6 +- crates/concord/src/cords/mod.rs | 2 + crates/concord/src/cords/rumor.rs | 96 ++++++++++++++++++ crates/concord/src/store.rs | 89 ++++++++++++++-- crates/workspace/src/sidebar/tree.rs | 2 +- docs/concord-simplification-plan.md | 52 ++++++---- docs/concord-usage.md | 47 +++++---- 14 files changed, 264 insertions(+), 277 deletions(-) create mode 100644 crates/concord/src/cords/rumor.rs diff --git a/Cargo.lock b/Cargo.lock index 0e822f8e..eb662aad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,6 +1318,7 @@ dependencies = [ "data-encoding", "hkdf", "hmac 0.12.1", + "log", "nostr", "nostr-memory", "nostr-sdk", diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 86b156bc..17068a31 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -107,8 +107,9 @@ impl CommunityRegistry { /// Create a community owned by the current account and begin tracking it. pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); - if nostr.read(cx).current_user().is_none() { + if current_user.is_none() { cx.emit(CommunityEvent::Error( "cannot create a community without an account".to_owned(), )); diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 8097b21a..54d5e046 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -14,9 +14,6 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey}; use nostr_sdk::prelude::*; use state::UniversalSigner; -const SUBSCRIPTION_PREFIX: &str = "concord/"; -const STATE_PREFIX: &str = "concord/"; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PlaneKind { Control(Epoch), @@ -77,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter { } pub fn subscription_id(id: &CommunityId) -> SubscriptionId { - SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex())) + SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex())) } pub fn community_of(subscription_id: &SubscriptionId) -> Option { subscription_id .as_str() - .strip_prefix(SUBSCRIPTION_PREFIX)? + .strip_prefix(store::STATE_PREFIX)? .parse() .ok() } @@ -130,30 +127,7 @@ pub async fn load( signer: &UniversalSigner, self_pk: PublicKey, ) -> Result> { - let filter = Filter::new().kind(Kind::ApplicationSpecificData); - let mut newest: BTreeMap = BTreeMap::new(); - - for event in client.database().query(filter).await? { - let Some(id) = state_document_of(&event) else { - continue; - }; - - match newest.get(&id) { - Some(existing) if existing.created_at >= event.created_at => {} - _ => { - newest.insert(id, event); - } - } - } - - let mut states = Vec::with_capacity(newest.len()); - - for event in newest.into_values() { - match serde_json::from_str::(&event.content) { - Ok(state) => states.push(state), - Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), - } - } + let mut states = store::load_states(client).await?; if let Some(list) = load_list(client, signer, self_pk).await? { states.retain(|state| list.is_live(&state.id)); @@ -162,12 +136,6 @@ pub async fn load( Ok(states) } -fn state_document_of(event: &Event) -> Option { - let identifier = event.tags.identifier()?; - let hex = identifier.strip_prefix(STATE_PREFIX)?; - hex.parse().ok() -} - async fn load_list( client: &Client, signer: &UniversalSigner, diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml index 009a86e1..5546f66d 100644 --- a/crates/concord/Cargo.toml +++ b/crates/concord/Cargo.toml @@ -17,6 +17,7 @@ rand.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true +log.workspace = true [dev-dependencies] nostr-memory.workspace = true diff --git a/crates/concord/src/cords/cord02/guestbook.rs b/crates/concord/src/cords/cord02/guestbook.rs index a4002a68..6fa19027 100644 --- a/crates/concord/src/cords/cord02/guestbook.rs +++ b/crates/concord/src/cords/cord02/guestbook.rs @@ -1,18 +1,16 @@ use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; use anyhow::Result; use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; use crate::cord01::{ - KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap, - wrap_seal, -}; -use crate::cord04::{ - AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, + KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal, }; +use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; +pub use crate::cords::rumor::RumorError as GuestbookError; +use crate::cords::rumor::{optional_citation, pubkey, required, value}; use crate::{GroupKey, decode_hex_32}; pub const KIND_JOIN_LEAVE: u16 = 3306; @@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content"; const CONTENT_JOIN: &str = "join"; const CONTENT_LEAVE: &str = "leave"; -#[derive(Debug)] -pub enum GuestbookError { - Stream(StreamError), - NotEncryptedSealed, - UnknownKind(u16), - MissingTag(&'static str), - DuplicateTag(&'static str), - BadTag(&'static str), -} - -impl fmt::Display for GuestbookError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - GuestbookError::Stream(error) => write!(f, "stream: {error}"), - GuestbookError::NotEncryptedSealed => { - write!(f, "guestbook rumor must ride an encrypted seal") - } - GuestbookError::UnknownKind(kind) => { - write!(f, "not a guestbook rumor kind: {kind}") - } - GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"), - GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"), - GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"), - } - } -} - -impl std::error::Error for GuestbookError {} - -impl From for GuestbookError { - fn from(error: StreamError) -> Self { - GuestbookError::Stream(error) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum GuestbookEntry { Join { @@ -469,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo Ok((snapshot_id, (index, total))) } -fn optional_citation(rumor: &UnsignedEvent) -> Result, GuestbookError> { - let Some(fields) = tag(rumor, TAG_CITATION)? else { - return Ok(None); - }; - - citation_from(fields) - .map(Some) - .ok_or(GuestbookError::BadTag(TAG_CITATION)) -} - fn decimal(raw: &str) -> Result { canonical_decimal(raw) .and_then(|value| u32::try_from(value).ok()) .ok_or(GuestbookError::BadTag(TAG_SNAP)) } -fn required<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result<&'a [String], GuestbookError> { - tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name)) -} - fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result { pubkey(value(required(rumor, name)?, name)?, name) } -fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, GuestbookError> { - let mut found: Option<&[String]> = None; - - for candidate in rumor.tags.iter() { - let fields = candidate.as_slice(); - - if fields.first().map(String::as_str) != Some(name) { - continue; - } - - if found.is_some() { - return Err(GuestbookError::DuplicateTag(name)); - } - - found = Some(fields); - } - - Ok(found) -} - -fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> { - fields - .get(1) - .map(String::as_str) - .ok_or(GuestbookError::BadTag(name)) -} - -fn pubkey(hex: &str, name: &'static str) -> Result { - let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?; - - PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name)) -} - #[cfg(test)] mod tests { use super::*; - use crate::cord01::build_rumor_secs; + use crate::cord01::{StreamError, build_rumor_secs}; + use crate::cord04::TAG_CITATION; use crate::derive::guestbook_group_key; use crate::{CommunityId, Epoch}; diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index bd3d1aef..e5df89a4 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -1,18 +1,16 @@ use std::cmp::Reverse; use std::collections::BTreeMap; -use std::fmt; use anyhow::Result; use nostr_sdk::prelude::*; use crate::cord01::{ - KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, - build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, - wrap_seal, -}; -use crate::cord04::{ - AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, + KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal, + channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal, }; +use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; +pub use crate::cords::rumor::RumorError as ChatError; +use crate::cords::rumor::{optional_citation, pubkey, tag, value}; use crate::derive::channel_group_key; use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; @@ -36,42 +34,6 @@ const TAG_TARGET_AUTHOR: &str = "p"; const TAG_EXPIRATION: &str = "expiration"; const TAG_TIMER: &str = "timer"; -#[derive(Debug)] -pub enum ChatError { - Stream(StreamError), - NotEncryptedSealed, - UnknownKind(u16), - MissingTag(&'static str), - DuplicateTag(&'static str), - BadTag(&'static str), - /// Neither a delete nor a timer notice may be erased by the policy it carries. - ExemptExpiration, -} - -impl fmt::Display for ChatError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ChatError::Stream(error) => write!(f, "stream: {error}"), - ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"), - ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"), - ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"), - ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"), - ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"), - ChatError::ExemptExpiration => { - write!(f, "a delete or timer notice must not carry an expiration") - } - } - } -} - -impl std::error::Error for ChatError {} - -impl From for ChatError { - fn from(error: StreamError) -> Self { - ChatError::Stream(error) - } -} - /// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReplyRef { @@ -579,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result Result, ChatError> { - let Some(fields) = tag(rumor, TAG_CITATION)? else { - return Ok(None); - }; - - citation_from(fields) - .map(Some) - .ok_or(ChatError::BadTag(TAG_CITATION)) -} - pub fn expiration_of(rumor: &UnsignedEvent) -> Result, ChatError> { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { return Ok(None); @@ -614,51 +566,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag { ) } -fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, ChatError> { - let mut found: Option<&[String]> = None; - - for candidate in rumor.tags.iter() { - let fields = candidate.as_slice(); - - if fields.first().map(String::as_str) != Some(name) { - continue; - } - - if found.is_some() { - return Err(ChatError::DuplicateTag(name)); - } - - found = Some(fields); - } - - Ok(found) -} - -fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> { - fields - .get(1) - .map(String::as_str) - .ok_or(ChatError::BadTag(name)) -} - fn hex_id(fields: &[String], name: &'static str) -> Result { let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) } -fn pubkey(hex: &str, name: &'static str) -> Result { - let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?; - - PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) -} - #[cfg(test)] mod tests { use super::*; + use crate::cord01::StreamError; const SECRET: [u8; 32] = [0x2du8; 32]; const AT: u64 = 1_700_000_000_417; diff --git a/crates/concord/src/cords/cord04/mod.rs b/crates/concord/src/cords/cord04/mod.rs index 508a1282..b2af00a3 100644 --- a/crates/concord/src/cords/cord04/mod.rs +++ b/crates/concord/src/cords/cord04/mod.rs @@ -121,7 +121,7 @@ fn signing_bytes( bytes } -pub fn edition_hash( +fn edition_hash( entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, @@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct FoldResult { +struct FoldResult { pub head: Option, pub gap: bool, pub anchored: bool, } /// The highest version whose chain is intact, given a held floor. -pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { +fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { let mut by_version: BTreeMap = BTreeMap::new(); for (index, edition) in editions.iter().enumerate() { @@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) } /// The highest version overall, ignoring contiguity. -pub fn bootstrap_head(editions: &[EditionMeta]) -> Option { +fn bootstrap_head(editions: &[EditionMeta]) -> Option { editions .iter() .enumerate() diff --git a/crates/concord/src/cords/cord04/roles.rs b/crates/concord/src/cords/cord04/roles.rs index 4fa37e8b..f44343c4 100644 --- a/crates/concord/src/cords/cord04/roles.rs +++ b/crates/concord/src/cords/cord04/roles.rs @@ -100,7 +100,7 @@ pub struct Role { } impl Role { - pub fn parse(content: &str) -> Option { + fn parse(content: &str) -> Option { serde_json::from_str(content).ok() } @@ -122,7 +122,7 @@ pub struct Grant { } impl Grant { - pub fn parse(content: &str) -> Option { + fn parse(content: &str) -> Option { serde_json::from_str(content).ok() } @@ -135,7 +135,7 @@ impl Grant { } } -pub fn parse_banlist(content: &str) -> Option> { +fn parse_banlist(content: &str) -> Option> { let entries: Vec = serde_json::from_str(content).ok()?; let mut banned = Vec::with_capacity(entries.len()); diff --git a/crates/concord/src/cords/mod.rs b/crates/concord/src/cords/mod.rs index 2a1028c4..f83962af 100644 --- a/crates/concord/src/cords/mod.rs +++ b/crates/concord/src/cords/mod.rs @@ -1,3 +1,5 @@ +mod rumor; + pub mod cord01; pub mod cord02; pub mod cord03; diff --git a/crates/concord/src/cords/rumor.rs b/crates/concord/src/cords/rumor.rs new file mode 100644 index 00000000..95d74114 --- /dev/null +++ b/crates/concord/src/cords/rumor.rs @@ -0,0 +1,96 @@ +use std::fmt; + +use nostr_sdk::prelude::*; + +use crate::cord01::StreamError; +use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from}; +use crate::decode_hex_32; + +#[derive(Debug)] +pub enum RumorError { + Stream(StreamError), + NotEncryptedSealed, + UnknownKind(u16), + MissingTag(&'static str), + DuplicateTag(&'static str), + BadTag(&'static str), + /// Neither a delete nor a timer notice may be erased by the policy it carries. + ExemptExpiration, +} + +impl fmt::Display for RumorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RumorError::Stream(error) => write!(f, "stream: {error}"), + RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"), + RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"), + RumorError::MissingTag(name) => write!(f, "missing tag: {name}"), + RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"), + RumorError::BadTag(name) => write!(f, "malformed tag: {name}"), + RumorError::ExemptExpiration => { + write!(f, "a delete or timer notice must not carry an expiration") + } + } + } +} + +impl std::error::Error for RumorError {} + +impl From for RumorError { + fn from(error: StreamError) -> Self { + RumorError::Stream(error) + } +} + +pub fn tag<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result, RumorError> { + let mut found: Option<&[String]> = None; + + for candidate in rumor.tags.iter() { + let fields = candidate.as_slice(); + + if fields.first().map(String::as_str) != Some(name) { + continue; + } + + if found.is_some() { + return Err(RumorError::DuplicateTag(name)); + } + + found = Some(fields); + } + + Ok(found) +} + +pub fn required<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result<&'a [String], RumorError> { + tag(rumor, name)?.ok_or(RumorError::MissingTag(name)) +} + +pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> { + fields + .get(1) + .map(String::as_str) + .ok_or(RumorError::BadTag(name)) +} + +pub fn pubkey(hex: &str, name: &'static str) -> Result { + let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?; + + PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name)) +} + +pub fn optional_citation(rumor: &UnsignedEvent) -> Result, RumorError> { + let Some(fields) = tag(rumor, TAG_CITATION)? else { + return Ok(None); + }; + + citation_from(fields) + .map(Some) + .ok_or(RumorError::BadTag(TAG_CITATION)) +} diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 59da9e79..b72ac62f 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -23,7 +23,8 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T; const MARK_VALUE: &str = "concord"; const WRAP_TAG: &str = "e"; const KIND_TAG: &str = "k"; -const STATE_PREFIX: &str = "concord/"; +/// The `concord/` namespace for locally-keyed documents. +pub const STATE_PREFIX: &str = "concord/"; /// An already-expired rumor is refused at ingest. Returns whether it was kept. pub async fn cache_rumor( @@ -91,7 +92,7 @@ pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) } pub async fn query_rumors( - database: &dyn NostrDatabase, + client: &Client, channel: &ChannelId, until: Option, limit: usize, @@ -106,7 +107,7 @@ pub async fn query_rumors( } let mut newest: BTreeMap = BTreeMap::new(); - for event in database.query(filter).await? { + for event in client.database().query(filter).await? { let Some(rumor_id) = event.tags.identifier() else { continue; }; @@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> { Ok(()) } -pub async fn load_state(database: &D, id: &CommunityId) -> Result> -where - D: NostrDatabase + ?Sized, -{ +pub async fn load_state(client: &Client, id: &CommunityId) -> Result> { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(state_identifier(id)) .limit(1); - match database.query(filter).await?.into_iter().next() { + match client.database().query(filter).await?.into_iter().next() { Some(event) => Ok(Some(serde_json::from_str(&event.content)?)), None => Ok(None), } } +/// The newest state document per community carried in the local database. +pub async fn load_states(client: &Client) -> Result> { + let filter = Filter::new().kind(Kind::ApplicationSpecificData); + let mut newest: BTreeMap = BTreeMap::new(); + + for event in client.database().query(filter).await? { + let Some(id) = state_document_of(&event) else { + continue; + }; + + match newest.get(&id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(id, event); + } + } + } + + let mut states = Vec::with_capacity(newest.len()); + + for event in newest.into_values() { + match serde_json::from_str::(&event.content) { + Ok(state) => states.push(state), + Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), + } + } + + Ok(states) +} + +fn state_document_of(event: &Event) -> Option { + let identifier = event.tags.identifier()?; + let hex = identifier.strip_prefix(STATE_PREFIX)?; + hex.parse().ok() +} + pub async fn backfill( client: &Client, channel: &ChannelId, @@ -492,4 +526,43 @@ mod tests { ["after the rekey", "still before", "before the rekey"] ); } + + #[test] + fn load_states_reads_one_document_per_community_and_ignores_other_documents() { + smol::block_on(async { + let client = ClientBuilder::default() + .database(nostr_memory::MemoryDatabase::unbounded()) + .build(); + + let state = CommunityState { + id: CommunityId::from_bytes([0x42; 32]), + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::new(), + channels: Vec::new(), + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms: 7, + }; + + save_state(&client, &state).await.expect("saves"); + + // A cached rumor is also an application-specific document, but not a + // state document, so the prefix keeps it out of the state scan. + let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}") + .tags([Tag::identifier("deadbeef")]) + .finalize(&*LOCAL_KEYS) + .expect("builds"); + client.database().save_event(&other).await.expect("saves"); + + let loaded = load_states(&client).await.expect("loads"); + + assert_eq!(loaded, vec![state]); + }); + } } diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 764662a4..04a9d11a 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -80,7 +80,7 @@ pub struct CommunityEntry { } pub fn dummy_communities() -> &'static [CommunityEntry] { - // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. + // TODO(concord): replace with CommunityRegistry communities, see docs/concord-usage.md. &[ CommunityEntry { name: "Coop Contributors", diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index aaaf3f89..f38ec941 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -251,25 +251,36 @@ Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob the base64 record). `cargo clippy -p concord --all-targets` and `cargo fmt -p concord --check` are clean. -### Phase 4 — duplication and hygiene (independent, low risk) +### Phase 4 — duplication and hygiene (independent, low risk) — DONE -1. Add `store::load_states(client)` and delete the app-side state-document scan - (`crates/community/src/sync.rs:100-137`). -2. Export the `concord/` state prefix from concord; delete the app-side copies - (`store.rs:26`, `sync.rs:15-16`). -3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs - `guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError` - enums. -4. Remove never-varied parameters where the change is local: `banned_at` from - `complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>` - once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors - (until)` if no scheduled flow needs them. -5. Tighten visibility of internal-only `pub` items in `cord04` - (`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`, - `parse_banlist`, `Role::parse`, `Grant::parse`). -6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state` - parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired - up yet" section (`:528-545`) once Phase 2 lands. +1. DONE — `store::load_states(client)` added (with a direct `store` test), the + app-side state-document scan in `sync::load` is gone. +2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are + gone, and subscription ids reuse the exported prefix. +3. DONE — the shared rumor tag readers and error live in a new `cords::rumor` + module (`RumorError`, `tag`, `required`, `value`, `pubkey`, + `optional_citation`), re-exported as `cord03::ChatError` and + `cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower + `RekeyError`, which the plan scoped out. +4. RETAINED — none of the "never-varied parameters" were removed. Each is + load-bearing for a flow the fold or a writer already implements (D1): + - `complete_memberlist`'s `banned_at` is read by the fold and is exercised + with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`; + `docs/concord-usage.md` already promises to fill it once the banlist head's + timestamp is plumbed through. + - `cache_rumor -> Result` is read by `backfill` to drop expired rumors. + - `coalesce`'s `snapshot_authority` gates which snapshots apply; passing + `None` today is a policy, not a dead parameter. + - `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors` + select protocol modes and paging. +5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`, + `bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no + longer `pub`. `HeadSelection` stays `pub` because the public `fold_head` + returns it. +6. DONE — doc drift fixed: the store takes `&Client` throughout (including + `load_state`/`load_states`/`query_rumors`, not just the writers), `backfill` + arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and + registry names, and the "Not wired up yet" registry bullet. --- @@ -284,7 +295,7 @@ Per D1 these stay, but they should be understood as unwired, not live: | `cord04::pins` | ~550 | none | | `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` | | guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` | -| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` | +| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` | Truly unreferenced even by tests (safe candidates, but kept per D1): `CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls, @@ -310,6 +321,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): - Phase 2 adds the app-level test: seed a `CommunityState` via `store::save_state`, drive `CommunityRegistry`, assert a subscription is made and an inbound wrap folds into the community. +- Phase 4: `cargo test -p concord -p community` (47 + 1 passed), + `cargo clippy -p concord -p community --all-targets`, and + `cargo fmt -p concord -p community --check` are all clean. ## 6. Immediate unblock diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 19d295a0..7010dc65 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -73,7 +73,7 @@ let editions: Vec = minted .collect::>()?; let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?; -save_state(database, &state).await?; +save_state(&client, &state).await?; ``` Put the community's relay list into `state.relays` and add those relays to the @@ -192,7 +192,7 @@ for wrap in &wraps { let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else { continue; }; - store::cache_rumor(database, &channel, &opened).await?; + store::cache_rumor(&client, &channel, &opened).await?; rumors.push(rumor); } @@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| { Relay history pages through the local cache: ```rust -let page = store::backfill(client, database, &channel, &held, until, 50).await?; -let cached = store::query_rumors(database, &channel, None, 50).await?; +let page = store::backfill(client, &channel, &held, until, 50).await?; +let cached = store::query_rumors(&client, &channel, None, 50).await?; ``` `backfill` walks newest-first across every held epoch, caches what it opens, and stops on a short page. `query_rumors` is the read path when the group keys are -gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as +gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as any other local sweep — the timer is cooperative, so the local store is the artifact that has to forget. @@ -275,7 +275,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or(""); let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok()); let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?; let (wrap, _) = writer.set_pin_list( - &my_keys, &community_id, &channel, &content, head, citation, now_secs)?; + &my_keys, &community_id, &channel, &content, head, citation, now_secs).await?; ``` Reading is verification: `read_list` decodes either content form (public, or @@ -428,7 +428,8 @@ the NIP-44 size cap, both protocol constants. ## GPUI integration -`crates/concord` stays GPUI-free. The UI layer adds a registry global and one +`crates/concord` stays GPUI-free; the registry and sync engine live in +`crates/community`. That layer adds a registry global and one entity per community, and moves every decrypt, verification, fold and I/O off the foreground thread. @@ -437,13 +438,13 @@ the foreground thread. Same shape as `ChatRegistry`: ```rust -pub fn init(window: &mut Window, cx: &mut App) { - ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); } -impl ConcordRegistry { +impl CommunityRegistry { pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() + cx.global::().0.clone() } } ``` @@ -452,8 +453,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with the account. -- `ConcordRegistry` holds `communities: Vec>`, an index by - `CommunityId`, and `tasks: SmallVec<[Task>; 2]>`. +- `CommunityRegistry` holds `communities: Vec>`, an index by + `CommunityId`, and `tasks: SmallVec<[Task>; 2]>`. - `Community` owns one `CommunityState`, the last `ControlFold`, the member list and the channel list. Views render `Entity`; no protocol state lives in a view. @@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded ```rust let (signal_tx, signal_rx) = flume::bounded::(256); -let database = client.database().clone(); +let client = client.clone(); // Background: open, verify, fold — no entities. self.ingress = Some(cx.background_spawn(async move { @@ -477,7 +478,7 @@ self.ingress = Some(cx.background_spawn(async move { continue; }; let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?; - store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?; + store::cache_rumor(&client, &plane.channel, &opened).await?; signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?; } Ok(()) @@ -492,8 +493,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| { })); ``` -- `client.database()` is a `&Arc` and `store::save_state` - wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`. +- Every store function takes the `&Client` and reaches the database through + `client.database()`, so clone the `Client` into the background task. - Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None` to an `Option>` before respawning it; a signer change replaces both the listener and the consumer. @@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?; ## Not wired up yet -- **No registry and no sync engine.** `crates/concord` has no subscriptions, no - `init`, and no `Entity`; the UI owns subscribing, routing a wrap to - the plane whose address it carries, and rebuilding a subscription when a plane's - address changes (join, channel added, rekey folded). GPUI integration above is - the shape to build, not code that exists. +- **`crates/concord` stays protocol-only; the registry lives in + `crates/community`.** `concord` has no subscriptions, no `init`, and no + `Entity`; `community::CommunityRegistry` owns one `Entity` + per state document, subscribes when a community's plane set changes, and + re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and + `CommunityRegistry::create` persists the genesis locally without publishing it + to the metadata's relays. - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,