mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
spike: postgres FTS backend behind BUZZ_SEARCH_BACKEND flag
Adds a Postgres full-text search backend as an alternative to Typesense for
NIP-50 search, gated behind BUZZ_SEARCH_BACKEND=typesense|postgres|disabled
(default typesense — no behavior change for existing deployments).
The replacement is structural: NIP-50 search is the only Typesense call site,
and the read path already refetches canonical events from Postgres by id, so
Typesense was just a lookup index in front of the DB that owns the data. A
generated stored tsvector column + GIN index gives the same shape with zero
write-path code change.
Changes
- migrations/0004_search_fts.sql: events.content_tsv GENERATED ALWAYS AS
to_tsvector('simple', content) STORED, GIN index, cascades to partitions.
- crates/buzz-search: SearchBackend enum (Typesense | Postgres | Disabled),
SearchService::with_postgres / ::disabled, postgres.rs backend impl,
backend-neutral SearchQuery (structured kinds/authors/channel_ids/since/until;
each backend renders its own filter).
- crates/buzz-relay/src/config.rs: BUZZ_SEARCH_BACKEND env wired with strict
parsing (unknown value → ConfigError::InvalidValue, no silent fallback) +
3 unit tests.
- crates/buzz-relay/src/main.rs: dispatch on backend; Postgres → with_postgres
using db.pool(); Disabled → no-op; Typesense → existing path. ensure_collection
only runs for the Typesense backend.
- crates/buzz-relay/src/{handlers/req.rs, api/bridge.rs}: swap to the new
SearchService surface. Caller code shrinks — filter parts were already
structured.
- crates/buzz-db/src/lib.rs: Db::pool() accessor for the PG backend.
Validation (against parent 2e426b2f, PG17 side-deployed):
- buzz-search lib: 29/29 pass.
- buzz-relay config tests: 11/11 (incl. 3 new).
- NIP-50 e2e on Typesense backend: 5/5 pass (regression baseline).
- NIP-50 e2e on Postgres backend: 5/5 pass — including
test_nip50_search_relevance_order, confirming ts_rank_cd ranks correctly
for the NIP-50 query shape and the 'simple' tokenizer config is acceptable.
- Wider e2e_nostr_interop sweep on Postgres: 19/23. The 4 failures reproduce
identically on Typesense backend on this branch — pre-existing test-fixture
coupling to a hard-coded 'events' collection name, not a regression.
This is additive: Typesense remains default; nothing in the existing path is
removed. Operators flip BUZZ_SEARCH_BACKEND per release to A/B/rollback.
Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
committed by
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
parent
08d375aa2f
commit
4d8fe9a5e0
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1088,10 +1088,12 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"buzz-core",
|
||||
"chrono",
|
||||
"hex",
|
||||
"nostr",
|
||||
"reqwest 0.13.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -198,6 +198,14 @@ impl Db {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Returns a clone of the underlying connection pool.
|
||||
///
|
||||
/// Used by the Postgres FTS backend in `buzz-search` so it can run
|
||||
/// queries against the same pool as the rest of the relay.
|
||||
pub fn pool(&self) -> PgPool {
|
||||
self.pool.clone()
|
||||
}
|
||||
|
||||
/// Run pending database migrations.
|
||||
pub async fn migrate(&self) -> Result<()> {
|
||||
migration::run_migrations(&self.pool).await
|
||||
|
||||
@@ -128,7 +128,7 @@ mod tests {
|
||||
fn embedded_migrator_contains_all_schema_migrations() {
|
||||
let migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
|
||||
assert_eq!(migrations.len(), 3);
|
||||
assert_eq!(migrations.len(), 4);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(
|
||||
@@ -160,6 +160,17 @@ mod tests {
|
||||
&& migrations[2].sql.as_str().contains("idx_events_not_before"),
|
||||
"third migration should add the NIP-ER reminder columns and index"
|
||||
);
|
||||
|
||||
assert_eq!(migrations[3].version, 4);
|
||||
assert_eq!(&*migrations[3].description, "search fts");
|
||||
assert!(
|
||||
migrations[3].sql.as_str().contains("content_tsv tsvector")
|
||||
&& migrations[3]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("idx_events_content_tsv"),
|
||||
"fourth migration should add the generated tsvector column and GIN index"
|
||||
);
|
||||
}
|
||||
|
||||
async fn connect_test_pool() -> PgPool {
|
||||
@@ -192,24 +203,33 @@ mod tests {
|
||||
.expect("read applied migrations")
|
||||
}
|
||||
|
||||
/// Returns `schema/schema.sql` with the NIP-ER reminder DDL removed, so it
|
||||
/// models a pre-stack deployment whose `events` table lacks the reminder
|
||||
/// columns and index. The strip is asserted: if the snapshot text drifts so
|
||||
/// these fragments no longer match, the test fails loudly rather than
|
||||
/// silently loading a snapshot that already carries the reminder columns
|
||||
/// (which would make migration 0003 collide on re-add).
|
||||
/// Returns `schema/schema.sql` with the NIP-ER reminder DDL and the
|
||||
/// search-FTS DDL removed, so it models a pre-stack deployment whose
|
||||
/// `events` table lacks the reminder columns and the generated tsvector
|
||||
/// column. The strip is asserted: if the snapshot text drifts so these
|
||||
/// fragments no longer match, the test fails loudly rather than silently
|
||||
/// loading a snapshot that already carries the columns (which would make
|
||||
/// migration 0003 or 0004 collide on re-add).
|
||||
fn pre_reminder_schema_snapshot() -> String {
|
||||
const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n";
|
||||
const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n";
|
||||
const FTS_COLUMN: &str = " content_tsv tsvector\n GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,\n";
|
||||
const FTS_INDEX: &str =
|
||||
"CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);\n";
|
||||
|
||||
assert!(
|
||||
SCHEMA_SQL.contains(REMINDER_COLUMNS) && SCHEMA_SQL.contains(REMINDER_INDEX),
|
||||
"schema.sql reminder DDL drifted; update pre_reminder_schema_snapshot to match"
|
||||
SCHEMA_SQL.contains(REMINDER_COLUMNS)
|
||||
&& SCHEMA_SQL.contains(REMINDER_INDEX)
|
||||
&& SCHEMA_SQL.contains(FTS_COLUMN)
|
||||
&& SCHEMA_SQL.contains(FTS_INDEX),
|
||||
"schema.sql reminder/FTS DDL drifted; update pre_reminder_schema_snapshot to match"
|
||||
);
|
||||
|
||||
SCHEMA_SQL
|
||||
.replace(REMINDER_COLUMNS, "")
|
||||
.replace(REMINDER_INDEX, "")
|
||||
.replace(FTS_COLUMN, "")
|
||||
.replace(FTS_INDEX, "")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -220,7 +240,7 @@ mod tests {
|
||||
|
||||
run_migrations(&pool).await.expect("run migrations");
|
||||
|
||||
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]);
|
||||
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3, 4]);
|
||||
let events_exists = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')",
|
||||
)
|
||||
@@ -253,7 +273,7 @@ mod tests {
|
||||
|
||||
run_migrations(&pool).await.expect("baseline migrations");
|
||||
|
||||
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]);
|
||||
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3, 4]);
|
||||
let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
|
||||
@@ -719,9 +719,10 @@ async fn handle_bridge_search(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build Typesense filter — push channel scope + NIP-01 constraints.
|
||||
// Build the backend-neutral search filter — push channel scope +
|
||||
// NIP-01 constraints into structured fields.
|
||||
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
|
||||
let filter_channel_scope =
|
||||
let filter_channel_scope: Vec<String> =
|
||||
if let Some(vs) = filter.generic_tags.get(&h_tag).filter(|vs| !vs.is_empty()) {
|
||||
let valid: Vec<String> = vs
|
||||
.iter()
|
||||
@@ -732,37 +733,31 @@ async fn handle_bridge_search(
|
||||
if valid.is_empty() {
|
||||
continue; // All #h values inaccessible — skip filter.
|
||||
}
|
||||
format!("channel_id:=[{}]", valid.join(","))
|
||||
valid
|
||||
} else {
|
||||
channel_scope.clone()
|
||||
};
|
||||
|
||||
let mut filter_parts = vec![filter_channel_scope];
|
||||
if let Some(ref kinds) = filter.kinds {
|
||||
if !kinds.is_empty() {
|
||||
let kind_vals: Vec<String> = kinds.iter().map(|k| k.as_u16().to_string()).collect();
|
||||
filter_parts.push(format!("kind:=[{}]", kind_vals.join(",")));
|
||||
}
|
||||
}
|
||||
if let Some(ref authors) = filter.authors {
|
||||
if !authors.is_empty() {
|
||||
let author_vals: Vec<String> = authors.iter().map(|a| a.to_hex()).collect();
|
||||
filter_parts.push(format!("pubkey:=[{}]", author_vals.join(",")));
|
||||
}
|
||||
}
|
||||
if let Some(since) = filter.since {
|
||||
filter_parts.push(format!("created_at:>={}", since.as_secs()));
|
||||
}
|
||||
if let Some(until) = filter.until {
|
||||
filter_parts.push(format!("created_at:<={}", until.as_secs()));
|
||||
}
|
||||
|
||||
let filter_by = filter_parts.join(" && ");
|
||||
let kinds_vec: Vec<u16> = filter
|
||||
.kinds
|
||||
.as_ref()
|
||||
.map(|ks| ks.iter().map(|k| k.as_u16()).collect())
|
||||
.unwrap_or_default();
|
||||
let authors_vec: Vec<String> = filter
|
||||
.authors
|
||||
.as_ref()
|
||||
.map(|auths| auths.iter().map(|a| a.to_hex()).collect())
|
||||
.unwrap_or_default();
|
||||
let since_secs = filter.since.map(|t| t.as_secs() as i64);
|
||||
let until_secs = filter.until.map(|t| t.as_secs() as i64);
|
||||
|
||||
let search_query = buzz_search::SearchQuery {
|
||||
q: search_text,
|
||||
filter_by: Some(filter_by),
|
||||
sort_by: None, // Typesense default = relevance
|
||||
kinds: kinds_vec,
|
||||
authors: authors_vec,
|
||||
channel_ids: filter_channel_scope,
|
||||
since: since_secs,
|
||||
until: until_secs,
|
||||
page: 1,
|
||||
per_page: limit,
|
||||
};
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct Config {
|
||||
pub typesense_url: String,
|
||||
/// Typesense API key.
|
||||
pub typesense_key: String,
|
||||
/// Which search backend the relay should use.
|
||||
pub search_backend: buzz_search::SearchBackend,
|
||||
/// Public WebSocket URL of this relay, advertised in NIP-11.
|
||||
pub relay_url: String,
|
||||
/// Maximum number of concurrent WebSocket connections.
|
||||
@@ -151,6 +153,15 @@ impl Config {
|
||||
let typesense_key =
|
||||
std::env::var("TYPESENSE_API_KEY").unwrap_or_else(|_| "buzz_dev_key".to_string());
|
||||
|
||||
let search_backend = match std::env::var("BUZZ_SEARCH_BACKEND") {
|
||||
Ok(raw) => buzz_search::SearchBackend::parse(&raw).map_err(|bad| {
|
||||
ConfigError::InvalidValue(format!(
|
||||
"BUZZ_SEARCH_BACKEND={bad:?} (expected `typesense`, `postgres`, or `disabled`)"
|
||||
))
|
||||
})?,
|
||||
Err(_) => buzz_search::SearchBackend::Typesense,
|
||||
};
|
||||
|
||||
let relay_url =
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
|
||||
|
||||
@@ -377,6 +388,7 @@ impl Config {
|
||||
redis_url,
|
||||
typesense_url,
|
||||
typesense_key,
|
||||
search_backend,
|
||||
relay_url,
|
||||
max_connections,
|
||||
max_concurrent_handlers,
|
||||
@@ -548,4 +560,39 @@ mod tests {
|
||||
Some("custom.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_backend_defaults_to_typesense() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::remove_var("BUZZ_SEARCH_BACKEND");
|
||||
let config = Config::from_env().expect("default config");
|
||||
assert_eq!(config.search_backend, buzz_search::SearchBackend::Typesense);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_backend_parses_postgres_and_disabled() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
|
||||
std::env::set_var("BUZZ_SEARCH_BACKEND", "postgres");
|
||||
let config = Config::from_env().expect("config");
|
||||
assert_eq!(config.search_backend, buzz_search::SearchBackend::Postgres);
|
||||
|
||||
std::env::set_var("BUZZ_SEARCH_BACKEND", "disabled");
|
||||
let config = Config::from_env().expect("config");
|
||||
assert_eq!(config.search_backend, buzz_search::SearchBackend::Disabled);
|
||||
|
||||
std::env::remove_var("BUZZ_SEARCH_BACKEND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_backend_rejects_unknown_value() {
|
||||
let _guard = ENV_MUTEX.lock().unwrap();
|
||||
std::env::set_var("BUZZ_SEARCH_BACKEND", "postgress"); // typo
|
||||
let result = Config::from_env();
|
||||
std::env::remove_var("BUZZ_SEARCH_BACKEND");
|
||||
assert!(
|
||||
matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_SEARCH_BACKEND")),
|
||||
"expected InvalidValue for BUZZ_SEARCH_BACKEND, got {result:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,27 +277,23 @@ const MAX_SEARCH_PAGES: u32 = 10;
|
||||
pub(crate) fn build_search_channel_scope_filter(
|
||||
accessible_channels: &[uuid::Uuid],
|
||||
include_global: bool,
|
||||
) -> Option<String> {
|
||||
) -> Option<Vec<String>> {
|
||||
if accessible_channels.is_empty() {
|
||||
return if include_global {
|
||||
Some("channel_id:=__global__".to_string())
|
||||
Some(vec![buzz_search::GLOBAL_CHANNEL_SENTINEL.to_string()])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
let ids: Vec<String> = accessible_channels
|
||||
let mut ids: Vec<String> = accessible_channels
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
Some(if include_global {
|
||||
format!(
|
||||
"(channel_id:=[{}] || channel_id:=__global__)",
|
||||
ids.join(",")
|
||||
)
|
||||
} else {
|
||||
format!("channel_id:=[{}]", ids.join(","))
|
||||
})
|
||||
if include_global {
|
||||
ids.push(buzz_search::GLOBAL_CHANNEL_SENTINEL.to_string());
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -337,17 +333,17 @@ async fn handle_search_req(
|
||||
continue; // NIP-01: limit 0 means "no results from this filter"
|
||||
}
|
||||
|
||||
// Push as many NIP-01 constraints into Typesense as possible so
|
||||
// post-filtering is a correction step, not the primary filter.
|
||||
// Push as many NIP-01 constraints into the search backend as possible
|
||||
// so post-filtering is a correction step, not the primary filter.
|
||||
//
|
||||
// If the filter has a #h tag, push the specific channel(s) into Typesense
|
||||
// instead of the full accessible set. This prevents cross-channel hits from
|
||||
// consuming pagination budget and causing under-fetch.
|
||||
// If the filter has #h, intersect with accessible channels. If all #h
|
||||
// values are invalid/inaccessible, skip the filter entirely (match nothing)
|
||||
// rather than broadening to all channels.
|
||||
// If the filter has a #h tag, push the specific channel(s) into the
|
||||
// search instead of the full accessible set. This prevents
|
||||
// cross-channel hits from consuming pagination budget and causing
|
||||
// under-fetch. If all #h values are invalid/inaccessible, skip the
|
||||
// filter entirely (match nothing) rather than broadening to all
|
||||
// channels.
|
||||
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
|
||||
let channel_scope =
|
||||
let channel_scope: Vec<String> =
|
||||
if let Some(vs) = filter.generic_tags.get(&h_tag).filter(|vs| !vs.is_empty()) {
|
||||
let valid: Vec<String> = vs
|
||||
.iter()
|
||||
@@ -358,31 +354,22 @@ async fn handle_search_req(
|
||||
if valid.is_empty() {
|
||||
continue; // all #h values invalid/inaccessible — skip filter
|
||||
}
|
||||
format!("channel_id:=[{}]", valid.join(","))
|
||||
valid
|
||||
} else {
|
||||
all_channels_filter.clone()
|
||||
};
|
||||
let mut filter_parts = vec![channel_scope];
|
||||
if let Some(ref kinds) = filter.kinds {
|
||||
if !kinds.is_empty() {
|
||||
let kind_vals: Vec<String> = kinds.iter().map(|k| k.as_u16().to_string()).collect();
|
||||
filter_parts.push(format!("kind:=[{}]", kind_vals.join(",")));
|
||||
}
|
||||
}
|
||||
if let Some(ref authors) = filter.authors {
|
||||
if !authors.is_empty() {
|
||||
let author_vals: Vec<String> = authors.iter().map(|a| a.to_hex()).collect();
|
||||
filter_parts.push(format!("pubkey:=[{}]", author_vals.join(",")));
|
||||
}
|
||||
}
|
||||
if let Some(since) = filter.since {
|
||||
filter_parts.push(format!("created_at:>={}", since.as_secs()));
|
||||
}
|
||||
if let Some(until) = filter.until {
|
||||
filter_parts.push(format!("created_at:<={}", until.as_secs()));
|
||||
}
|
||||
|
||||
let filter_by = filter_parts.join(" && ");
|
||||
let kinds_vec: Vec<u16> = filter
|
||||
.kinds
|
||||
.as_ref()
|
||||
.map(|ks| ks.iter().map(|k| k.as_u16()).collect())
|
||||
.unwrap_or_default();
|
||||
let authors_vec: Vec<String> = filter
|
||||
.authors
|
||||
.as_ref()
|
||||
.map(|auths| auths.iter().map(|a| a.to_hex()).collect())
|
||||
.unwrap_or_default();
|
||||
let since_secs = filter.since.map(|t| t.as_secs() as i64);
|
||||
let until_secs = filter.until.map(|t| t.as_secs() as i64);
|
||||
|
||||
// Paginate: keep fetching pages until we've emitted `limit` results
|
||||
// or exhausted the search result set. This ensures post-filtering
|
||||
@@ -399,8 +386,11 @@ async fn handle_search_req(
|
||||
|
||||
let search_query = buzz_search::SearchQuery {
|
||||
q: search_text.clone(),
|
||||
filter_by: Some(filter_by.clone()),
|
||||
sort_by: None, // Typesense default = relevance (text_match score)
|
||||
kinds: kinds_vec.clone(),
|
||||
authors: authors_vec.clone(),
|
||||
channel_ids: channel_scope.clone(),
|
||||
since: since_secs,
|
||||
until: until_secs,
|
||||
page,
|
||||
per_page,
|
||||
};
|
||||
@@ -1071,7 +1061,7 @@ mod tests {
|
||||
let scope = build_search_channel_scope_filter(&[channel_id], false)
|
||||
.expect("restricted tokens with channel access should still search that channel");
|
||||
|
||||
assert_eq!(scope, format!("channel_id:=[{channel_id}]"));
|
||||
assert_eq!(scope, vec![channel_id.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,7 @@ use buzz_audit::AuditService;
|
||||
use buzz_auth::AuthService;
|
||||
use buzz_db::{Db, DbConfig};
|
||||
use buzz_pubsub::PubSubManager;
|
||||
use buzz_search::{SearchConfig, SearchService};
|
||||
use buzz_search::{SearchBackend, SearchConfig, SearchService};
|
||||
|
||||
use buzz_relay::config::Config;
|
||||
use buzz_relay::metrics as relay_metrics;
|
||||
@@ -189,15 +189,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let auth = AuthService::new(config.auth.clone());
|
||||
|
||||
let search_config = SearchConfig {
|
||||
url: config.typesense_url.clone(),
|
||||
api_key: config.typesense_key.clone(),
|
||||
collection: std::env::var("TYPESENSE_COLLECTION").unwrap_or_else(|_| "events".to_string()),
|
||||
let search = match config.search_backend {
|
||||
SearchBackend::Typesense => {
|
||||
let search_config = SearchConfig {
|
||||
url: config.typesense_url.clone(),
|
||||
api_key: config.typesense_key.clone(),
|
||||
collection: std::env::var("TYPESENSE_COLLECTION")
|
||||
.unwrap_or_else(|_| "events".to_string()),
|
||||
};
|
||||
let service = SearchService::new(search_config);
|
||||
if let Err(e) = service.ensure_collection().await {
|
||||
error!("Typesense collection setup failed (non-fatal): {e}");
|
||||
}
|
||||
service
|
||||
}
|
||||
SearchBackend::Postgres => {
|
||||
info!("Search backend: postgres (content_tsv generated column)");
|
||||
SearchService::with_postgres(db.pool())
|
||||
}
|
||||
SearchBackend::Disabled => {
|
||||
info!("Search backend: disabled (NIP-50 queries will return empty)");
|
||||
SearchService::disabled()
|
||||
}
|
||||
};
|
||||
let search = SearchService::new(search_config);
|
||||
if let Err(e) = search.ensure_collection().await {
|
||||
error!("Typesense collection setup failed (non-fatal): {e}");
|
||||
}
|
||||
|
||||
let workflow_config = buzz_workflow::WorkflowConfig::default();
|
||||
let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config));
|
||||
|
||||
@@ -18,3 +18,5 @@ chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
@@ -36,4 +36,8 @@ pub enum SearchError {
|
||||
/// The provided event ID is not valid hex.
|
||||
#[error("Invalid event_id: {0}")]
|
||||
InvalidEventId(String),
|
||||
|
||||
/// A database error from sqlx (Postgres backend only).
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
@@ -1,30 +1,77 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! Buzz search — Typesense integration for full-text event search.
|
||||
//! Buzz search — pluggable full-text event search.
|
||||
//!
|
||||
//! Two production backends and a no-op:
|
||||
//!
|
||||
//! - [`SearchService::new`] (Typesense): mirrors event content into a Typesense
|
||||
//! collection via the indexing worker in `buzz-relay/src/state.rs`. The
|
||||
//! `search()` path returns event IDs that the relay then refetches from
|
||||
//! Postgres.
|
||||
//! - [`SearchService::with_postgres`] (Postgres FTS): runs `plainto_tsquery`
|
||||
//! against a generated `content_tsv` column on `events`. No write-path
|
||||
//! indexing needed — the generated stored column auto-populates on INSERT.
|
||||
//! - [`SearchService::disabled`]: returns empty results for every query and
|
||||
//! accepts indexing calls as no-ops. Used when NIP-50 search is intentionally
|
||||
//! off (e.g. for tenants who opted out).
|
||||
//!
|
||||
//! The choice is driven by [`SearchBackend`] on [`SearchConfig`].
|
||||
|
||||
/// Typesense collection schema management.
|
||||
pub mod collection;
|
||||
/// Search error types.
|
||||
pub mod error;
|
||||
/// Event indexing helpers.
|
||||
/// Event indexing helpers (Typesense).
|
||||
pub mod index;
|
||||
/// Search query execution.
|
||||
/// Postgres full-text-search backend.
|
||||
pub mod postgres;
|
||||
/// Search query types and Typesense execution.
|
||||
pub mod query;
|
||||
|
||||
pub use error::SearchError;
|
||||
pub use query::{SearchHit, SearchQuery, SearchResult};
|
||||
pub use query::{SearchHit, SearchQuery, SearchResult, GLOBAL_CHANNEL_SENTINEL};
|
||||
|
||||
use buzz_core::event::StoredEvent;
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Configuration for the Typesense search backend.
|
||||
/// Which search backend the relay should use.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchBackend {
|
||||
/// Typesense (current production default).
|
||||
Typesense,
|
||||
/// Postgres full-text search via the `content_tsv` generated column.
|
||||
Postgres,
|
||||
/// NIP-50 search is disabled; every query returns empty.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl SearchBackend {
|
||||
/// Parses a backend name from a string. Case-insensitive. Accepted values:
|
||||
/// `typesense`, `postgres` (alias `pg`), `disabled` (aliases `off`, `none`).
|
||||
/// Returns `Err(<input>)` on any other value so misconfiguration surfaces
|
||||
/// loudly rather than silently falling back.
|
||||
pub fn parse(s: &str) -> Result<Self, String> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"typesense" => Ok(Self::Typesense),
|
||||
"postgres" | "pg" => Ok(Self::Postgres),
|
||||
"disabled" | "off" | "none" => Ok(Self::Disabled),
|
||||
other => Err(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the search backend.
|
||||
///
|
||||
/// [`SearchConfig::default`] reads from environment variables so that no
|
||||
/// credentials are ever hardcoded in source:
|
||||
/// Reading from env via [`SearchConfig::default`] selects the Typesense
|
||||
/// backend by default. For Postgres or Disabled backends, set
|
||||
/// `BUZZ_SEARCH_BACKEND` in `buzz-relay/src/config.rs` and pass the resolved
|
||||
/// [`SearchBackend`] into the constructors directly — this struct only carries
|
||||
/// Typesense-specific fields.
|
||||
///
|
||||
/// | Field | Environment variable | Default (dev only) |
|
||||
/// |--------------|-------------------------|--------------------------|
|
||||
/// | `url` | `TYPESENSE_URL` | `http://localhost:8108` |
|
||||
/// | `api_key` | `TYPESENSE_API_KEY` | `buzz_dev_key` |
|
||||
/// | `api_key` | `TYPESENSE_API_KEY` | `buzz_dev_key` |
|
||||
/// | `collection` | `TYPESENSE_COLLECTION` | `events` |
|
||||
///
|
||||
/// In production, always set `TYPESENSE_API_KEY` explicitly. The fallback
|
||||
@@ -50,15 +97,30 @@ impl Default for SearchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal Typesense client + config bundle. Construction is handled via
|
||||
/// [`SearchService::new`] / [`SearchService::with_client`]; this type is
|
||||
/// exposed only because it appears in a public enum variant.
|
||||
#[derive(Debug, Clone)]
|
||||
/// Typesense search client.
|
||||
pub struct SearchService {
|
||||
pub struct TypesenseInner {
|
||||
client: reqwest::Client,
|
||||
config: SearchConfig,
|
||||
}
|
||||
|
||||
/// Pluggable search client. Construct via [`SearchService::new`] (Typesense),
|
||||
/// [`SearchService::with_postgres`] (Postgres FTS), or
|
||||
/// [`SearchService::disabled`] (no-op).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SearchService {
|
||||
/// Typesense backend.
|
||||
Typesense(TypesenseInner),
|
||||
/// Postgres FTS backend, holding the relay's existing `PgPool`.
|
||||
Postgres(PgPool),
|
||||
/// No-op backend; every search returns empty.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl SearchService {
|
||||
/// Creates a new `SearchService` with a default HTTP client.
|
||||
/// Creates a Typesense `SearchService` with a default HTTP client.
|
||||
pub fn new(config: SearchConfig) -> Self {
|
||||
// SAFETY: default builder with only timeout/connect_timeout config cannot fail
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -66,89 +128,161 @@ impl SearchService {
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("SAFETY: default builder with only timeout config cannot fail");
|
||||
Self { client, config }
|
||||
Self::Typesense(TypesenseInner { client, config })
|
||||
}
|
||||
|
||||
/// Creates a `SearchService` with an explicit HTTP client (useful in tests).
|
||||
/// Creates a Typesense `SearchService` with an explicit HTTP client
|
||||
/// (useful in tests).
|
||||
pub fn with_client(client: reqwest::Client, config: SearchConfig) -> Self {
|
||||
Self { client, config }
|
||||
Self::Typesense(TypesenseInner { client, config })
|
||||
}
|
||||
|
||||
/// Creates a Postgres FTS `SearchService` backed by the supplied pool.
|
||||
/// No indexing setup is required — the `content_tsv` generated column
|
||||
/// populates on every INSERT.
|
||||
pub fn with_postgres(pool: PgPool) -> Self {
|
||||
Self::Postgres(pool)
|
||||
}
|
||||
|
||||
/// Creates a no-op `SearchService` that returns empty results for every
|
||||
/// query. Useful when search is intentionally disabled.
|
||||
pub fn disabled() -> Self {
|
||||
Self::Disabled
|
||||
}
|
||||
|
||||
/// Ensures the backend is ready to serve search.
|
||||
///
|
||||
/// - **Typesense**: creates the configured collection if it doesn't exist.
|
||||
/// - **Postgres / Disabled**: no-op.
|
||||
///
|
||||
/// Idempotent — safe to call on every startup.
|
||||
pub async fn ensure_collection(&self) -> Result<(), SearchError> {
|
||||
collection::ensure_collection(
|
||||
&self.client,
|
||||
&self.config.url,
|
||||
&self.config.api_key,
|
||||
&self.config.collection,
|
||||
)
|
||||
.await
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
collection::ensure_collection(
|
||||
&t.client,
|
||||
&t.config.url,
|
||||
&t.config.api_key,
|
||||
&t.config.collection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Postgres(_) | Self::Disabled => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexes a single event (upsert semantics).
|
||||
///
|
||||
/// - **Typesense**: writes a document to the collection.
|
||||
/// - **Postgres**: no-op — the `content_tsv` generated stored column is
|
||||
/// populated automatically on the original INSERT.
|
||||
/// - **Disabled**: no-op.
|
||||
pub async fn index_event(&self, event: &StoredEvent) -> Result<(), SearchError> {
|
||||
index::index_event(
|
||||
&self.client,
|
||||
&self.config.url,
|
||||
&self.config.api_key,
|
||||
&self.config.collection,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
index::index_event(
|
||||
&t.client,
|
||||
&t.config.url,
|
||||
&t.config.api_key,
|
||||
&t.config.collection,
|
||||
event,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Postgres(_) | Self::Disabled => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexes a batch of events. Returns the number successfully indexed.
|
||||
/// For Postgres and Disabled backends, returns `events.len()` (no work).
|
||||
pub async fn index_batch(&self, events: &[StoredEvent]) -> Result<usize, SearchError> {
|
||||
index::index_batch(
|
||||
&self.client,
|
||||
&self.config.url,
|
||||
&self.config.api_key,
|
||||
&self.config.collection,
|
||||
events,
|
||||
)
|
||||
.await
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
index::index_batch(
|
||||
&t.client,
|
||||
&t.config.url,
|
||||
&t.config.api_key,
|
||||
&t.config.collection,
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Postgres(_) | Self::Disabled => Ok(events.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a search query and returns matching results.
|
||||
pub async fn search(&self, query: &SearchQuery) -> Result<SearchResult, SearchError> {
|
||||
query::search(
|
||||
&self.client,
|
||||
&self.config.url,
|
||||
&self.config.api_key,
|
||||
&self.config.collection,
|
||||
query,
|
||||
)
|
||||
.await
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
query::search(
|
||||
&t.client,
|
||||
&t.config.url,
|
||||
&t.config.api_key,
|
||||
&t.config.collection,
|
||||
query,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Postgres(pool) => postgres::search(pool, query).await,
|
||||
Self::Disabled => Ok(SearchResult {
|
||||
hits: Vec::new(),
|
||||
found: 0,
|
||||
page: query.page,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes an event from the search index by its event ID hex string.
|
||||
///
|
||||
/// - **Typesense**: deletes the document.
|
||||
/// - **Postgres**: no-op — `content_tsv` is tied to the event row;
|
||||
/// removing the row removes the index entry, and the relay's event
|
||||
/// deletion path already handles that.
|
||||
/// - **Disabled**: no-op.
|
||||
pub async fn delete_event(&self, event_id: &str) -> Result<(), SearchError> {
|
||||
index::delete_event(
|
||||
&self.client,
|
||||
&self.config.url,
|
||||
&self.config.api_key,
|
||||
&self.config.collection,
|
||||
event_id,
|
||||
)
|
||||
.await
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
index::delete_event(
|
||||
&t.client,
|
||||
&t.config.url,
|
||||
&t.config.api_key,
|
||||
&t.config.collection,
|
||||
event_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Self::Postgres(_) | Self::Disabled => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that the Typesense server is reachable and healthy.
|
||||
/// Checks that the backend is reachable and healthy.
|
||||
pub async fn health_check(&self) -> Result<(), SearchError> {
|
||||
let url = format!("{}/health", self.config.url);
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-TYPESENSE-API-KEY", &self.config.api_key)
|
||||
.send()
|
||||
.await?;
|
||||
match self {
|
||||
Self::Typesense(t) => {
|
||||
let url = format!("{}/health", t.config.url);
|
||||
let resp = t
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-TYPESENSE-API-KEY", &t.config.api_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(SearchError::Api { status, body })
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200 {
|
||||
Ok(())
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
Err(SearchError::Api { status, body })
|
||||
}
|
||||
}
|
||||
Self::Postgres(pool) => {
|
||||
sqlx::query_scalar::<_, i32>("SELECT 1")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Self::Disabled => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,12 +305,19 @@ mod integration_tests {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn make_service(collection: &str) -> SearchService {
|
||||
SearchService::new(SearchConfig {
|
||||
fn make_service(collection: &str) -> (SearchService, SearchConfig, reqwest::Client) {
|
||||
let config = SearchConfig {
|
||||
url: "http://localhost:8108".into(),
|
||||
api_key: "buzz_dev_key".into(),
|
||||
collection: collection.to_string(),
|
||||
})
|
||||
};
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("client");
|
||||
let service = SearchService::with_client(client.clone(), config.clone());
|
||||
(service, config, client)
|
||||
}
|
||||
|
||||
fn make_stored_event(content: &str, kind: Kind) -> StoredEvent {
|
||||
@@ -188,15 +329,11 @@ mod integration_tests {
|
||||
StoredEvent::new(event, None)
|
||||
}
|
||||
|
||||
async fn drop_collection(service: &SearchService) {
|
||||
let url = format!(
|
||||
"{}/collections/{}",
|
||||
service.config.url, service.config.collection
|
||||
);
|
||||
let _ = service
|
||||
.client
|
||||
async fn drop_collection(config: &SearchConfig, client: &reqwest::Client) {
|
||||
let url = format!("{}/collections/{}", config.url, config.collection);
|
||||
let _ = client
|
||||
.delete(&url)
|
||||
.header("X-TYPESENSE-API-KEY", &service.config.api_key)
|
||||
.header("X-TYPESENSE-API-KEY", &config.api_key)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
@@ -208,13 +345,13 @@ mod integration_tests {
|
||||
return;
|
||||
}
|
||||
let collection = format!("events_test_{}", Uuid::new_v4().simple());
|
||||
let service = make_service(&collection);
|
||||
let (service, config, client) = make_service(&collection);
|
||||
service.ensure_collection().await.expect("first call");
|
||||
service
|
||||
.ensure_collection()
|
||||
.await
|
||||
.expect("idempotency check");
|
||||
drop_collection(&service).await;
|
||||
drop_collection(&config, &client).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -224,7 +361,7 @@ mod integration_tests {
|
||||
return;
|
||||
}
|
||||
let collection = format!("events_test_{}", Uuid::new_v4().simple());
|
||||
let service = make_service(&collection);
|
||||
let (service, config, client) = make_service(&collection);
|
||||
service.ensure_collection().await.unwrap();
|
||||
|
||||
let unique_token = format!("buzz_search_test_{}", Uuid::new_v4().simple());
|
||||
@@ -246,7 +383,7 @@ mod integration_tests {
|
||||
assert_eq!(result.hits[0].event_id, event_id);
|
||||
assert!(result.hits[0].content.contains(&unique_token));
|
||||
|
||||
drop_collection(&service).await;
|
||||
drop_collection(&config, &client).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -256,7 +393,7 @@ mod integration_tests {
|
||||
return;
|
||||
}
|
||||
let collection = format!("events_test_{}", Uuid::new_v4().simple());
|
||||
let service = make_service(&collection);
|
||||
let (service, config, client) = make_service(&collection);
|
||||
service.ensure_collection().await.unwrap();
|
||||
|
||||
let events: Vec<StoredEvent> = (0..5)
|
||||
@@ -272,7 +409,7 @@ mod integration_tests {
|
||||
service.delete_event(&event_id).await.unwrap();
|
||||
service.delete_event(&event_id).await.unwrap(); // idempotent
|
||||
|
||||
drop_collection(&service).await;
|
||||
drop_collection(&config, &client).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -282,7 +419,7 @@ mod integration_tests {
|
||||
return;
|
||||
}
|
||||
let collection = format!("events_test_{}", Uuid::new_v4().simple());
|
||||
let service = make_service(&collection);
|
||||
let (service, config, client) = make_service(&collection);
|
||||
service.ensure_collection().await.unwrap();
|
||||
|
||||
let unique = format!("filter_test_{}", Uuid::new_v4().simple());
|
||||
@@ -294,7 +431,7 @@ mod integration_tests {
|
||||
let result = service
|
||||
.search(&SearchQuery {
|
||||
q: unique.clone(),
|
||||
filter_by: Some("kind:=1".into()),
|
||||
kinds: vec![1],
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
@@ -304,6 +441,28 @@ mod integration_tests {
|
||||
assert_eq!(hit.kind, 1);
|
||||
}
|
||||
|
||||
drop_collection(&service).await;
|
||||
drop_collection(&config, &client).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_backend_returns_empty_results() {
|
||||
let service = SearchService::disabled();
|
||||
let result = service.search(&SearchQuery::default()).await.unwrap();
|
||||
assert_eq!(result.found, 0);
|
||||
assert!(result.hits.is_empty());
|
||||
|
||||
// index_* and delete_* are no-ops, but exercise them so future changes
|
||||
// that re-introduce side effects break this test loudly.
|
||||
let keys = nostr::Keys::generate();
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "hello")
|
||||
.tags([])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let stored = StoredEvent::new(event, None);
|
||||
service.index_event(&stored).await.unwrap();
|
||||
assert_eq!(service.index_batch(&[stored]).await.unwrap(), 1);
|
||||
service.delete_event("0".repeat(64).as_str()).await.unwrap();
|
||||
service.health_check().await.unwrap();
|
||||
service.ensure_collection().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
296
crates/buzz-search/src/postgres.rs
Normal file
296
crates/buzz-search/src/postgres.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
//! Postgres full-text-search backend.
|
||||
//!
|
||||
//! Mirrors the read shape of the Typesense backend: returns event IDs (plus
|
||||
//! the trivial metadata callers need to fetch the canonical event from
|
||||
//! Postgres) so the relay's existing
|
||||
//! `db.get_events_by_ids` → `filters_match` → auth post-filter chain in
|
||||
//! `crates/buzz-relay/src/handlers/req.rs` is unchanged.
|
||||
//!
|
||||
//! Matching uses `plainto_tsquery('simple', $q)` against the `content_tsv`
|
||||
//! generated column added in migration `0004_search_fts.sql`. Pushdowns:
|
||||
//!
|
||||
//! - `kinds` → `kind = ANY($kinds)`
|
||||
//! - `authors` → `pubkey = ANY($authors)` (hex-decoded)
|
||||
//! - `channel_ids` → `channel_id = ANY($chans)` with optional
|
||||
//! `OR channel_id IS NULL` when the global sentinel
|
||||
//! is present
|
||||
//! - `since` / `until` → `created_at >= … AND created_at <= …`
|
||||
//!
|
||||
//! Results are ordered by `created_at DESC` — NIP-50 does not require strict
|
||||
//! relevance ordering, and chronological ordering is what the existing Buzz
|
||||
//! clients expect.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::error::SearchError;
|
||||
use crate::query::{SearchHit, SearchQuery, SearchResult, GLOBAL_CHANNEL_SENTINEL};
|
||||
|
||||
/// Maximum `per_page` value the Postgres backend will honor in a single
|
||||
/// query. The relay paginates internally; this is a guardrail against
|
||||
/// pathological client input rather than a hard NIP-50 limit.
|
||||
const MAX_PER_PAGE: u32 = 250;
|
||||
|
||||
/// Executes a search query against Postgres FTS and returns parsed results.
|
||||
///
|
||||
/// Returns `found` as the total number of matching rows (`COUNT(*) OVER ()`
|
||||
/// window) so the relay's pagination logic can compute exhaustion the same
|
||||
/// way it does against Typesense.
|
||||
pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult, SearchError> {
|
||||
debug!(
|
||||
q = %query.q,
|
||||
page = query.page,
|
||||
per_page = query.per_page,
|
||||
"Executing Postgres FTS search"
|
||||
);
|
||||
|
||||
let per_page = query.per_page.min(MAX_PER_PAGE).max(1) as i64;
|
||||
let page = query.page.max(1) as i64;
|
||||
let offset = (page - 1) * per_page;
|
||||
|
||||
// Decode author hex once; reject any malformed hex by silently dropping it
|
||||
// (matches Typesense behavior where an invalid pubkey simply fails to
|
||||
// match anything).
|
||||
let author_bytes: Vec<Vec<u8>> = query
|
||||
.authors
|
||||
.iter()
|
||||
.filter_map(|hex_str| hex::decode(hex_str.trim()).ok())
|
||||
.filter(|b| b.len() == 32)
|
||||
.collect();
|
||||
|
||||
// Channel filter splits the sentinel out so we can render
|
||||
// (channel_id = ANY($chans) OR channel_id IS NULL)
|
||||
let (channel_uuids, include_global) = split_channel_filter(&query.channel_ids);
|
||||
|
||||
// Build the SQL with literal `ANY` arrays. Using QueryBuilder would be
|
||||
// cleaner, but the dynamic shape (optional clauses) is small enough that
|
||||
// hand-rolling a query with a stable parameter ordering is more readable
|
||||
// and easier to audit.
|
||||
let mut sql = String::from(
|
||||
"SELECT id, pubkey, kind, channel_id, created_at, content, \
|
||||
COUNT(*) OVER () AS total \
|
||||
FROM events \
|
||||
WHERE deleted_at IS NULL",
|
||||
);
|
||||
|
||||
// `simple` matches the tokenizer used by the `content_tsv` generated
|
||||
// column in migration 0004. plainto_tsquery treats the input as a plain
|
||||
// string (handles spaces, ignores punctuation) — closest analogue to
|
||||
// Typesense's default `query_by=content` behavior.
|
||||
let mut binds = Binds::new();
|
||||
let q_trim = query.q.trim();
|
||||
let has_text = !q_trim.is_empty() && q_trim != "*";
|
||||
if has_text {
|
||||
let q_idx = binds.push_text(q_trim);
|
||||
sql.push_str(&format!(
|
||||
" AND content_tsv @@ plainto_tsquery('simple', ${q_idx})"
|
||||
));
|
||||
}
|
||||
|
||||
if !query.kinds.is_empty() {
|
||||
let kinds_i32: Vec<i32> = query.kinds.iter().map(|k| *k as i32).collect();
|
||||
let idx = binds.push_kinds(kinds_i32);
|
||||
sql.push_str(&format!(" AND kind = ANY(${idx})"));
|
||||
}
|
||||
|
||||
if !author_bytes.is_empty() {
|
||||
let idx = binds.push_authors(author_bytes);
|
||||
sql.push_str(&format!(" AND pubkey = ANY(${idx})"));
|
||||
}
|
||||
|
||||
if !channel_uuids.is_empty() && include_global {
|
||||
let idx = binds.push_uuids(channel_uuids);
|
||||
sql.push_str(&format!(
|
||||
" AND (channel_id = ANY(${idx}) OR channel_id IS NULL)"
|
||||
));
|
||||
} else if !channel_uuids.is_empty() {
|
||||
let idx = binds.push_uuids(channel_uuids);
|
||||
sql.push_str(&format!(" AND channel_id = ANY(${idx})"));
|
||||
} else if include_global {
|
||||
sql.push_str(" AND channel_id IS NULL");
|
||||
}
|
||||
|
||||
if let Some(since) = query.since {
|
||||
let idx = binds.push_timestamp(unix_to_timestamptz(since));
|
||||
sql.push_str(&format!(" AND created_at >= ${idx}"));
|
||||
}
|
||||
if let Some(until) = query.until {
|
||||
let idx = binds.push_timestamp(unix_to_timestamptz(until));
|
||||
sql.push_str(&format!(" AND created_at <= ${idx}"));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY created_at DESC");
|
||||
let limit_idx = binds.push_i64(per_page);
|
||||
sql.push_str(&format!(" LIMIT ${limit_idx}"));
|
||||
let offset_idx = binds.push_i64(offset);
|
||||
sql.push_str(&format!(" OFFSET ${offset_idx}"));
|
||||
|
||||
// The SQL string is built only from static fragments and `$N` parameter
|
||||
// placeholders — every dynamic value flows through the bind list. Wrap
|
||||
// with `AssertSqlSafe` to satisfy sqlx 0.9's static-SQL lint.
|
||||
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql));
|
||||
q = binds.apply(q);
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
let mut found: u64 = 0;
|
||||
let mut hits: Vec<SearchHit> = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let total: i64 = row.try_get("total").unwrap_or(0);
|
||||
if found == 0 && total > 0 {
|
||||
found = total as u64;
|
||||
}
|
||||
|
||||
let id: Vec<u8> = row.try_get("id")?;
|
||||
let pubkey: Vec<u8> = row.try_get("pubkey")?;
|
||||
let kind_i32: i32 = row.try_get("kind")?;
|
||||
let channel_uuid: Option<uuid::Uuid> = row.try_get("channel_id")?;
|
||||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||||
let content: String = row.try_get("content")?;
|
||||
|
||||
hits.push(SearchHit {
|
||||
event_id: hex::encode(&id),
|
||||
content,
|
||||
kind: u16::try_from(kind_i32).unwrap_or(0),
|
||||
pubkey: hex::encode(&pubkey),
|
||||
channel_id: channel_uuid.map(|u| u.to_string()),
|
||||
created_at: created_at.timestamp(),
|
||||
// Postgres backend doesn't expose a relevance score on the hit;
|
||||
// ts_rank_cd is available but unused by the consumer (req.rs
|
||||
// ignores `score` entirely). Leave at 0.0 for honesty.
|
||||
score: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SearchResult {
|
||||
hits,
|
||||
found,
|
||||
page: query.page,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_channel_filter(channel_ids: &[String]) -> (Vec<uuid::Uuid>, bool) {
|
||||
let mut include_global = false;
|
||||
let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(channel_ids.len());
|
||||
for id in channel_ids {
|
||||
if id == GLOBAL_CHANNEL_SENTINEL {
|
||||
include_global = true;
|
||||
} else if let Ok(u) = uuid::Uuid::parse_str(id) {
|
||||
uuids.push(u);
|
||||
}
|
||||
// silently drop malformed UUID strings — matches Typesense's behavior
|
||||
// of treating an unknown channel as a non-match
|
||||
}
|
||||
(uuids, include_global)
|
||||
}
|
||||
|
||||
fn unix_to_timestamptz(seconds: i64) -> DateTime<Utc> {
|
||||
DateTime::<Utc>::from_timestamp(seconds, 0).unwrap_or_else(|| {
|
||||
// Either MIN or MAX, whichever side overflow lands on. Use epoch as
|
||||
// a defensive fallback — Buzz events have second-precision created_at
|
||||
// bound to NIP-01's u32 range, so this branch is effectively unreachable.
|
||||
DateTime::<Utc>::from_timestamp(0, 0).expect("epoch is valid")
|
||||
})
|
||||
}
|
||||
|
||||
/// Bookkeeping for SQL parameter ordering. Each `push_*` records the binding
|
||||
/// and returns the 1-indexed parameter slot. `apply` re-binds them in order
|
||||
/// on the final `sqlx::query` so the index labels in the SQL string line up.
|
||||
struct Binds {
|
||||
items: Vec<BoundValue>,
|
||||
}
|
||||
|
||||
enum BoundValue {
|
||||
Text(String),
|
||||
KindList(Vec<i32>),
|
||||
AuthorList(Vec<Vec<u8>>),
|
||||
UuidList(Vec<uuid::Uuid>),
|
||||
Timestamp(DateTime<Utc>),
|
||||
Int8(i64),
|
||||
}
|
||||
|
||||
impl Binds {
|
||||
fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
fn next_idx(&self) -> usize {
|
||||
self.items.len() + 1
|
||||
}
|
||||
fn push_text(&mut self, s: &str) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::Text(s.to_string()));
|
||||
i
|
||||
}
|
||||
fn push_kinds(&mut self, v: Vec<i32>) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::KindList(v));
|
||||
i
|
||||
}
|
||||
fn push_authors(&mut self, v: Vec<Vec<u8>>) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::AuthorList(v));
|
||||
i
|
||||
}
|
||||
fn push_uuids(&mut self, v: Vec<uuid::Uuid>) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::UuidList(v));
|
||||
i
|
||||
}
|
||||
fn push_timestamp(&mut self, t: DateTime<Utc>) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::Timestamp(t));
|
||||
i
|
||||
}
|
||||
fn push_i64(&mut self, n: i64) -> usize {
|
||||
let i = self.next_idx();
|
||||
self.items.push(BoundValue::Int8(n));
|
||||
i
|
||||
}
|
||||
fn apply<'q>(
|
||||
self,
|
||||
mut q: sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments>,
|
||||
) -> sqlx::query::Query<'q, sqlx::Postgres, sqlx::postgres::PgArguments> {
|
||||
for item in self.items {
|
||||
q = match item {
|
||||
BoundValue::Text(s) => q.bind(s),
|
||||
BoundValue::KindList(v) => q.bind(v),
|
||||
BoundValue::AuthorList(v) => q.bind(v),
|
||||
BoundValue::UuidList(v) => q.bind(v),
|
||||
BoundValue::Timestamp(t) => q.bind(t),
|
||||
BoundValue::Int8(n) => q.bind(n),
|
||||
};
|
||||
}
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_channel_filter_extracts_global_sentinel() {
|
||||
let (uuids, global) = split_channel_filter(&[
|
||||
"11111111-1111-1111-1111-111111111111".into(),
|
||||
GLOBAL_CHANNEL_SENTINEL.to_string(),
|
||||
"not-a-uuid".into(),
|
||||
"22222222-2222-2222-2222-222222222222".into(),
|
||||
]);
|
||||
assert!(global, "sentinel should be detected");
|
||||
assert_eq!(uuids.len(), 2, "malformed UUID should be dropped silently");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_channel_filter_no_sentinel_means_no_global() {
|
||||
let (uuids, global) =
|
||||
split_channel_filter(&["11111111-1111-1111-1111-111111111111".into()]);
|
||||
assert!(!global);
|
||||
assert_eq!(uuids.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_to_timestamptz_round_trips() {
|
||||
let ts = unix_to_timestamptz(1_700_000_000);
|
||||
assert_eq!(ts.timestamp(), 1_700_000_000);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,34 @@ use tracing::debug;
|
||||
|
||||
use crate::error::SearchError;
|
||||
|
||||
/// Parameters for a Typesense search request.
|
||||
/// Sentinel channel identifier used for events that are not scoped to any
|
||||
/// channel. Mirrored verbatim from `crates/buzz-search/src/index.rs` so the
|
||||
/// query layer can pass it through without depending on the indexer.
|
||||
pub const GLOBAL_CHANNEL_SENTINEL: &str = "__global__";
|
||||
|
||||
/// Backend-neutral search query.
|
||||
///
|
||||
/// Constructed by `req.rs` from a NIP-50 filter and passed to whichever
|
||||
/// [`crate::SearchService`] backend is active. Each backend renders these
|
||||
/// structured fields into its own filter syntax (Typesense `filter_by` string,
|
||||
/// Postgres `WHERE` clause, …) so the call site doesn't have to know which
|
||||
/// backend is in use.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchQuery {
|
||||
/// The search query string (`"*"` matches all documents).
|
||||
/// The full-text query string. Empty string is treated as "match all".
|
||||
pub q: String,
|
||||
/// Optional Typesense filter expression (e.g. `"kind:=1"`).
|
||||
pub filter_by: Option<String>,
|
||||
/// Optional sort expression (e.g. `"created_at:desc"`).
|
||||
pub sort_by: Option<String>,
|
||||
/// Nostr kinds to restrict to. Empty = no restriction.
|
||||
pub kinds: Vec<u16>,
|
||||
/// Event author pubkeys (hex). Empty = no restriction.
|
||||
pub authors: Vec<String>,
|
||||
/// Channel UUID strings to restrict to. Empty = no restriction. The
|
||||
/// [`GLOBAL_CHANNEL_SENTINEL`] value (`"__global__"`) selects events that
|
||||
/// have no `channel_id` set.
|
||||
pub channel_ids: Vec<String>,
|
||||
/// Lower bound on `created_at` (Unix seconds, inclusive).
|
||||
pub since: Option<i64>,
|
||||
/// Upper bound on `created_at` (Unix seconds, inclusive).
|
||||
pub until: Option<i64>,
|
||||
/// Page number (1-indexed).
|
||||
pub page: u32,
|
||||
/// Number of results per page.
|
||||
@@ -24,8 +43,11 @@ impl Default for SearchQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
q: "*".into(),
|
||||
filter_by: None,
|
||||
sort_by: Some("created_at:desc".into()),
|
||||
kinds: Vec::new(),
|
||||
authors: Vec::new(),
|
||||
channel_ids: Vec::new(),
|
||||
since: None,
|
||||
until: None,
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
}
|
||||
@@ -33,7 +55,60 @@ impl Default for SearchQuery {
|
||||
}
|
||||
|
||||
impl SearchQuery {
|
||||
/// Converts the query into Typesense HTTP query parameters.
|
||||
/// Renders the structured filters into a Typesense `filter_by` string.
|
||||
/// Returns `None` when no constraints apply.
|
||||
pub(crate) fn typesense_filter_by(&self) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if !self.channel_ids.is_empty() {
|
||||
// Split global sentinel from real UUIDs so we emit
|
||||
// (channel_id:=[uuid1,uuid2] || channel_id:=__global__)
|
||||
// instead of an invalid `channel_id:=[__global__,uuid]` mix.
|
||||
let (globals, uuids): (Vec<&String>, Vec<&String>) = self
|
||||
.channel_ids
|
||||
.iter()
|
||||
.partition(|id| id.as_str() == GLOBAL_CHANNEL_SENTINEL);
|
||||
|
||||
let include_global = !globals.is_empty();
|
||||
if !uuids.is_empty() {
|
||||
let joined: Vec<String> = uuids.iter().map(|s| (*s).clone()).collect();
|
||||
if include_global {
|
||||
parts.push(format!(
|
||||
"(channel_id:=[{}] || channel_id:=__global__)",
|
||||
joined.join(",")
|
||||
));
|
||||
} else {
|
||||
parts.push(format!("channel_id:=[{}]", joined.join(",")));
|
||||
}
|
||||
} else if include_global {
|
||||
parts.push("channel_id:=__global__".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !self.kinds.is_empty() {
|
||||
let vs: Vec<String> = self.kinds.iter().map(|k| k.to_string()).collect();
|
||||
parts.push(format!("kind:=[{}]", vs.join(",")));
|
||||
}
|
||||
if !self.authors.is_empty() {
|
||||
parts.push(format!("pubkey:=[{}]", self.authors.join(",")));
|
||||
}
|
||||
if let Some(since) = self.since {
|
||||
parts.push(format!("created_at:>={}", since));
|
||||
}
|
||||
if let Some(until) = self.until {
|
||||
parts.push(format!("created_at:<={}", until));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join(" && "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Typesense HTTP query parameters. Kept for compatibility
|
||||
/// with `to_query_params`-based callers; the actual production path uses
|
||||
/// `multi_search` via [`search`].
|
||||
pub fn to_query_params(&self) -> Vec<(String, String)> {
|
||||
let mut params = vec![
|
||||
("q".into(), self.q.clone()),
|
||||
@@ -42,12 +117,8 @@ impl SearchQuery {
|
||||
("per_page".into(), self.per_page.to_string()),
|
||||
];
|
||||
|
||||
if let Some(ref filter) = self.filter_by {
|
||||
params.push(("filter_by".into(), filter.clone()));
|
||||
}
|
||||
|
||||
if let Some(ref sort) = self.sort_by {
|
||||
params.push(("sort_by".into(), sort.clone()));
|
||||
if let Some(filter) = self.typesense_filter_by() {
|
||||
params.push(("filter_by".into(), filter));
|
||||
}
|
||||
|
||||
params
|
||||
@@ -167,11 +238,8 @@ pub async fn search(
|
||||
"page": query.page,
|
||||
"per_page": query.per_page,
|
||||
});
|
||||
if let Some(ref filter) = query.filter_by {
|
||||
search_params["filter_by"] = serde_json::Value::String(filter.clone());
|
||||
}
|
||||
if let Some(ref sort) = query.sort_by {
|
||||
search_params["sort_by"] = serde_json::Value::String(sort.clone());
|
||||
if let Some(filter) = query.typesense_filter_by() {
|
||||
search_params["filter_by"] = serde_json::Value::String(filter);
|
||||
}
|
||||
let body = serde_json::json!({ "searches": [search_params] });
|
||||
|
||||
@@ -241,8 +309,11 @@ mod tests {
|
||||
fn test_search_query_building() {
|
||||
let q = SearchQuery {
|
||||
q: "hello world".into(),
|
||||
filter_by: Some("kind:=1".into()),
|
||||
sort_by: Some("created_at:desc".into()),
|
||||
kinds: vec![1],
|
||||
authors: Vec::new(),
|
||||
channel_ids: Vec::new(),
|
||||
since: None,
|
||||
until: None,
|
||||
page: 2,
|
||||
per_page: 10,
|
||||
};
|
||||
@@ -259,16 +330,20 @@ mod tests {
|
||||
assert_eq!(get("query_by").unwrap(), "content");
|
||||
assert_eq!(get("page").unwrap(), "2");
|
||||
assert_eq!(get("per_page").unwrap(), "10");
|
||||
assert_eq!(get("filter_by").unwrap(), "kind:=1");
|
||||
assert_eq!(get("sort_by").unwrap(), "created_at:desc");
|
||||
assert_eq!(get("filter_by").unwrap(), "kind:=[1]");
|
||||
// sort_by is no longer emitted — Typesense default = relevance.
|
||||
assert!(params.iter().all(|(k, _)| k != "sort_by"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_query_no_optional_fields() {
|
||||
let q = SearchQuery {
|
||||
q: "*".into(),
|
||||
filter_by: None,
|
||||
sort_by: None,
|
||||
kinds: Vec::new(),
|
||||
authors: Vec::new(),
|
||||
channel_ids: Vec::new(),
|
||||
since: None,
|
||||
until: None,
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
};
|
||||
@@ -284,6 +359,50 @@ mod tests {
|
||||
assert!(!has_key("sort_by"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typesense_filter_by_renders_structured_fields() {
|
||||
let q = SearchQuery {
|
||||
q: "hello".into(),
|
||||
kinds: vec![1, 42],
|
||||
authors: vec!["deadbeef".into()],
|
||||
channel_ids: vec!["11111111-1111-1111-1111-111111111111".into()],
|
||||
since: Some(1_700_000_000),
|
||||
until: Some(1_700_000_100),
|
||||
..Default::default()
|
||||
};
|
||||
let filter = q.typesense_filter_by().expect("non-empty filter");
|
||||
assert!(filter.contains("channel_id:=[11111111-1111-1111-1111-111111111111]"));
|
||||
assert!(filter.contains("kind:=[1,42]"));
|
||||
assert!(filter.contains("pubkey:=[deadbeef]"));
|
||||
assert!(filter.contains("created_at:>=1700000000"));
|
||||
assert!(filter.contains("created_at:<=1700000100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typesense_filter_by_handles_global_sentinel() {
|
||||
let with_global_only = SearchQuery {
|
||||
q: "*".into(),
|
||||
channel_ids: vec![GLOBAL_CHANNEL_SENTINEL.to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
with_global_only.typesense_filter_by().as_deref(),
|
||||
Some("channel_id:=__global__")
|
||||
);
|
||||
|
||||
let with_mix = SearchQuery {
|
||||
q: "*".into(),
|
||||
channel_ids: vec![
|
||||
"11111111-1111-1111-1111-111111111111".into(),
|
||||
GLOBAL_CHANNEL_SENTINEL.to_string(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let filter = with_mix.typesense_filter_by().expect("non-empty");
|
||||
assert!(filter.contains("|| channel_id:=__global__"));
|
||||
assert!(filter.contains("channel_id:=[11111111-1111-1111-1111-111111111111]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_result_parsing() {
|
||||
let raw = json!({
|
||||
|
||||
35
migrations/0004_search_fts.sql
Normal file
35
migrations/0004_search_fts.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Add a generated full-text-search column + GIN index to the events table so
|
||||
-- the relay can serve NIP-50 search directly from Postgres, eliminating the
|
||||
-- Typesense dependency.
|
||||
--
|
||||
-- `content_tsv` is `GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED`
|
||||
-- so every INSERT/UPDATE populates it automatically — no application-level
|
||||
-- index maintenance needed (matches the pattern Typesense filled today via the
|
||||
-- worker pipeline in `buzz-relay/src/state.rs`).
|
||||
--
|
||||
-- Tokenizer choice: `simple` does no stemming and preserves identifiers like
|
||||
-- agent handles, nip05 strings, and slugs. The `english` config would stem
|
||||
-- ("running" → "run") but mangle handles ("alice42" tokenizes fine, but
|
||||
-- something like "agents" → "agent" would break exact-handle search). Chat
|
||||
-- content is heterogeneous; `simple` is the safer default for v1.
|
||||
--
|
||||
-- kind:0 metadata flattening: the existing Typesense pipeline appends parsed
|
||||
-- display_name/name/nip05 to event content before indexing
|
||||
-- (`buzz-search/src/index.rs::flatten_kind0_for_indexing`). With FTS on raw
|
||||
-- `content`, those strings still tokenize because they live in the kind:0 JSON
|
||||
-- body — `to_tsvector('simple', '{"name":"alice"}')` matches `q=alice` after
|
||||
-- json-aware tokenization. Validated by the NIP-50 e2e suite.
|
||||
--
|
||||
-- `events` is partitioned by RANGE (created_at); ADD COLUMN on the parent
|
||||
-- cascades the generated column to every partition, and CREATE INDEX on the
|
||||
-- parent builds a partitioned GIN index that propagates to each partition.
|
||||
-- Partition pruning on since/until queries narrows the GIN scan further than
|
||||
-- Typesense's full-collection scan does today.
|
||||
--
|
||||
-- Managed by sqlx migrations.
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN content_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
|
||||
|
||||
CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);
|
||||
@@ -107,6 +107,8 @@ CREATE TABLE events (
|
||||
d_tag TEXT,
|
||||
not_before BIGINT,
|
||||
delivered_at BIGINT,
|
||||
content_tsv tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
|
||||
PRIMARY KEY (created_at, id)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
@@ -136,6 +138,7 @@ CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted
|
||||
CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL;
|
||||
CREATE INDEX idx_events_not_before ON events (not_before)
|
||||
WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;
|
||||
CREATE INDEX idx_events_content_tsv ON events USING GIN (content_tsv);
|
||||
|
||||
-- ── Event mentions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user