fix(relay): match pushed-down tags by position, not containment (#8335)

🤖 The relay pushes several tag filters into SQL as JSONB containment
(`tags @> [[name, value]]`). Containment ignores element position, so a
tag like `["x","e",<id>]` or `["meta","t",v]` satisfies `#e:[<id>]` or
`#t:[v]`, and `#t:["t"]` matches `["t","other"]`. Because the predicate
runs before `LIMIT`, a newer event that only matches by containment can
take a `limit: 1` slot; the exact NIP-01 post-filter then drops it and
the real match is never returned. Where no post-filter follows, the
decoy is returned or counted outright.

`push_exact_tag_match` in `buzz-db/src/store/event.rs` keeps containment
as the GIN-indexed prefilter (one array-bound `tags @> ANY(...)`, so the
e-tag planner shape is unchanged) and adds a positional `EXISTS (...
tag->>0 = name AND tag->>1 = ANY(values))`. It now backs every
`EventQuery` tag pushdown:

| Pushdown | Before | Effect of over-match |
|---|---|---|
| `#e` (`e_tags`, query and COUNT) | containment | REQ `limit` slot
theft; COUNT has no post-filter and counted decoys; thread aux closure
returned decoy reactions/replies |
| artifact `#d` (`d_tag_values`) | containment | `limit` slot theft on
NIP-AR identity lookups |
| `custom_tag` (bridge `#buzz-channel`) | containment | no post-filter:
a decoy project/repository was returned for its channel |
| shared-gated visibility (`shared_gated_reader`) | containment of
`["shared","true"]` | a private event tagged `["x","shared","true"]`
took a page slot before `event_visible_to_reader` dropped it, so the
page came back short. Never a visibility leak: the post-filter stays in
place |

**Bridge `/count` honors `#buzz-channel`.** `POST /query` scoped
project/repository reads by `#buzz-channel`, but `POST /count` ignored
it, so a count for one channel counted matching projects in every
channel the reader can see. `/count` now sets the same `custom_tag` on
both its channel-scoped and access-scoped paths, and the COUNT SQL
builder pushes `custom_tag` down exactly like the query builder, so
`/count` and `/query` select the same events on both the fast count and
the post-filtered fallback.

Left unchanged: the retention-free purge predicate for mesh status
(`tags @> [["k","buzz-mesh-status"]]`). It mirrors the migration 0019
trigger, has no `LIMIT`, and only affects how the author's own deleted
event is purged. REQ `#t` outside group-state kinds is still
post-filtered only.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-10-14 20:37:11 -04:00
committed by GitHub
parent 9ade5d1ad1
commit e645918c63
4 changed files with 278 additions and 92 deletions

View File

@@ -82,15 +82,16 @@ pub struct EventQuery {
/// Restrict results to events with any of these IDs (multi-id `IN` pushdown).
pub ids: Option<Vec<Vec<u8>>>,
/// Restrict results to events with an `e` tag referencing any of these event IDs (hex).
/// Uses JSONB containment (`tags @> ...`) against the `tags` column.
/// Matched positionally, with JSONB containment as the index prefilter.
pub e_tags: Option<Vec<String>>,
/// Restrict artifact rows ([`ARTIFACT_KINDS`]) to those with a `d` tag
/// matching any of these values, via JSONB containment. Their `d_tag`
/// matching any of these values, matched positionally. Their `d_tag`
/// column is NULL (not NIP-33), so this lets identity lookups match before
/// SQL `LIMIT`. Rows of other kinds are left to the caller's post-filter.
pub d_tag_values: Option<Vec<String>>,
/// Restrict results to events with an exact custom tag pair.
/// Uses JSONB containment against `tags` before SQL `LIMIT`.
/// Restrict results to events with an exact custom tag pair: a tag whose
/// first element is the name and second the value, checked positionally
/// (JSONB containment is only the index prefilter) before SQL `LIMIT`.
pub custom_tag: Option<(String, String)>,
/// Restrict results to events that, for every `(name, values)` entry,
/// have a tag whose name is `name` and whose first value is one of
@@ -117,17 +118,12 @@ pub struct EventQuery {
/// private events are excluded from the candidate page rather than
/// discarded after it.
///
/// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`,
/// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB
/// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004,
/// jsonb_path_ops) makes the containment check fast.
///
/// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which
/// matches any tag array that is a superset of `[["shared","true"]]` — it
/// would match `["shared","true","extra"]` too. The ingest `parts.len() ==
/// 2` exact-shape check ensures such malformed tags are never stored, so the
/// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter
/// defense-in-depth catches any residual mismatch.
/// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR <shared>)`,
/// where the `IN` list is [`SHARED_GATED_KINDS`] and `<shared>` requires a
/// tag whose element 0 is `shared` and element 1 is `true`. Containment on
/// the GIN index (migration 0004, jsonb_path_ops) is the fast first pass;
/// containment alone would also admit decoys like `["x","shared","true"]`.
/// `event_visible_to_reader` remains the post-filter.
pub shared_gated_reader: Option<Vec<u8>>,
}
@@ -729,14 +725,15 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
}
}
// e-tag pushdown via JSONB containment: tags @> '[["e","<hex>"]]'.
// Multiple e-tags use OR (any match). Served by idx_events_tags_gin
// e-tag pushdown: exact positional match with a JSONB containment
// prefilter, tags @> ANY('[["e","<hex>"]]', ...). Served by idx_events_tags_gin
// (GIN, jsonb_path_ops — migrations/0004): the channel-window aux closure
// fans this out once per retained row, which made unindexed containment
// the dominant scroll-back cost (~1.7s/page on staging).
if let Some(ref e_tags) = q.e_tags {
if !e_tags.is_empty() {
push_e_tag_filter(&mut qb, col_prefix, e_tags);
qb.push(" AND ");
push_exact_tag_match(&mut qb, col_prefix, "e", e_tags);
}
}
@@ -745,9 +742,8 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
}
if let Some((ref name, ref value)) = q.custom_tag {
let containment = serde_json::json!([[name, value]]);
qb.push(format!(" AND {col_prefix}tags @> "))
.push_bind(containment);
qb.push(" AND ");
push_exact_tag_match(&mut qb, col_prefix, name, std::slice::from_ref(value));
}
push_tag_value_filters(&mut qb, col_prefix, &q.tag_values);
@@ -794,16 +790,12 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
// shared ones off the end of the result set (the catalog query pattern).
//
// Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader
// OR tags @> '[["shared","true"]]')
// OR <exact ["shared","true",...] tag>)
//
// The JSONB containment check is served by idx_events_tags_gin (migration
// 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array
// that contains exactly the sub-array — a two-element `["shared","true"]`
// tag passes; a tag-absent event does not. Because ingest requires exactly
// two elements for the shared tag (parts.len() == 2), no stored event can
// carry a three-element superset.
// The shared match is positional, like the other tag pushdowns: plain
// containment would admit a private event carrying ["x","shared","true"],
// which then wastes a page slot before the post-filter drops it.
if let Some(ref reader_bytes) = q.shared_gated_reader {
let shared_containment = serde_json::json!([["shared", "true"]]);
qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
let mut sep = qb.separated(", ");
for kind in SHARED_GATED_KINDS {
@@ -811,8 +803,8 @@ fn build_query_events_sql(q: &EventQuery) -> QueryBuilder<sqlx::Postgres> {
}
qb.push(format!(") OR {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(format!(" OR {col_prefix}tags @> "));
qb.push_bind(shared_containment);
qb.push(" OR ");
push_exact_tag_match(&mut qb, col_prefix, "shared", &["true".to_owned()]);
qb.push(")");
}
@@ -863,17 +855,35 @@ async fn fetch_with_e_tag_deadline(
Ok(rows)
}
/// e-tag pushdown as one array-bound containment test instead of an N-way
/// `OR` chain, so planner cost does not scale with the number of referenced
/// ids (the thread aux hop sends one id per reply).
fn push_e_tag_filter(qb: &mut QueryBuilder<sqlx::Postgres>, col_prefix: &str, e_tags: &[String]) {
let containments: Vec<serde_json::Value> = e_tags
/// Match a tag whose first element is `name` and whose second is any of
/// `values` (NIP-01), as one parenthesized predicate.
///
/// JSONB containment alone ignores element position and multiplicity:
/// `[["t","t"]]` is contained in `["t","other"]`, and `[["e",id]]` in
/// `["x","e",id]`. Over-matching before `LIMIT` lets a non-matching newer row
/// take the slot, so containment stays only as the GIN-indexed prefilter
/// (one array-bound test, so planner cost does not scale with `values`) and
/// the positional check makes the predicate exact.
fn push_exact_tag_match(
qb: &mut QueryBuilder<sqlx::Postgres>,
col_prefix: &str,
name: &str,
values: &[String],
) {
let containments: Vec<serde_json::Value> = values
.iter()
.map(|hex_id| serde_json::json!([["e", hex_id]]))
.map(|value| serde_json::json!([[name, value]]))
.collect();
qb.push(format!(" AND {col_prefix}tags @> ANY("))
qb.push(format!("({col_prefix}tags @> ANY("))
.push_bind(containments)
.push("::jsonb[])");
.push(format!(
"::jsonb[]) AND EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS tag \
WHERE tag->>0 = "
))
.push_bind(name.to_owned())
.push(" AND tag->>1 = ANY(")
.push_bind(values.to_vec())
.push(")))");
}
/// Whether `q` carries a [`EventQuery::tag_values`] match. Such a query runs
@@ -884,16 +894,8 @@ fn has_tag_values(q: &EventQuery) -> bool {
q.tag_values.iter().any(|(_, values)| !values.is_empty())
}
/// Two tests per [`EventQuery::tag_values`] entry, so each entry is an OR
/// over its values and entries are ANDed:
///
/// - `tags @> ANY(...)` lets Postgres use the `tags` GIN index. On its own
/// it over-matches, because JSONB containment ignores order and repeats:
/// `[["t", "stream"]]` contains `[["t", "t"]]`, and `[["name", "t"]]`
/// contains `[["t", "name"]]`. Those false candidates could fill the page
/// before `LIMIT` and hide a real match.
/// - The `EXISTS` test then keeps only rows with a tag whose name is
/// exactly `name` and whose first value is one of `values`.
/// AND one [`push_exact_tag_match`] per non-empty [`EventQuery::tag_values`]
/// entry, so entries are ANDed and values within an entry are ORed.
fn push_tag_value_filters(
qb: &mut QueryBuilder<sqlx::Postgres>,
col_prefix: &str,
@@ -903,26 +905,13 @@ fn push_tag_value_filters(
if values.is_empty() {
continue;
}
let containments: Vec<serde_json::Value> = values
.iter()
.map(|value| serde_json::json!([[name, value]]))
.collect();
qb.push(format!(" AND {col_prefix}tags @> ANY("))
.push_bind(containments)
.push("::jsonb[])");
qb.push(format!(
" AND EXISTS (SELECT 1 FROM jsonb_array_elements({col_prefix}tags) AS tag \
WHERE tag->>0 = "
))
.push_bind(name.clone())
.push(" AND tag->>1 = ANY(")
.push_bind(values.clone())
.push("::text[]))");
qb.push(" AND ");
push_exact_tag_match(qb, col_prefix, name, values);
}
}
/// Match `#d` on artifact rows before `LIMIT` while leaving other kinds to the
/// caller's post-filter: `(kind NOT IN (artifact kinds) OR tags @> [["d", v]] ...)`.
/// caller's post-filter: `(kind NOT IN (artifact kinds) OR <exact d match>)`.
fn push_artifact_d_tag_predicate(
qb: &mut QueryBuilder<sqlx::Postgres>,
col_prefix: &str,
@@ -933,12 +922,9 @@ fn push_artifact_d_tag_predicate(
}
let [revision, removal] = ARTIFACT_KINDS;
qb.push(format!(
" AND ({col_prefix}kind NOT IN ({revision}, {removal})"
" AND ({col_prefix}kind NOT IN ({revision}, {removal}) OR "
));
for value in values {
qb.push(format!(" OR {col_prefix}tags @> "));
qb.push_bind(serde_json::json!([["d", value]]));
}
push_exact_tag_match(qb, col_prefix, "d", values);
qb.push(")");
}
@@ -1111,7 +1097,8 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer
if let Some(ref e_tags) = q.e_tags {
if !e_tags.is_empty() {
push_e_tag_filter(&mut qb, col_prefix, e_tags);
qb.push(" AND ");
push_exact_tag_match(&mut qb, col_prefix, "e", e_tags);
}
}
@@ -1119,6 +1106,11 @@ pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuer
push_artifact_d_tag_predicate(&mut qb, col_prefix, values);
}
if let Some((ref name, ref value)) = q.custom_tag {
qb.push(" AND ");
push_exact_tag_match(&mut qb, col_prefix, name, std::slice::from_ref(value));
}
push_tag_value_filters(&mut qb, col_prefix, &q.tag_values);
if let Some(s) = q.since {
@@ -2936,6 +2928,145 @@ mod postgres_tests {
(inside, after)
}
/// Every pushed-down tag filter matches by position: a newer decoy whose
/// tag holds the wanted name and value in the wrong slots must not take a
/// `limit: 1` page or be counted.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn pushed_down_tag_filters_reject_positional_decoys() {
let pool = setup_pool().await;
let community = CommunityId::from_uuid(make_test_community(&pool).await);
let keys = Keys::generate();
let now = nostr::Timestamp::now().as_secs();
let target = "ab".repeat(32);
let insert = |kind: u16, tag: Vec<&str>, at: u64| {
let event = EventBuilder::new(Kind::Custom(kind), "")
.tags([Tag::parse(tag).unwrap()])
.custom_created_at(nostr::Timestamp::from(at))
.sign_with_keys(&keys)
.unwrap();
let pool = pool.clone();
async move {
insert_event(&pool, community, &event, None).await.unwrap();
event.id.as_bytes().to_vec()
}
};
// Removals: revisions also need an `artifact_heads` row to be read.
let [_, artifact_kind] = ARTIFACT_KINDS;
let artifact_kind = artifact_kind as u16;
struct Case<'a> {
label: &'a str,
kind: u16,
real: Vec<&'a str>,
/// Inserted after `real`, so it wins a `limit: 1` read if matched.
decoy: Vec<&'a str>,
query: EventQuery,
}
let cases = [
Case {
label: "custom tag value in name slot",
kind: 1,
real: vec!["t", "t"],
decoy: vec!["t", "other"],
query: {
let mut q = EventQuery::for_community(community);
q.custom_tag = Some(("t".into(), "t".into()));
q
},
},
Case {
label: "custom tag shifted right",
kind: 1,
real: vec!["t", "v"],
decoy: vec!["meta", "t", "v"],
query: {
let mut q = EventQuery::for_community(community);
q.custom_tag = Some(("t".into(), "v".into()));
q
},
},
Case {
label: "e tag shifted right",
kind: 7,
real: vec!["e", &target],
decoy: vec!["x", "e", &target],
query: {
let mut q = EventQuery::for_community(community);
q.e_tags = Some(vec![target.clone()]);
q
},
},
Case {
label: "artifact d tag shifted right",
kind: artifact_kind,
real: vec!["d", "a1"],
decoy: vec!["x", "d", "a1"],
query: {
let mut q = EventQuery::for_community(community);
q.d_tag_values = Some(vec!["a1".into()]);
q
},
},
];
for (offset, case) in (0u64..).step_by(10).zip(cases) {
let Case {
label,
kind,
real,
decoy,
query: mut q,
} = case;
let real_id = insert(kind, real, now - 1000 + offset).await;
insert(kind, decoy, now - 995 + offset).await;
q.kinds = Some(vec![i32::from(kind)]);
q.pubkey = Some(keys.public_key().to_bytes().to_vec());
q.since = Some(DateTime::from_timestamp((now - 1000 + offset) as i64, 0).unwrap());
q.until = Some(DateTime::from_timestamp((now - 995 + offset) as i64, 0).unwrap());
q.limit = Some(1);
let rows = query_events(&pool, &q).await.unwrap();
assert_eq!(rows.len(), 1, "{label}");
assert_eq!(rows[0].event.id.as_bytes().to_vec(), real_id, "{label}");
assert_eq!(count_events(&pool, &q).await.unwrap(), 1, "{label} count");
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn shared_gate_rejects_positional_decoy() {
let pool = setup_pool().await;
let community = CommunityId::from_uuid(make_test_community(&pool).await);
let (author, reader) = (Keys::generate(), Keys::generate());
let kind = SHARED_GATED_KINDS[0];
let now = nostr::Timestamp::now().as_secs();
let mut ids = Vec::new();
// Real shared event, then a newer private one whose shared marker is
// shifted right, so it would win a `limit: 1` read if admitted.
for (tag, at) in [
(["shared", "true"].as_slice(), now - 10),
(&["x", "shared", "true"], now),
] {
let event = EventBuilder::new(Kind::Custom(kind as u16), "")
.tags([
Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(),
Tag::parse(tag.iter().copied()).unwrap(),
])
.custom_created_at(nostr::Timestamp::from(at))
.sign_with_keys(&author)
.unwrap();
insert_event(&pool, community, &event, None).await.unwrap();
ids.push(event.id.as_bytes().to_vec());
}
let mut q = EventQuery::for_community(community);
q.kinds = Some(vec![kind as i32]);
q.pubkey = Some(author.public_key().to_bytes().to_vec());
q.shared_gated_reader = Some(reader.public_key().to_bytes().to_vec());
q.limit = Some(1);
let rows = query_events(&pool, &q).await.unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].event.id.as_bytes().to_vec(), ids[0]);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn e_tag_deadline_only_tightens_and_restores_timeout() {

View File

@@ -144,25 +144,74 @@ impl Fixture {
#[ignore = "requires Postgres"]
async fn project_filter_survives_artifact_query_hook() {
let f = Fixture::new().await;
let project = EventBuilder::new(Kind::Custom(30621), "")
.tags([
Tag::parse(["d", "review-project"]).unwrap(),
Tag::parse(["buzz-channel", &f.home.to_string()]).unwrap(),
])
.sign_with_keys(&f.owner)
.unwrap();
f.state
.db
.insert_event(f.community, &project, None)
.await
.unwrap();
let filter = json!([{"kinds":[30621],"#buzz-channel":[f.home]}]);
let (status, body) = f.request("/query", filter.clone()).await;
assert!(status.is_success(), "{status}: {body}");
assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["id"], project.id.to_hex());
let (status, body) = f.request("/count", filter).await;
assert!(status.is_success(), "{status}: {body}");
let home = f.home.to_string();
// 30621 projects and 30617 repositories bind a channel via `buzz-channel`.
for kind in [30621u16, 30617] {
let event = |tag: &[&str], at: u64| {
EventBuilder::new(Kind::Custom(kind), "")
.tags([
Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(),
Tag::parse(tag.iter().copied()).unwrap(),
])
.custom_created_at(nostr::Timestamp::from(at))
.sign_with_keys(&f.owner)
.unwrap()
};
let now = nostr::Timestamp::now().as_secs();
let bound = event(&["buzz-channel", &home], now - 10);
// Newer, unbound: `buzz-channel` and the wanted id sit in an unrelated tag.
let decoy = event(&["x", "buzz-channel", &home], now);
for e in [&bound, &decoy] {
f.state.db.insert_event(f.community, e, None).await.unwrap();
}
let filter = json!([{"kinds":[kind],"#buzz-channel":[f.home],"limit":1}]);
let (status, body) = f.request("/query", filter.clone()).await;
assert!(status.is_success(), "{kind} {status}: {body}");
assert_eq!(body.as_array().unwrap().len(), 1, "{kind}: {body}");
assert_eq!(body[0]["id"], bound.id.to_hex(), "{kind}: {body}");
let (status, body) = f.request("/count", filter).await;
assert!(status.is_success(), "{kind} {status}: {body}");
assert_eq!(body["count"], 1, "{kind}: {body}");
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn count_honors_buzz_channel_filter() {
let f = Fixture::new().await;
// Two projects bound to `home`, one bound to `private`, all stored in
// `home` so the explicit `#h` scope alone would count all three.
for channel in [f.home, f.home, f.private] {
let event = EventBuilder::new(Kind::Custom(30621), "")
.tags([
Tag::parse(["d", &Uuid::new_v4().to_string()]).unwrap(),
Tag::parse(["buzz-channel", &channel.to_string()]).unwrap(),
Tag::parse(["t", "proj"]).unwrap(),
])
.sign_with_keys(&f.owner)
.unwrap();
f.state
.db
.insert_event(f.community, &event, Some(f.home))
.await
.unwrap();
}
for filter in [
// Fast COUNT over the reader's accessible channels.
json!([{"kinds":[30621],"#buzz-channel":[f.home]}]),
// Fast COUNT on the explicit-channel path.
json!([{"kinds":[30621],"#h":[f.home],"#buzz-channel":[f.home]}]),
// `#t` is not fully pushable, forcing the per-event fallback on both paths.
json!([{"kinds":[30621],"#t":["proj"],"#buzz-channel":[f.home]}]),
json!([{"kinds":[30621],"#h":[f.home],"#t":["proj"],"#buzz-channel":[f.home]}]),
] {
let (status, body) = f.request("/query", filter.clone()).await;
assert!(status.is_success(), "{filter} {status}: {body}");
assert_eq!(body.as_array().unwrap().len(), 2, "{filter}: {body}");
let (status, body) = f.request("/count", filter.clone()).await;
assert!(status.is_success(), "{filter} {status}: {body}");
assert_eq!(body["count"], 2, "{filter}: {body}");
}
}
#[tokio::test]

View File

@@ -2045,7 +2045,7 @@ async fn count_events_authed(
.await?;
let mut total: u64 = 0;
for filter in &filters {
for (raw, filter) in raw.iter().zip(&filters) {
let needs_author_only_filtering =
crate::handlers::req::filter_can_match_author_only_kinds(filter);
// Same result-gated guard as the WS COUNT handler: force the per-event
@@ -2092,6 +2092,9 @@ async fn count_events_authed(
ch_id,
&accessible_channels,
);
if let Some(channel) = extract_buzz_channel(raw) {
query.custom_tag = Some(("buzz-channel".into(), channel.into()));
}
// Shared-gated visibility pushdown: same as REQ and /query paths, so
// the fallback's query_events call doesn't over-fetch private rows.
if needs_shared_gate_filtering {
@@ -2161,6 +2164,9 @@ async fn count_events_authed(
)
.await;
query.channel_ids = Some(accessible_channels.to_vec());
if let Some(channel) = extract_buzz_channel(raw) {
query.custom_tag = Some(("buzz-channel".into(), channel.into()));
}
// Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on
// the fallback query_events path.
if needs_shared_gate_filtering {

View File

@@ -1097,7 +1097,7 @@ fn filter_to_query_params(
}
});
// Push #e tag filter into SQL via JSONB containment.
// Push #e tag filter into SQL as an exact positional tag match.
let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E);
let e_tags = filter.generic_tags.get(&e_tag_key).and_then(|values| {
if values.is_empty() {