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:
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",