@@ -74,11 +74,10 @@ pub struct EventQuery {
7474 /// channel-less global events. Applied before SQL `LIMIT` so access-filtered
7575 /// historical pages have exact exhaustion semantics.
7676 pub channel_ids : Option < Vec < uuid:: Uuid > > ,
77- /// Restrict results to events in exactly these channels. Unlike
78- /// `channel_ids`, this excludes channel-less global events. Used for
79- /// multi-value `#h` filters so unrelated accessible channels and global
80- /// events cannot consume the SQL `LIMIT` before NIP-01 post-filtering.
81- pub exact_channel_ids : Option < Vec < uuid:: Uuid > > ,
77+ /// Restrict results to events in channels where this pubkey has an active
78+ /// membership. The membership is resolved by PostgreSQL with an indexed
79+ /// `EXISTS` subquery, avoiding client-supplied channel-ID arrays.
80+ pub member_channel_pubkey : Option < Vec < u8 > > ,
8281 /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by
8382 /// the COUNT fallback path, which needs to fetch all matching events for
8483 /// post-filter counting. When None, the default clamp applies.
@@ -127,7 +126,7 @@ impl EventQuery {
127126 ids : None ,
128127 e_tags : None ,
129128 channel_ids : None ,
130- exact_channel_ids : None ,
129+ member_channel_pubkey : None ,
131130 max_limit : None ,
132131 shared_gated_reader : None ,
133132 }
@@ -410,7 +409,7 @@ pub(crate) async fn query_events_on(
410409 qb. push ( format ! ( " AND {col_prefix}channel_id IS NULL" ) ) ;
411410 }
412411
413- // Multi-channel array pushdown: restrict to events in any of these channels
412+ // Multi-channel IN pushdown: restrict to events in any of these channels
414413 // OR global events (channel_id IS NULL). Used by NIP-45 COUNT to enforce
415414 // channel access at the SQL level without fetching all rows.
416415 //
@@ -422,23 +421,41 @@ pub(crate) async fn query_events_on(
422421 qb. push ( format ! ( " AND {col_prefix}channel_id IS NULL" ) ) ;
423422 } else {
424423 qb. push ( format ! (
425- " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id = ANY ("
424+ " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
426425 ) ) ;
427- qb. push_bind ( ch_ids. clone ( ) ) ;
426+ let mut sep = qb. separated ( ", " ) ;
427+ for ch in ch_ids {
428+ sep. push_bind ( * ch) ;
429+ }
428430 qb. push ( "))" ) ;
429431 }
430432 }
431433
432- // Exact multi-channel pushdown for NIP-01 `#h` filters. Unlike the access
433- // scope above, an `#h` filter never matches channel-less global events.
434- if let Some ( ref ch_ids) = q. exact_channel_ids {
435- if ch_ids. is_empty ( ) {
436- qb. push ( " AND FALSE" ) ;
434+ // Membership-scoped overview queries resolve channel access inside
435+ // PostgreSQL. The active-membership index starts with (community_id,
436+ // pubkey), and the channel PK check completes the lookup without shipping
437+ // every channel UUID through the client and relay.
438+ if let Some ( ref member_pubkey) = q. member_channel_pubkey {
439+ let outer_event_prefix = if q. p_tag_hex . is_some ( ) {
440+ "e."
437441 } else {
438- qb. push ( format ! ( " AND {col_prefix}channel_id = ANY(" ) ) ;
439- qb. push_bind ( ch_ids. clone ( ) ) ;
440- qb. push ( ")" ) ;
441- }
442+ "events."
443+ } ;
444+ qb. push ( format ! (
445+ " AND {outer_event_prefix}channel_id IS NOT NULL AND EXISTS (\
446+ SELECT 1 FROM channel_members cm \
447+ JOIN channels c \
448+ ON c.community_id = cm.community_id AND c.id = cm.channel_id \
449+ WHERE cm.community_id = {outer_event_prefix}community_id \
450+ AND cm.channel_id = {outer_event_prefix}channel_id \
451+ AND cm.pubkey = "
452+ ) ) ;
453+ qb. push_bind ( member_pubkey. clone ( ) ) ;
454+ qb. push (
455+ " AND cm.removed_at IS NULL \
456+ AND c.deleted_at IS NULL \
457+ AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL))",
458+ ) ;
442459 }
443460
444461 if let Some ( ks) = q. kinds . as_deref ( ) . filter ( |k| !k. is_empty ( ) ) {
@@ -685,32 +702,23 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer
685702 qb. push ( format ! ( " AND {col_prefix}channel_id IS NULL" ) ) ;
686703 }
687704
688- // Multi-channel array pushdown for COUNT: restrict to accessible channels + global.
705+ // Multi-channel IN pushdown for COUNT: restrict to accessible channels + global.
689706 // SECURITY: Some(empty vec) = no channel access → global events only.
690707 if let Some ( ref ch_ids) = q. channel_ids {
691708 if ch_ids. is_empty ( ) {
692709 qb. push ( format ! ( " AND {col_prefix}channel_id IS NULL" ) ) ;
693710 } else {
694711 qb. push ( format ! (
695- " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id = ANY ("
712+ " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN ("
696713 ) ) ;
697- qb. push_bind ( ch_ids. clone ( ) ) ;
714+ let mut sep = qb. separated ( ", " ) ;
715+ for ch in ch_ids {
716+ sep. push_bind ( * ch) ;
717+ }
698718 qb. push ( "))" ) ;
699719 }
700720 }
701721
702- // Exact multi-channel pushdown for NIP-01 `#h` filters. Unlike the access
703- // scope above, an `#h` filter never matches channel-less global events.
704- if let Some ( ref ch_ids) = q. exact_channel_ids {
705- if ch_ids. is_empty ( ) {
706- qb. push ( " AND FALSE" ) ;
707- } else {
708- qb. push ( format ! ( " AND {col_prefix}channel_id = ANY(" ) ) ;
709- qb. push_bind ( ch_ids. clone ( ) ) ;
710- qb. push ( ")" ) ;
711- }
712- }
713-
714722 if let Some ( ks) = q. kinds . as_deref ( ) . filter ( |k| !k. is_empty ( ) ) {
715723 qb. push ( format ! ( " AND {col_prefix}kind IN (" ) ) ;
716724 let mut sep = qb. separated ( ", " ) ;
@@ -1951,80 +1959,53 @@ mod tests {
19511959
19521960 #[ tokio:: test]
19531961 #[ ignore = "requires Postgres" ]
1954- async fn exact_channel_scope_is_applied_before_limit_and_excludes_globals ( ) {
1962+ async fn member_channel_scope_is_applied_before_limit_and_excludes_non_members ( ) {
19551963 let pool = setup_pool ( ) . await ;
19561964 let community_uuid = make_test_community ( & pool) . await ;
19571965 let community = CommunityId :: from_uuid ( community_uuid) ;
1958- let requested_a = make_test_channel ( & pool, community_uuid, None ) . await ;
1959- let requested_b = make_test_channel ( & pool, community_uuid, None ) . await ;
1960- let unrequested = make_test_channel ( & pool, community_uuid, None ) . await ;
1961- let base = 1_800_100_000 ;
1966+ let member_channel = make_test_channel ( & pool, community_uuid, None ) . await ;
1967+ let nonmember_channel = make_test_channel ( & pool, community_uuid, None ) . await ;
1968+ let member_pubkey = vec ! [ 42_u8 ; 32 ] ;
1969+ sqlx:: query (
1970+ "INSERT INTO channel_members (community_id, channel_id, pubkey) VALUES ($1, $2, $3)" ,
1971+ )
1972+ . bind ( community_uuid)
1973+ . bind ( member_channel)
1974+ . bind ( & member_pubkey)
1975+ . execute ( & pool)
1976+ . await
1977+ . expect ( "insert active membership" ) ;
19621978
1963- // Newer rows outside the exact #h set must not consume the SQL page.
1979+ let base = 1_800_100_000 ;
19641980 for offset in 10 ..13 {
1965- let event = make_event_at ( 39_001 , "newer unrequested " , base + offset) ;
1966- insert_event ( & pool, community, & event, Some ( unrequested ) )
1981+ let event = make_event_at ( 30_620 , "newer nonmember " , base + offset) ;
1982+ insert_event ( & pool, community, & event, Some ( nonmember_channel ) )
19671983 . await
1968- . expect ( "insert unrequested candidate " ) ;
1984+ . expect ( "insert nonmember workflow " ) ;
19691985 }
1970- let global = make_event_at ( 39_001 , "newer global" , base + 9 ) ;
1986+ let global = make_event_at ( 30_620 , "newer global" , base + 9 ) ;
19711987 insert_event ( & pool, community, & global, None )
19721988 . await
1973- . expect ( "insert global candidate " ) ;
1974- let requested_newer = make_event_at ( 39_001 , "requested a " , base + 2 ) ;
1975- insert_event ( & pool, community, & requested_newer , Some ( requested_a ) )
1989+ . expect ( "insert global workflow " ) ;
1990+ let member_workflow = make_event_at ( 30_620 , "older member " , base + 1 ) ;
1991+ insert_event ( & pool, community, & member_workflow , Some ( member_channel ) )
19761992 . await
1977- . expect ( "insert requested a candidate" ) ;
1978- let requested_older = make_event_at ( 39_001 , "requested b" , base + 1 ) ;
1979- insert_event ( & pool, community, & requested_older, Some ( requested_b) )
1980- . await
1981- . expect ( "insert requested b candidate" ) ;
1993+ . expect ( "insert member workflow" ) ;
19821994
19831995 let events = query_events (
19841996 & pool,
19851997 & EventQuery {
1986- kinds : Some ( vec ! [ 39_001 ] ) ,
1987- exact_channel_ids : Some ( vec ! [ requested_a , requested_b ] ) ,
1998+ kinds : Some ( vec ! [ 30_620 ] ) ,
1999+ member_channel_pubkey : Some ( member_pubkey ) ,
19882000 limit : Some ( 2 ) ,
19892001 ..EventQuery :: for_community ( community)
19902002 } ,
19912003 )
19922004 . await
1993- . expect ( "query exact-channel page" ) ;
1994-
1995- assert_eq ! ( events. len( ) , 2 , "exact-channel page must fill before EOF" ) ;
1996- assert_eq ! ( events[ 0 ] . event. id, requested_newer. id) ;
1997- assert_eq ! ( events[ 1 ] . event. id, requested_older. id) ;
1998- assert ! (
1999- events. iter( ) . all( |stored| stored. channel_id. is_some( ) ) ,
2000- "an explicit #h list must exclude channel-less global events"
2001- ) ;
2002- }
2003-
2004- #[ tokio:: test]
2005- #[ ignore = "requires Postgres" ]
2006- async fn empty_exact_channel_scope_matches_nothing ( ) {
2007- let pool = setup_pool ( ) . await ;
2008- let community_uuid = make_test_community ( & pool) . await ;
2009- let community = CommunityId :: from_uuid ( community_uuid) ;
2010- let channel = make_test_channel ( & pool, community_uuid, None ) . await ;
2011- let event = make_event_at ( 39_002 , "must not match" , 1_800_200_000 ) ;
2012- insert_event ( & pool, community, & event, Some ( channel) )
2013- . await
2014- . expect ( "insert candidate" ) ;
2015-
2016- let events = query_events (
2017- & pool,
2018- & EventQuery {
2019- kinds : Some ( vec ! [ 39_002 ] ) ,
2020- exact_channel_ids : Some ( vec ! [ ] ) ,
2021- ..EventQuery :: for_community ( community)
2022- } ,
2023- )
2024- . await
2025- . expect ( "query empty exact-channel scope" ) ;
2005+ . expect ( "query member workflow page" ) ;
20262006
2027- assert ! ( events. is_empty( ) ) ;
2007+ assert_eq ! ( events. len( ) , 1 ) ;
2008+ assert_eq ! ( events[ 0 ] . event. id, member_workflow. id) ;
20282009 }
20292010
20302011 fn make_text_event ( content : & str ) -> nostr:: Event {
0 commit comments