feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "buzz-search"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Postgres full-text search for Buzz, scoped by community"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
@@ -0,0 +1,9 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by the FTS service.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SearchError {
|
||||
/// A database error from sqlx.
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
//! Buzz search — community-scoped Postgres full-text search over Buzz events.
|
||||
//!
|
||||
//! The index lives in the `events` table: `search_tsv TSVECTOR GENERATED
|
||||
//! ALWAYS AS (to_tsvector('simple', content)) STORED`, with `GIN
|
||||
//! (search_tsv)` as the access path. Because the column is `GENERATED ALWAYS`,
|
||||
//! every row write *is* the index update — there is no separate indexer, no
|
||||
//! mpsc queue, no reindex job, no consistency window to reason about. A
|
||||
//! client cannot forge the tsvector out of sync with the content it signed.
|
||||
//!
|
||||
//! This crate is the **query** side. Indexing is the SQL row insert — owned
|
||||
//! by `buzz-db`. The relay refetches canonical events through `buzz-db`'s
|
||||
//! scoped fetcher and runs access checks per hit; search is never the access
|
||||
//! boundary (conformance row 50).
|
||||
//!
|
||||
//! ## Multi-tenant fence
|
||||
//!
|
||||
//! Every [`SearchQuery`] carries a [`CommunityId`]. There is no construction
|
||||
//! path through this crate that omits it, and every SQL execution binds
|
||||
//! `community_id = $ctx` as the first predicate. A query bound to community
|
||||
//! A cannot return events stored under community B, by construction.
|
||||
|
||||
/// Search error types.
|
||||
pub mod error;
|
||||
/// Search query execution.
|
||||
pub mod query;
|
||||
|
||||
pub use buzz_core::CommunityId;
|
||||
pub use error::SearchError;
|
||||
pub use query::{search, ChannelScope, SearchHit, SearchMode, SearchQuery, SearchResult};
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Thin handle around a `PgPool` for community-scoped FTS.
|
||||
///
|
||||
/// Holds nothing the pool itself doesn't already own. The whole purpose of
|
||||
/// this type is a stable injection point for the relay's `AppState`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchService {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SearchService {
|
||||
/// Build a search service over an existing Postgres pool.
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Execute a community-scoped FTS query.
|
||||
pub async fn search(&self, query: &SearchQuery) -> Result<SearchResult, SearchError> {
|
||||
query::search(&self.pool, query).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! NIP-50 search query against Postgres FTS, community-scoped.
|
||||
//!
|
||||
//! The relay never trusts a hit by itself: this layer returns canonical event
|
||||
//! ids ordered by relevance, the relay refetches `StoredEvent`s through
|
||||
//! buzz-db's `(community_id, event_id)` scoped fetcher, and runs the access
|
||||
//! predicate (`search_hit_accepted` in `bridge.rs`) per hit. Search is never
|
||||
//! the access boundary — it cannot widen visibility.
|
||||
//!
|
||||
//! See conformance row 50.
|
||||
|
||||
use buzz_core::CommunityId;
|
||||
use sqlx::{PgPool, QueryBuilder, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::SearchError;
|
||||
|
||||
/// Channel-scope filter for a community-scoped FTS query.
|
||||
///
|
||||
/// Four variants, 1-to-1 with the legacy `(accessible_channels: &[Uuid],
|
||||
/// include_global: bool)` matrix from the Typesense relay:
|
||||
///
|
||||
/// | accessible | include_global | `ChannelScope` |
|
||||
/// |---|---|---|
|
||||
/// | non-empty | true | `ChannelsOrChannelLess(accessible)` |
|
||||
/// | non-empty | false | `Channels(accessible)` |
|
||||
/// | empty | true | `ChannelLessOnly` |
|
||||
/// | empty | false | (don't call — caller short-circuits to EOSE) |
|
||||
///
|
||||
/// `ChannelLessOnly` is the variant that the old `Option<Vec<Uuid>>` +
|
||||
/// `bool` 2x2 could not express unambiguously: with empty accessible
|
||||
/// channels and `include_global=true`, both `Some(vec![]) + true` and
|
||||
/// `None + true` would broaden to all community channels rather than
|
||||
/// restrict to channel-less events. The enum closes that hole at the
|
||||
/// type level.
|
||||
///
|
||||
/// Empty-vec edge cases are intentionally not special-cased:
|
||||
/// `Channels(vec![])` emits `channel_id = ANY('{}')` which Postgres
|
||||
/// evaluates as false-for-all-rows (zero hits), and
|
||||
/// `ChannelsOrChannelLess(vec![])` emits `(channel_id = ANY('{}') OR
|
||||
/// channel_id IS NULL)` which is equivalent to `ChannelLessOnly`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChannelScope {
|
||||
/// No channel constraint. Matches every event in the community.
|
||||
Any,
|
||||
/// Restrict to `channel_id IS NULL` events only — what the legacy
|
||||
/// Typesense `channel_id:=__global__` sentinel meant.
|
||||
ChannelLessOnly,
|
||||
/// Restrict to events whose `channel_id` is in this list.
|
||||
Channels(Vec<Uuid>),
|
||||
/// Restrict to events whose `channel_id` is in this list, OR are
|
||||
/// channel-less (`channel_id IS NULL`).
|
||||
ChannelsOrChannelLess(Vec<Uuid>),
|
||||
}
|
||||
|
||||
/// Search matching semantics.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchMode {
|
||||
/// Standard NIP-50-ish word/lexeme search using `websearch_to_tsquery`.
|
||||
FullText,
|
||||
/// Prefix-match the trailing normalized query token (`pro` matches `project`).
|
||||
///
|
||||
/// Intended for bounded typeahead surfaces such as the desktop topbar. The
|
||||
/// relay still refetches and re-authorizes every hit; this mode changes only
|
||||
/// the candidate tsquery, not the access boundary.
|
||||
Prefix,
|
||||
}
|
||||
|
||||
/// A community-scoped FTS query.
|
||||
///
|
||||
/// The community is REQUIRED at the type level — there is no construction path
|
||||
/// that omits it. This is the search-side expression of conformance row zero:
|
||||
/// every search call carries the server-resolved tenant, never client input.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchQuery {
|
||||
/// Server-resolved community. Required.
|
||||
pub community: CommunityId,
|
||||
/// NIP-50 search text. Empty string is rejected by `search()` early
|
||||
/// (no hits, no SQL roundtrip).
|
||||
pub q: String,
|
||||
/// How to scope hits by channel. See [`ChannelScope`] — the four variants
|
||||
/// are 1-to-1 with the legacy `(accessible_channels, include_global)`
|
||||
/// matrix, and `ChannelLessOnly` closes the gap where "empty accessible
|
||||
/// channels + include global" used to silently broaden to all channels.
|
||||
pub channel_scope: ChannelScope,
|
||||
/// NIP-01 kinds filter. None = no kind constraint.
|
||||
pub kinds: Option<Vec<i32>>,
|
||||
/// NIP-01 authors filter (32-byte pubkeys). None = no author constraint.
|
||||
pub authors: Option<Vec<Vec<u8>>>,
|
||||
/// NIP-01 since (Unix seconds). Inclusive lower bound on created_at.
|
||||
pub since: Option<i64>,
|
||||
/// NIP-01 until (Unix seconds). Inclusive upper bound on created_at.
|
||||
pub until: Option<i64>,
|
||||
/// 1-indexed page number.
|
||||
pub page: u32,
|
||||
/// Page size. Clamped at 500 internally.
|
||||
pub per_page: u32,
|
||||
/// Matching semantics for the search text.
|
||||
pub mode: SearchMode,
|
||||
}
|
||||
|
||||
/// A single FTS hit. The relay refetches the canonical `StoredEvent` and
|
||||
/// re-authorizes; this struct is just enough to drive that fetch and preserve
|
||||
/// relevance ordering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchHit {
|
||||
/// 32-byte event id.
|
||||
pub event_id: [u8; 32],
|
||||
/// Nostr kind.
|
||||
pub kind: i32,
|
||||
/// 32-byte pubkey of author.
|
||||
pub pubkey: [u8; 32],
|
||||
/// Optional channel UUID. `None` = channel-less event.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// Unix seconds.
|
||||
pub created_at: i64,
|
||||
/// `ts_rank_cd` relevance score (higher = better).
|
||||
pub rank: f32,
|
||||
}
|
||||
|
||||
/// Result of a search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchResult {
|
||||
/// Hits on this page, ordered by relevance then created_at desc.
|
||||
pub hits: Vec<SearchHit>,
|
||||
/// 1-indexed page returned.
|
||||
pub page: u32,
|
||||
}
|
||||
|
||||
const PER_PAGE_MAX: u32 = 500;
|
||||
const PER_PAGE_DEFAULT: u32 = 100;
|
||||
/// Hard cap on search text handed to Postgres text-search parsers. This keeps a
|
||||
/// single request from spending unbounded parser CPU/memory while still allowing
|
||||
/// far longer queries than the desktop UI normally emits.
|
||||
const SEARCH_TEXT_MAX_CHARS: usize = 4096;
|
||||
/// Search pages are currently server-generated (WS uses 1..=MAX_SEARCH_PAGES,
|
||||
/// bridge uses page 1), but clamp here too so a future caller cannot accidentally
|
||||
/// wire untrusted input into a multi-trillion-row OFFSET.
|
||||
const PAGE_MAX: u32 = 1000;
|
||||
|
||||
fn push_tsquery(qb: &mut QueryBuilder<sqlx::Postgres>, mode: SearchMode, search_text: &str) {
|
||||
match mode {
|
||||
SearchMode::FullText => {
|
||||
qb.push("websearch_to_tsquery('simple', ");
|
||||
qb.push_bind(search_text);
|
||||
qb.push(")");
|
||||
}
|
||||
SearchMode::Prefix => {
|
||||
// Prefix mode is for typeahead: completed whitespace-delimited tokens
|
||||
// are exact, and only the trailing token is suffixed with `:*`.
|
||||
// Each raw token still goes through Postgres' `simple` parser before
|
||||
// tsquery construction so query-side normalization matches the
|
||||
// `search_tsv` generated column. `quote_literal` prevents tsquery
|
||||
// syntax injection from punctuation/operators in the raw topbar input.
|
||||
qb.push(
|
||||
"(SELECT COALESCE(\
|
||||
string_agg(\
|
||||
quote_literal(lexeme) || CASE WHEN token_ord = max_token_ord THEN ':*' ELSE '' END, \
|
||||
' & ' ORDER BY token_ord, lex_ord\
|
||||
), \
|
||||
''\
|
||||
)::tsquery \
|
||||
FROM (\
|
||||
SELECT raw_token.token_ord, normalized.lexeme, normalized.lex_ord, raw_token.max_token_ord \
|
||||
FROM (\
|
||||
SELECT token, token_ord, max(token_ord) OVER () AS max_token_ord \
|
||||
FROM regexp_split_to_table(",
|
||||
);
|
||||
qb.push_bind(search_text);
|
||||
qb.push(
|
||||
", '\\s+') WITH ORDINALITY AS split(token, token_ord)\
|
||||
) AS raw_token \
|
||||
CROSS JOIN LATERAL unnest(tsvector_to_array(to_tsvector('simple', raw_token.token))) \
|
||||
WITH ORDINALITY AS normalized(lexeme, lex_ord)\
|
||||
) AS prefix_terms)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn normalized_search_text(q: &str) -> Option<String> {
|
||||
let trimmed = q.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cleaned = String::with_capacity(trimmed.len().min(SEARCH_TEXT_MAX_CHARS));
|
||||
for ch in trimmed.chars().take(SEARCH_TEXT_MAX_CHARS) {
|
||||
cleaned.push(if ch == '\0' { ' ' } else { ch });
|
||||
}
|
||||
|
||||
let cleaned = cleaned.trim();
|
||||
if cleaned.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(cleaned.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a community-scoped FTS query.
|
||||
///
|
||||
/// SQL shape (always):
|
||||
/// ```sql
|
||||
/// SELECT id, kind, pubkey, channel_id, EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s,
|
||||
/// ts_rank_cd(search_tsv, query) AS rank
|
||||
/// FROM events,
|
||||
/// <mode-specific tsquery> AS query
|
||||
/// WHERE community_id = $ctx
|
||||
/// AND deleted_at IS NULL
|
||||
/// AND search_tsv @@ query
|
||||
/// [+ channel scope, kinds, authors, since, until]
|
||||
/// ORDER BY rank DESC, created_at DESC, id
|
||||
/// LIMIT $per_page OFFSET (($page - 1) * $per_page)
|
||||
/// ```
|
||||
///
|
||||
/// `community_id = $ctx` is the first predicate and is non-negotiable. There
|
||||
/// is no code path through this function that omits it.
|
||||
pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult, SearchError> {
|
||||
let Some(search_text) = normalized_search_text(&query.q) else {
|
||||
return Ok(SearchResult {
|
||||
hits: Vec::new(),
|
||||
page: query.page.clamp(1, PAGE_MAX),
|
||||
});
|
||||
};
|
||||
|
||||
let per_page = query.per_page.clamp(1, PER_PAGE_MAX);
|
||||
let per_page_actual = if query.per_page == 0 {
|
||||
PER_PAGE_DEFAULT
|
||||
} else {
|
||||
per_page
|
||||
};
|
||||
let page = query.page.clamp(1, PAGE_MAX);
|
||||
let offset = ((page - 1) as i64) * (per_page_actual as i64);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
|
||||
"SELECT id, kind, pubkey, channel_id, \
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, \
|
||||
ts_rank_cd(search_tsv, search_query.query) AS rank \
|
||||
FROM events CROSS JOIN LATERAL (SELECT ",
|
||||
);
|
||||
push_tsquery(&mut qb, query.mode, &search_text);
|
||||
qb.push(" AS query) AS search_query WHERE community_id = ");
|
||||
qb.push_bind(*query.community.as_uuid());
|
||||
qb.push(" AND deleted_at IS NULL AND search_tsv @@ search_query.query");
|
||||
|
||||
// Channel scope — see `ChannelScope` doc for the four-case mapping. The
|
||||
// emitted SQL fragments are identical to the legacy 2x2 tuple for the
|
||||
// three carry-over cases; `ChannelLessOnly` is the new fence that the
|
||||
// old shape could not express.
|
||||
match &query.channel_scope {
|
||||
ChannelScope::Any => {
|
||||
// No channel constraint.
|
||||
}
|
||||
ChannelScope::ChannelLessOnly => {
|
||||
qb.push(" AND channel_id IS NULL");
|
||||
}
|
||||
ChannelScope::Channels(ids) => {
|
||||
qb.push(" AND channel_id = ANY(");
|
||||
qb.push_bind(ids.clone());
|
||||
qb.push(")");
|
||||
}
|
||||
ChannelScope::ChannelsOrChannelLess(ids) => {
|
||||
qb.push(" AND (channel_id = ANY(");
|
||||
qb.push_bind(ids.clone());
|
||||
qb.push(") OR channel_id IS NULL)");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref kinds) = query.kinds {
|
||||
if !kinds.is_empty() {
|
||||
qb.push(" AND kind = ANY(");
|
||||
qb.push_bind(kinds.clone());
|
||||
qb.push(")");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref authors) = query.authors {
|
||||
if !authors.is_empty() {
|
||||
qb.push(" AND pubkey = ANY(");
|
||||
qb.push_bind(authors.clone());
|
||||
qb.push(")");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(since) = query.since {
|
||||
qb.push(" AND created_at >= to_timestamp(");
|
||||
qb.push_bind(since);
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
if let Some(until) = query.until {
|
||||
qb.push(" AND created_at <= to_timestamp(");
|
||||
qb.push_bind(until);
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
|
||||
qb.push_bind(per_page_actual as i64);
|
||||
qb.push(" OFFSET ");
|
||||
qb.push_bind(offset);
|
||||
|
||||
let rows = qb.build().fetch_all(pool).await?;
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("id")?;
|
||||
let pk_bytes: Vec<u8> = row.try_get("pubkey")?;
|
||||
let id: [u8; 32] = id_bytes.try_into().map_err(|v: Vec<u8>| {
|
||||
sqlx::Error::Decode(format!("event id column is {} bytes, expected 32", v.len()).into())
|
||||
})?;
|
||||
let pubkey: [u8; 32] = pk_bytes.try_into().map_err(|v: Vec<u8>| {
|
||||
sqlx::Error::Decode(format!("pubkey column is {} bytes, expected 32", v.len()).into())
|
||||
})?;
|
||||
hits.push(SearchHit {
|
||||
event_id: id,
|
||||
kind: row.try_get("kind")?,
|
||||
pubkey,
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
created_at: row.try_get("created_at_s")?,
|
||||
rank: row.try_get("rank")?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SearchResult { hits, page })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalized_search_text_trims_and_rejects_empty() {
|
||||
assert_eq!(
|
||||
normalized_search_text(" hello ").as_deref(),
|
||||
Some("hello")
|
||||
);
|
||||
assert!(normalized_search_text(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_search_text_replaces_nul_bytes() {
|
||||
assert_eq!(
|
||||
normalized_search_text("foo\0bar").as_deref(),
|
||||
Some("foo bar")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_search_text_caps_length() {
|
||||
let long = "x".repeat(SEARCH_TEXT_MAX_CHARS + 10);
|
||||
let cleaned = normalized_search_text(&long).expect("non-empty");
|
||||
assert_eq!(cleaned.chars().count(), SEARCH_TEXT_MAX_CHARS);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user