diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..5bcd5bc9fc 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -683,7 +683,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( @@ -698,7 +703,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()) @@ -1527,7 +1531,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) @@ -1928,6 +1932,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"] diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 511a2a6083..d54fc6fe55 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -830,4 +830,40 @@ mod tests { let unique: std::collections::HashSet> = 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 = (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" + ); + } } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index c59cda1a48..29fce5fc99 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -145,24 +145,35 @@ 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 = 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(" ON CONFLICT DO NOTHING"); - - qb.build().execute(pool).await?; + // 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. All chunks run in one + // transaction: callers treat mention indexing as fire-and-forget (failures + // are logged, the event is already stored, and nothing retries), so a + // partially committed index would be permanent. + const MENTION_INSERT_CHUNK_ROWS: usize = 5_000; + let mut tx = pool.begin().await?; + for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) { + let mut qb: QueryBuilder = QueryBuilder::new( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + ); + + 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.build().execute(&mut *tx).await?; + } + tx.commit().await?; Ok(()) }