fix(channels): return complete member rosters (#5765)

## Summary

- return complete channel rosters instead of truncating at 1,000 members
- chunk `event_mentions` inserts inside one transaction so large kind
`39002` snapshots remain discoverable by every `p` tag
- add a targeted `buzz-admin reconcile-channels --channel <uuid>`
force-republish path for stale discovery snapshots
- cover a 1,501-member roster, 11,000-tag mention index, and kind
`39002` tag construction past member 1,000

## Why

The relay builds NIP-29 discovery and several authorization decisions
from `get_members()`, but that helper silently returned only the first
1,000 active members. Desktop then counted the truncated kind `39002`
event, while late members could be rejected by roster-scanning member
actions.

Removing the roster cap exposes PostgreSQL's 65,535 bind-parameter
ceiling in mention indexing, so the insert is chunked transactionally to
preserve all-or-nothing indexing.

The existing reconcilers only fill missing discovery events. The
targeted admin option bypasses the separately known 1,000-channel
reconciliation-list ceiling and replaces an existing channel snapshot
using the configured production relay key.

## Attribution

This supersedes and builds on #3166 by @LordMelkor. Thank you for
identifying the roster boundary and contributing the original
complete-roster and mention-index patch. The production roster/query
changes and the two PostgreSQL regressions retain that work's shape;
this PR rebases it onto current `main`, adds relay coverage, and adds
the targeted repair operation requested for rollout.

## Validation

Exact pushed head: `24d02e4f3824150ed84913c9d230e675502e5b12`

- `cargo check -p buzz-db -p buzz-admin`
- `cargo test -p buzz-db
channel::tests::get_members_returns_full_roster_beyond_1000 -- --ignored
--exact --nocapture`
- `cargo test -p buzz-db
feed::tests::insert_mentions_indexes_rosters_past_bind_parameter_cap --
--ignored --exact --nocapture`
- `cargo test -p buzz-relay --lib
handlers::side_effects::tests::group_members_snapshot_keeps_members_past_one_thousand
-- --exact`
- `cargo run -q -p buzz-admin -- reconcile-channels --help`
- mandatory pre-push hook: branch-skew, desktop checks/typecheck/tests,
mobile tests, Rust tests, and desktop Tauri checks all passed on the
pushed head

## Rollout

1. Deploy the relay/backend build.
2. Run `buzz-admin reconcile-channels --channel <general-channel-uuid>`
with `BUZZ_RELAY_PRIVATE_KEY` configured.
3. Verify the replacement kind `39002` roster count matches the active
database membership count.

No schema migration or desktop release is required.

Fixes #3156
Supersedes #3166

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-13 15:48:04 -06:00
committed by GitHub
parent 514195b1d5
commit e0940927ff
9 changed files with 547 additions and 86 deletions

View File

@@ -35,4 +35,5 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
tracing = { workspace = true }
sqlx = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
clap = { version = "4", features = ["derive"] }

View File

@@ -88,12 +88,17 @@ enum Command {
#[command(subcommand)]
command: deletions::DeletionsCommand,
},
/// Emit kind:39000/39002 events for channels missing them.
/// Emit missing kind:39000/39001/39002 channel discovery events, or
/// republish only a targeted channel's kind:39002 roster.
///
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
/// have Nostr discovery events. This command creates them so pure-nostr
/// clients can see those channels. Idempotent — safe to run multiple times.
/// Without `--channel`, only channels missing discovery metadata are
/// reconciled. With `--channel`, only that channel's member snapshot is
/// replaced; canonical metadata and admin events remain untouched.
ReconcileChannels {
/// Optional channel UUID to force-republish.
#[arg(long)]
channel: Option<String>,
/// Relay private key (hex) for signing events. Falls back to
/// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates
/// an ephemeral key (events will be unverifiable after restart).
@@ -156,8 +161,8 @@ async fn run(cli: Cli) -> Result<i32> {
command: ProductFeedbackCommand::List { limit },
} => cmd_list_product_feedback(limit).await,
Command::Deletions { command } => deletions::run(command).await,
Command::ReconcileChannels { relay_key } => {
reconcile_channels(relay_key).await?;
Command::ReconcileChannels { channel, relay_key } => {
reconcile_channels(channel, relay_key).await?;
Ok(0)
}
}
@@ -466,14 +471,26 @@ async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
Ok(TenantContext::resolved(record.id, record.host))
}
async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
async fn reconcile_channels(
channel_arg: Option<String>,
relay_key_arg: Option<String>,
) -> Result<()> {
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;
let db = connect_db().await?;
// Resolve relay signing key: arg > env > ephemeral
let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) {
// Resolve relay signing key: arg > env > ephemeral. Force-republish must
// never use an ephemeral key because it replaces an existing authoritative
// snapshot.
let configured_relay_key =
relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok());
if channel_arg.is_some() && configured_relay_key.is_none() {
return Err(anyhow::anyhow!(
"--channel requires --relay-key or BUZZ_RELAY_PRIVATE_KEY"
));
}
let relay_keys = match configured_relay_key {
Some(key_hex) => {
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
}
@@ -490,7 +507,21 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
};
let tenant = resolve_admin_tenant(&db).await?;
let channels = db.list_channels(tenant.community(), None).await?;
let target_channel = channel_arg
.as_deref()
.map(uuid::Uuid::parse_str)
.transpose()
.map_err(|e| anyhow::anyhow!("invalid --channel UUID: {e}"))?;
let channels = if let Some(target) = target_channel {
vec![db
.get_channel(tenant.community(), target)
.await
.map_err(|_| {
anyhow::anyhow!("channel {target} not found in community {}", tenant.host())
})?]
} else {
db.list_channels(tenant.community(), None).await?
};
if channels.is_empty() {
println!("No channels in database.");
return Ok(());
@@ -513,57 +544,64 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
.await
.unwrap_or_default();
if !existing.is_empty() {
if !existing.is_empty() && target_channel.is_none() {
skipped += 1;
continue;
}
let members = db.get_members(tenant.community(), channel.id).await?;
// kind:39000 — channel metadata
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.push(Tag::parse(["t", &channel.channel_type])?);
let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
// A targeted repair is deliberately roster-only. kind:39000 metadata
// is richer than this legacy backfill builder, and kind:39001 is not
// part of the stale-roster incident; replacing either can destroy
// canonical state. Full backfill still creates all three event kinds
// for channels with no discovery metadata.
if target_channel.is_none() {
// kind:39000 — channel metadata
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, &m.role])?);
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
tags.push(Tag::parse(["name", &channel.name])?);
if let Some(ref desc) = channel.description {
if !desc.is_empty() {
tags.push(Tag::parse(["about", desc])?);
}
}
if channel.visibility == "private" {
tags.push(Tag::parse(["private"])?);
} else {
tags.push(Tag::parse(["public"])?);
}
if channel.channel_type == "dm" {
tags.push(Tag::parse(["hidden"])?);
}
tags.push(Tag::parse(["closed"])?);
tags.push(Tag::parse(["t", &channel.channel_type])?);
let event = EventBuilder::new(Kind::Custom(39000), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
// kind:39001 — admins
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
for m in members
.iter()
.filter(|m| m.role == "owner" || m.role == "admin")
{
let pk = hex::encode(&m.pubkey);
tags.push(Tag::parse(["p", &pk, &m.role])?);
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}
// kind:39002 — members

View File

@@ -687,7 +687,12 @@ pub async fn membership_pairs(
.collect()
}
/// Returns all active members of the given channel.
/// Returns all active members of the given channel, ordered by `joined_at`.
///
/// The roster is returned in full and is never truncated: callers use it to
/// build the kind 39002 (NIP-29 group members) snapshot and to resolve actor
/// roles for admin-event authorization, so a partial list silently hides late
/// joiners from channel discovery and makes them read as non-members.
///
/// Returns an empty list if the channel has been soft-deleted.
pub async fn get_members(
@@ -702,7 +707,6 @@ pub async fn get_members(
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.removed_at IS NULL
ORDER BY cm.joined_at ASC
LIMIT 1000
"#,
)
.bind(community_id.as_uuid())
@@ -1532,7 +1536,7 @@ mod tests {
use crate::user::{ensure_user, set_agent_owner};
use nostr::Keys;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
async fn setup_pool() -> PgPool {
PgPool::connect(TEST_DB_URL)
@@ -1933,6 +1937,85 @@ mod tests {
assert_eq!(channel_ids.len(), channel_count as usize);
}
/// `get_members` must return the complete roster, not a truncated prefix.
///
/// The relay builds the kind 39002 (NIP-29 group members) snapshot and every
/// admin role lookup from this list, so a cap silently hides late joiners:
/// their clients never discover the channel, and an owner past the cutoff
/// reads as a non-member.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn get_members_returns_full_roster_beyond_1000() {
let database_url =
std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
let pool = PgPool::connect(&database_url)
.await
.expect("connect to test DB");
let community_id = make_test_community(&pool).await;
let community = CommunityId::from_uuid(community_id);
let creator = random_pubkey();
// create_test_channel also inserts the creator as the first (owner) member.
let channel = create_test_channel(
&pool,
community_id,
"high-volume-roster",
ChannelType::Stream,
ChannelVisibility::Open,
None,
&creator,
None,
)
.await
.expect("create test channel");
// Bulk-insert additional members with strictly increasing `joined_at`, so
// member N lands at roster position N (the creator holds position 0).
// The final member is an owner joining well past the old 1000-row cutoff.
let extra_members = 1_500;
sqlx::query(
r#"
INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at)
SELECT
$1,
$2,
decode(lpad(to_hex(n), 64, '0'), 'hex'),
(CASE WHEN n = $3 THEN 'owner' ELSE 'member' END)::member_role,
NOW() + (n || ' seconds')::interval
FROM generate_series(1, $3) n
"#,
)
.bind(community_id)
.bind(channel.id)
.bind(extra_members)
.execute(&pool)
.await
.expect("insert high-volume channel members");
let members = get_members(&pool, community, channel.id)
.await
.expect("load channel members");
assert_eq!(
members.len(),
extra_members as usize + 1,
"get_members truncated the roster"
);
// The last joiner sits at the final roster position — past any
// 1000-row cap — which also pins the documented `joined_at` ordering.
let late_owner = hex::decode(format!("{:064x}", extra_members)).expect("hex pubkey");
let late = members.last().expect("roster is non-empty");
assert_eq!(
late.pubkey, late_owner,
"member who joined after the 1000th must be present and ordered last"
);
assert_eq!(
late.role, "owner",
"role of a late-joining owner must resolve correctly"
);
}
/// A random non-admin, non-owner user cannot remove someone else's bot.
#[tokio::test]
#[ignore = "requires Postgres"]

View File

@@ -886,4 +886,40 @@ mod tests {
let unique: std::collections::HashSet<Vec<u8>> = byte_seqs.into_iter().collect();
assert_eq!(unique.len(), 5, "all channel IDs must be distinct");
}
/// `insert_mentions` must index every p-tag even past Postgres's
/// bind-parameter statement cap.
///
/// Relay-signed kind 39002 member snapshots carry one p-tag per channel
/// member, and a multi-row INSERT binds 6 parameters per row — a single
/// statement tops out at ~10.9k rows against the 65,535-parameter limit.
/// Clients discover their channels via `{kinds:[39002], "#p":[me]}`, so a
/// failed insert silently breaks discovery for the whole channel.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn insert_mentions_indexes_rosters_past_bind_parameter_cap() {
let pool = setup_pool().await;
let community = CommunityId::from_uuid(make_test_community(&pool).await);
let channel = insert_test_channel(&pool, community).await;
// 11,000 rows x 6 binds = 66,000 > 65,535: overflows a single statement.
let mention_count = 11_000usize;
let tags: Vec<Tag> = (1..=mention_count)
.map(|n| Tag::parse(["p", &format!("{n:064x}")]).expect("p tag"))
.collect();
let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await;
let indexed: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2",
)
.bind(community.as_uuid())
.bind(event.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.expect("count indexed mentions");
assert_eq!(
indexed as usize, mention_count,
"every roster p-tag must land in event_mentions"
);
}
}

View File

@@ -104,6 +104,21 @@ pub async fn insert_mentions(
community_id: CommunityId,
event: &nostr::Event,
channel_id: Option<Uuid>,
) -> Result<()> {
let mut tx = pool.begin().await?;
insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?;
tx.commit().await?;
Ok(())
}
/// Insert mention rows on the caller's transaction. Replacement writes use
/// this so the authoritative event and its discovery index commit or roll back
/// as one unit.
async fn insert_mentions_in_transaction(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
community_id: CommunityId,
event: &nostr::Event,
channel_id: Option<Uuid>,
) -> Result<()> {
let p_tags: Vec<&str> = event
.tags
@@ -150,24 +165,31 @@ pub async fn insert_mentions(
return Ok(());
}
// Single multi-row INSERT ... ON CONFLICT DO NOTHING — one round-trip regardless of mention count.
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
);
// Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under
// Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a
// single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry
// one p-tag per channel member and can exceed that. The caller owns the
// transaction so all chunks share its commit boundary.
const MENTION_INSERT_CHUNK_ROWS: usize = 5_000;
for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) {
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"INSERT INTO event_mentions \
(community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ",
);
qb.push_values(&valid_pubkeys, |mut b, pubkey| {
b.push_bind(community_id.as_uuid())
.push_bind(pubkey.as_str())
.push_bind(event_id_bytes.as_slice())
.push_bind(created_at)
.push_bind(channel_id)
.push_bind(kind as i32);
});
qb.push_values(chunk, |mut b, pubkey| {
b.push_bind(community_id.as_uuid())
.push_bind(pubkey.as_str())
.push_bind(event_id_bytes.as_slice())
.push_bind(created_at)
.push_bind(channel_id)
.push_bind(kind as i32);
});
qb.push(" ON CONFLICT DO NOTHING");
qb.push(" ON CONFLICT DO NOTHING");
qb.build().execute(pool).await?;
qb.build().execute(&mut **tx).await?;
}
Ok(())
}
@@ -4916,13 +4938,12 @@ impl Db {
));
}
tx.commit().await?;
// The replaceable event and its denormalized mention index are one
// authoritative discovery write. An indexing error must roll back the
// new event and restore the previously-live event.
crate::insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?;
// Mentions are a denormalized index — safe outside the transaction.
// insert_event() normally handles this, but we inlined the INSERT above.
if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await {
tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}");
}
tx.commit().await?;
Ok((
StoredEvent::with_received_at(event.clone(), received_at, channel_id, true),
@@ -5459,6 +5480,87 @@ mod tests {
id
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn addressable_replacement_rolls_back_when_mention_indexing_fails() {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
let admin = PgPool::connect(&admin_url().await)
.await
.expect("connect admin");
let (pool, scratch_name) = create_scratch_db(&admin, "atomic_addressable").await;
let db = Db::from_pool(pool.clone());
let community_uuid = Uuid::new_v4();
let channel = Uuid::new_v4();
let keys = Keys::generate();
seed_community_channel(&pool, community_uuid, channel, &keys).await;
let community = CommunityId::from_uuid(community_uuid);
let member = Keys::generate().public_key().to_hex();
let tags = || {
vec![
Tag::parse(["d", channel.to_string().as_str()]).expect("d tag"),
Tag::parse(["p", member.as_str(), "", "member"]).expect("p tag"),
]
};
let base = Timestamp::now().as_secs();
let old = EventBuilder::new(Kind::Custom(39002), "old")
.tags(tags())
.custom_created_at(Timestamp::from(base))
.sign_with_keys(&keys)
.expect("sign old");
db.replace_addressable_event(community, &old, Some(channel))
.await
.expect("insert old roster");
sqlx::query(
"CREATE FUNCTION reject_test_mention() RETURNS trigger AS $$ \
BEGIN RAISE EXCEPTION 'injected mention failure'; END; \
$$ LANGUAGE plpgsql",
)
.execute(&pool)
.await
.expect("create failure function");
sqlx::query(
"CREATE TRIGGER reject_test_mention BEFORE INSERT ON event_mentions \
FOR EACH ROW EXECUTE FUNCTION reject_test_mention()",
)
.execute(&pool)
.await
.expect("install failure injection");
let new = EventBuilder::new(Kind::Custom(39002), "new")
.tags(tags())
.custom_created_at(Timestamp::from(base + 1))
.sign_with_keys(&keys)
.expect("sign new");
let error = db
.replace_addressable_event(community, &new, Some(channel))
.await
.expect_err("mention failure must fail replacement");
assert!(error.to_string().contains("injected mention failure"));
let live_id: Vec<u8> = sqlx::query_scalar(
"SELECT id FROM events WHERE community_id=$1 AND channel_id=$2 \
AND kind=39002 AND deleted_at IS NULL",
)
.bind(community.as_uuid())
.bind(channel)
.fetch_one(&pool)
.await
.expect("query live roster");
assert_eq!(live_id, old.id.as_bytes(), "old roster must remain live");
let new_rows: i64 =
sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2")
.bind(community.as_uuid())
.bind(new.id.as_bytes().as_slice())
.fetch_one(&pool)
.await
.expect("count rolled-back event");
assert_eq!(new_rows, 0, "new roster must roll back with its index");
drop_scratch_db(&admin, pool, &scratch_name).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn nip_rs_replacement_hard_deletes_payload_and_watermark_rejects_replay() {

View File

@@ -1033,6 +1033,18 @@ async fn emit_addressable_discovery_event(
Ok(())
}
fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result<Vec<Tag>> {
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
tags.push(Tag::parse(["d", group_id])?);
for member in members {
let pubkey_hex = hex::encode(&member.pubkey);
// NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url
// because the canonical relay is implicit (this event is signed by it).
tags.push(Tag::parse(["p", &pubkey_hex, "", &member.role])?);
}
Ok(tags)
}
/// Emit NIP-29 group discovery events (39000, 39001, 39002) signed by the relay keypair.
/// Called after group creation, metadata changes, or membership changes.
/// Events are stored channel-scoped (`channel_id = Some(...)`) so that existing
@@ -1136,13 +1148,7 @@ pub async fn emit_group_discovery_events(
}
{
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &group_id])?];
for m in &members {
let pubkey_hex = hex::encode(&m.pubkey);
// NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url
// because the canonical relay is implicit (this event is signed by it).
tags.push(Tag::parse(["p", &pubkey_hex, "", &m.role])?);
}
let tags = group_members_tags(&group_id, &members)?;
emit_addressable_discovery_event(
tenant,
state,
@@ -3372,6 +3378,33 @@ fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
mod tests {
use super::*;
#[test]
fn group_members_snapshot_keeps_members_past_one_thousand() {
let channel_id = Uuid::new_v4();
let members: Vec<MemberRecord> = (0_u16..1_501)
.map(|index| MemberRecord {
channel_id,
pubkey: vec![(index >> 8) as u8, index as u8],
role: if index == 1_500 { "owner" } else { "member" }.to_string(),
joined_at: chrono::Utc::now(),
invited_by: None,
removed_at: None,
})
.collect();
let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags");
assert_eq!(tags.len(), 1_502, "d tag plus every member p tag");
let late_pubkey = hex::encode(&members[1_500].pubkey);
assert!(tags.iter().any(|tag| {
let fields = tag.as_slice();
fields.len() == 4
&& fields[0] == "p"
&& fields[1] == late_pubkey
&& fields[3] == "owner"
}));
}
#[test]
fn delete_tombstone_omits_absent_moderation_metadata() {
let content =