tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 8e8ccd0edb74a17acfe2918fd4c8710ede997f47
parent b182c2d7957c22b8c17bb87830befea44c80263e
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 23:50:46 -0700

groups: apply projection from event views

- make projection ordering derive from GroupEventView
- parse group metadata through event views
- move projection tag handling off protocol Tag values
- prove Pocket and protocol projection state equivalence

Diffstat:
Mcrates/tangle_groups/src/metadata.rs | 68++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/tangle_groups/src/projection.rs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
2 files changed, 221 insertions(+), 82 deletions(-)

diff --git a/crates/tangle_groups/src/metadata.rs b/crates/tangle_groups/src/metadata.rs @@ -3,8 +3,9 @@ use std::collections::BTreeSet; use crate::{ GroupLimitsConfig, errors::{GroupError, GroupErrorKind}, + event_view::{GroupEventTag, GroupEventView}, }; -use tangle_protocol::{Kind, Tag}; +use tangle_protocol::Kind; pub const MAX_METADATA_NAME_BYTES: usize = 128; pub const MAX_METADATA_PICTURE_BYTES: usize = 2_048; @@ -138,20 +139,20 @@ pub enum SupportedKinds { } pub fn parse_group_metadata( - tags: &[Tag], + event: &(impl GroupEventView + ?Sized), limits: GroupLimitsConfig, ) -> Result<GroupMetadata, GroupError> { let mut builder = MetadataBuilder::default(); - for tag in tags { - let Some(name) = tag.values().first().map(String::as_str) else { - continue; + event.visit_tags(|tag| { + let Some(name) = tag.first_value() else { + return Ok(()); }; match name { - "name" => builder.name = parse_text_tag(tag, "name", MAX_METADATA_NAME_BYTES)?, + "name" => builder.name = parse_text_tag(&tag, "name", MAX_METADATA_NAME_BYTES)?, "picture" => { - builder.picture = parse_text_tag(tag, "picture", MAX_METADATA_PICTURE_BYTES)? + builder.picture = parse_text_tag(&tag, "picture", MAX_METADATA_PICTURE_BYTES)? } - "about" => builder.about = parse_text_tag(tag, "about", MAX_METADATA_ABOUT_BYTES)?, + "about" => builder.about = parse_text_tag(&tag, "about", MAX_METADATA_ABOUT_BYTES)?, "private" => builder.private = true, "restricted" => builder.restricted = true, "hidden" => builder.hidden = true, @@ -163,11 +164,12 @@ pub fn parse_group_metadata( "metadata must contain at most one supported_kinds tag", )); } - builder.supported_kinds = Some(parse_supported_kinds_tag(tag, limits)?); + builder.supported_kinds = Some(parse_supported_kinds_tag(&tag, limits)?); } _ => {} } - } + Ok(()) + })?; Ok(GroupMetadata { name: builder.name, picture: builder.picture, @@ -195,11 +197,11 @@ struct MetadataBuilder { } fn parse_text_tag( - tag: &Tag, + tag: &GroupEventTag<'_>, field: &'static str, max_bytes: usize, ) -> Result<Option<String>, GroupError> { - let value = tag.values().get(1).cloned(); + let value = tag.value(1).map(str::to_owned); if let Some(value) = &value && value.len() > max_bytes { @@ -212,10 +214,10 @@ fn parse_text_tag( } fn parse_supported_kinds_tag( - tag: &Tag, + tag: &GroupEventTag<'_>, limits: GroupLimitsConfig, ) -> Result<SupportedKinds, GroupError> { - let values = tag.values().iter().skip(1).collect::<Vec<_>>(); + let values = tag.values().iter().skip(1).copied().collect::<Vec<_>>(); if values.is_empty() { return Ok(SupportedKinds::None); } @@ -253,12 +255,14 @@ mod tests { use super::{SupportedKinds, parse_group_metadata}; use crate::{GroupErrorKind, GroupLimitsConfig}; - use tangle_protocol::{Kind, Tag}; + use tangle_protocol::{ + Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + }; #[test] fn parses_group_metadata_flags_and_fields() { let metadata = parse_group_metadata( - &[ + &event(vec![ Tag::from_parts("name", &["Farmers"]).expect("name"), Tag::from_parts("picture", &["https://radroots.test/group.png"]).expect("picture"), Tag::from_parts("about", &["Local harvest coordination"]).expect("about"), @@ -267,7 +271,7 @@ mod tests { Tag::from_parts("hidden", &[]).expect("hidden"), Tag::from_parts("closed", &[]).expect("closed"), Tag::from_parts("supported_kinds", &["1", "7"]).expect("supported"), - ], + ]), GroupLimitsConfig::default(), ) .expect("metadata"); @@ -291,14 +295,16 @@ mod tests { #[test] fn supported_kinds_absent_empty_and_list_forms_are_distinct() { assert_eq!( - parse_group_metadata(&[], GroupLimitsConfig::default()) + parse_group_metadata(&event(Vec::new()), GroupLimitsConfig::default()) .expect("absent") .supported_kinds(), &SupportedKinds::UnspecifiedAll ); assert_eq!( parse_group_metadata( - &[Tag::from_parts("supported_kinds", &[]).expect("supported")], + &event(vec![ + Tag::from_parts("supported_kinds", &[]).expect("supported") + ]), GroupLimitsConfig::default() ) .expect("empty") @@ -307,7 +313,7 @@ mod tests { ); assert!(matches!( parse_group_metadata( - &[Tag::from_parts("supported_kinds", &["1"]).expect("supported")], + &event(vec![Tag::from_parts("supported_kinds", &["1"]).expect("supported")]), GroupLimitsConfig::default() ) .expect("list") @@ -319,7 +325,9 @@ mod tests { #[test] fn metadata_parser_rejects_oversize_fields_and_kind_limits() { let error = parse_group_metadata( - &[Tag::from_parts("name", &[&"a".repeat(129)]).expect("name")], + &event(vec![ + Tag::from_parts("name", &[&"a".repeat(129)]).expect("name"), + ]), GroupLimitsConfig::default(), ) .expect_err("name"); @@ -327,10 +335,26 @@ mod tests { let limits = GroupLimitsConfig::new(128, 8, 1, 1, 1).expect("limits"); let error = parse_group_metadata( - &[Tag::from_parts("supported_kinds", &["1", "2"]).expect("supported")], + &event(vec![ + Tag::from_parts("supported_kinds", &["1", "2"]).expect("supported"), + ]), limits, ) .expect_err("supported kinds"); assert_eq!(error.kind(), GroupErrorKind::TooManySupportedKinds); } + + fn event(tags: Vec<Tag>) -> Event { + Event::new( + EventId::new(&"0".repeat(64)).expect("id"), + UnsignedEvent::new( + PublicKeyHex::new(&"1".repeat(64)).expect("pubkey"), + UnixTimestamp::new(1), + Kind::new(1).expect("kind"), + tags, + "", + ), + SignatureHex::new(&"2".repeat(128)).expect("sig"), + ) + } } diff --git a/crates/tangle_groups/src/projection.rs b/crates/tangle_groups/src/projection.rs @@ -3,10 +3,10 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ CapabilitySet, GroupError, GroupErrorKind, GroupEventClass, GroupId, GroupLimitsConfig, GroupMetadata, GroupMetadataFlags, GroupMetadataText, RoleDefinition, RoleName, SupportedKinds, - classify_group_event, parse_group_metadata, + classify_group_event, event_view::GroupEventView, parse_group_metadata, }; use serde::{Deserialize, Serialize}; -use tangle_protocol::{Event, EventId, Kind, PublicKeyHex, Tag, UnixTimestamp}; +use tangle_protocol::{Event, EventId, Kind, PublicKeyHex, UnixTimestamp}; pub const GROUP_PROJECTION_SCHEMA_VERSION: u32 = 1; pub const GROUP_POLICY_VERSION: u32 = 1; @@ -40,12 +40,11 @@ impl ProjectionOrderTuple { } } - pub fn from_event(event: &Event, store_offset: StoreOffset) -> Self { - Self::new( - event.unsigned().created_at(), - event.id().clone(), - store_offset, - ) + pub fn from_event_view( + event: &(impl GroupEventView + ?Sized), + store_offset: StoreOffset, + ) -> Result<Self, GroupError> { + Ok(Self::new(event.created_at(), event.id()?, store_offset)) } pub fn created_at(&self) -> UnixTimestamp { @@ -661,12 +660,12 @@ impl GroupProjection { pub fn apply_canonical_event( &mut self, - event: &Event, + event: &(impl GroupEventView + ?Sized), store_offset: StoreOffset, limits: GroupLimitsConfig, ) -> Result<ProjectionApplyOutcome, GroupError> { let class = classify_group_event(event, limits)?; - let tuple = ProjectionOrderTuple::from_event(event, store_offset); + let tuple = ProjectionOrderTuple::from_event_view(event, store_offset)?; match class { GroupEventClass::NonGroup => Ok(ProjectionApplyOutcome::Skipped), GroupEventClass::Normal { .. } => Ok(ProjectionApplyOutcome::Ignored), @@ -682,17 +681,17 @@ impl GroupProjection { fn apply_moderation_event( &mut self, group_id: GroupId, - event: &Event, + event: &(impl GroupEventView + ?Sized), tuple: ProjectionOrderTuple, limits: GroupLimitsConfig, ) -> Result<ProjectionApplyOutcome, GroupError> { - match event.unsigned().kind().as_u32() { + match event.kind_u32() { crate::KIND_GROUP_CREATE_GROUP => { let state = GroupState::new( group_id.clone(), - parse_group_metadata(event.unsigned().tags(), limits)?, - event.unsigned().pubkey().clone(), - event.id().clone(), + parse_group_metadata(event, limits)?, + event.pubkey()?, + event.id()?, tuple, ); if self @@ -709,10 +708,7 @@ impl GroupProjection { let Some(group) = self.groups.get_mut(&group_id) else { return Ok(ProjectionApplyOutcome::Ignored); }; - group.update_metadata( - parse_group_metadata(event.unsigned().tags(), limits)?, - tuple, - ); + group.update_metadata(parse_group_metadata(event, limits)?, tuple); Ok(ProjectionApplyOutcome::Applied) } crate::KIND_GROUP_PUT_USER => { @@ -722,8 +718,8 @@ impl GroupProjection { self.apply_member_status(group_id, event, tuple, MemberStatus::Removed) } crate::KIND_GROUP_DELETE_EVENT => { - let target_event_id = EventId::new(first_tag_value(event.unsigned().tags(), "e")?) - .map_err(|reason| { + let target_event_id = + EventId::new(&first_tag_value(event, "e")?).map_err(|reason| { GroupError::invalid( GroupErrorKind::MalformedTargetTag, format!("malformed e target tag: {reason}"), @@ -732,9 +728,9 @@ impl GroupProjection { let deletion = GroupEventDeletion::new( group_id, target_event_id, - event.id().clone(), - event.unsigned().created_at(), - event.unsigned().pubkey().clone(), + event.id()?, + event.created_at(), + event.pubkey()?, tuple, ); self.put_event_deletion(deletion); @@ -743,17 +739,13 @@ impl GroupProjection { crate::KIND_GROUP_DELETE_GROUP => { let tombstone = GroupTombstone::new( group_id.clone(), - event.id().clone(), - event.unsigned().created_at(), - event.unsigned().pubkey().clone(), + event.id()?, + event.created_at(), + event.pubkey()?, tuple.clone(), ); if let Some(group) = self.groups.get_mut(&group_id) { - group.mark_deleted( - event.unsigned().created_at(), - event.id().clone(), - tuple.clone(), - ); + group.mark_deleted(event.created_at(), event.id()?, tuple.clone()); } if self .tombstones @@ -774,28 +766,29 @@ impl GroupProjection { &mut self, group_id: GroupId, kind: Kind, - event: &Event, + event: &(impl GroupEventView + ?Sized), tuple: ProjectionOrderTuple, limits: GroupLimitsConfig, ) -> Result<ProjectionApplyOutcome, GroupError> { if kind.as_u32() == crate::KIND_GROUP_METADATA { - let metadata = parse_group_metadata(event.unsigned().tags(), limits)?; + let metadata = parse_group_metadata(event, limits)?; + let event_id = event.id()?; if let Some(group) = self.groups.get_mut(&group_id) { group.update_metadata(metadata, tuple.clone()); - group.snapshots.set_for_kind(kind, event.id().clone()); + group.snapshots.set_for_kind(kind, event_id); } else { let mut state = GroupState::new( group_id.clone(), metadata, - event.unsigned().pubkey().clone(), - event.id().clone(), + event.pubkey()?, + event_id.clone(), tuple.clone(), ); - state.snapshots.set_for_kind(kind, event.id().clone()); + state.snapshots.set_for_kind(kind, event_id); self.put_group(state); } } else if let Some(group) = self.groups.get_mut(&group_id) { - group.snapshots.set_for_kind(kind, event.id().clone()); + group.snapshots.set_for_kind(kind, event.id()?); } Ok(ProjectionApplyOutcome::Applied) } @@ -803,19 +796,19 @@ impl GroupProjection { fn apply_member_status( &mut self, group_id: GroupId, - event: &Event, + event: &(impl GroupEventView + ?Sized), tuple: ProjectionOrderTuple, status: MemberStatus, ) -> Result<ProjectionApplyOutcome, GroupError> { - let target = first_tag_value(event.unsigned().tags(), "p")?; - let pubkey = PublicKeyHex::new(target).map_err(|reason| { + let target = first_tag_value(event, "p")?; + let pubkey = PublicKeyHex::new(&target).map_err(|reason| { GroupError::invalid( GroupErrorKind::MalformedTargetTag, format!("malformed p target tag: {reason}"), ) })?; - let roles = role_tags(event.unsigned().tags())?; - let state = MemberState::new(pubkey, status, roles, event.id().clone(), tuple); + let roles = role_tags(event)?; + let state = MemberState::new(pubkey, status, roles, event.id()?, tuple); self.put_member(group_id, state); Ok(ProjectionApplyOutcome::Applied) } @@ -851,7 +844,7 @@ impl CanonicalGroupEvent { } pub fn tuple(&self) -> ProjectionOrderTuple { - ProjectionOrderTuple::from_event(&self.event, self.store_offset) + ProjectionOrderTuple::from_event_view(&self.event, self.store_offset).expect("tuple") } } @@ -977,10 +970,14 @@ fn prefixed_key(prefix: &str, first: &str, second: Option<&str>) -> Vec<u8> { key } -fn first_tag_value<'a>(tags: &'a [Tag], name: &str) -> Result<&'a str, GroupError> { - for tag in tags { - if tag.values().first().is_none_or(|tag_name| tag_name != name) { - continue; +fn first_tag_value( + event: &(impl GroupEventView + ?Sized), + name: &str, +) -> Result<String, GroupError> { + let mut found = None; + event.visit_tags(|tag| { + if tag.first_value().is_none_or(|tag_name| tag_name != name) { + return Ok(()); } let Some((_, value)) = tag.indexed_pair() else { return Err(GroupError::invalid( @@ -988,23 +985,27 @@ fn first_tag_value<'a>(tags: &'a [Tag], name: &str) -> Result<&'a str, GroupErro format!("malformed {name} target tag"), )); }; - return Ok(value); - } - Err(GroupError::invalid( - GroupErrorKind::MissingTargetTag, - format!("missing {name} target tag"), - )) + found = Some(value.to_owned()); + Ok(()) + })?; + found.ok_or_else(|| { + GroupError::invalid( + GroupErrorKind::MissingTargetTag, + format!("missing {name} target tag"), + ) + }) } -fn role_tags(tags: &[Tag]) -> Result<BTreeSet<RoleName>, GroupError> { +fn role_tags(event: &(impl GroupEventView + ?Sized)) -> Result<BTreeSet<RoleName>, GroupError> { let mut roles = BTreeSet::new(); - for tag in tags { - if tag.values().first().is_some_and(|name| name == "role") - && let Some(value) = tag.values().get(1) + event.visit_tags(|tag| { + if tag.first_value().is_some_and(|name| name == "role") + && let Some(value) = tag.value(1) { roles.insert(RoleName::new(value)?); } - } + Ok(()) + })?; Ok(roles) } @@ -1418,8 +1419,10 @@ mod tests { KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA, KIND_GROUP_METADATA, KIND_GROUP_PUT_USER, RoleDefinition, RoleName, SupportedKinds, }; + use pocket_types::{Event as PocketEvent, OwnedEvent as PocketOwnedEvent}; use tangle_protocol::{ Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent, + event_to_value, }; #[test] @@ -1565,6 +1568,36 @@ mod tests { } #[test] + fn projection_applies_pocket_event_views_equivalent_to_protocol_events() { + let limits = GroupLimitsConfig::default(); + let events = projection_event_stream(); + let mut protocol_projection = GroupProjection::new(); + let mut pocket_projection = GroupProjection::new(); + + for (event, offset) in &events { + protocol_projection + .apply_canonical_event(event, *offset, limits) + .expect("protocol event"); + let pocket = pocket_event(event); + pocket_projection + .apply_canonical_event(&pocket, *offset, limits) + .expect("pocket event"); + } + + assert_eq!(protocol_projection.groups(), pocket_projection.groups()); + assert_eq!(protocol_projection.members(), pocket_projection.members()); + assert_eq!(protocol_projection.roles(), pocket_projection.roles()); + assert_eq!( + protocol_projection.tombstones(), + pocket_projection.tombstones() + ); + assert_eq!( + protocol_projection.event_deletions(), + pocket_projection.event_deletions() + ); + } + + #[test] fn projection_rebuild_sorts_before_applying_last_tuple_wins() { let report = rebuild_group_projection( [ @@ -1875,6 +1908,88 @@ mod tests { ) } + fn projection_event_stream() -> Vec<(Event, StoreOffset)> { + vec![ + ( + event( + KIND_GROUP_CREATE_GROUP, + "10", + 10, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("name", &["Farmers"]).expect("name"), + ], + ), + StoreOffset::new(1), + ), + ( + event( + KIND_GROUP_EDIT_METADATA, + "20", + 20, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("name", &["Market"]).expect("name"), + ], + ), + StoreOffset::new(2), + ), + ( + event( + KIND_GROUP_PUT_USER, + "30", + 30, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("p", &[&"8".repeat(64)]).expect("p"), + Tag::from_parts("role", &["moderator"]).expect("role"), + ], + ), + StoreOffset::new(3), + ), + ( + event( + KIND_GROUP_METADATA, + "40", + 40, + vec![ + Tag::from_parts("d", &["Farm"]).expect("d"), + Tag::from_parts("name", &["Snapshot"]).expect("name"), + ], + ), + StoreOffset::new(4), + ), + ( + event( + KIND_GROUP_DELETE_EVENT, + "45", + 45, + vec![ + Tag::from_parts("h", &["Farm"]).expect("h"), + Tag::from_parts("e", &[id("30")]).expect("e"), + ], + ), + StoreOffset::new(5), + ), + ( + event( + KIND_GROUP_DELETE_GROUP, + "50", + 50, + vec![Tag::from_parts("h", &["Farm"]).expect("h")], + ), + StoreOffset::new(6), + ), + ] + } + + fn pocket_event(event: &Event) -> PocketOwnedEvent { + let raw = event_to_value(event).to_string(); + let mut buffer = vec![0; 4096]; + let (_, pocket) = PocketEvent::from_json(raw.as_bytes(), &mut buffer).expect("pocket"); + pocket.to_owned() + } + fn id(suffix: &str) -> &'static str { match suffix { "10" => "0000000000000000000000000000000000000000000000000000000000000010",