diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30659bf385..d9f0d2eb81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,7 @@ We review as capacity allows — focused PRs that follow this guide move fastest | pnpm | 10+ | Required for desktop app commands and `just ci` | | Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | | Docker | 24+ | For Postgres, Redis, MinIO | +| PostgreSQL | 16+ | Docker provides 17; a database you run yourself must be 16 or later because migration 0056 uses `pg_input_is_valid` | | `just` | latest | Task runner — `cargo install just` | | `lefthook` | 2.1.3 (Hermit-pinned) | Auto-installed by `just hooks` — no manual install needed | | `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` | diff --git a/README.md b/README.md index 56439f00bc..b96e910dd8 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ For a split-terminal workflow (relay logs separate from Vite output), use `just Want a single-node / VPS relay instead of the local-dev stack? Use the production Compose bundle in [`deploy/compose/`](deploy/compose/README.md) (`docker compose` + Postgres, Redis, MinIO, optional Caddy/TLS). The root [`docker-compose.yml`](docker-compose.yml) is for day-to-day development only. +The relay needs PostgreSQL 16 or later (its migrations use `pg_input_is_valid`). Both Compose bundles ship 17, so this only matters when `DATABASE_URL` points at a database you run yourself; an older server fails migration 0056 at startup with a message naming the requirement. + For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls. --- diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 50ce92646f..ebb1e21f9a 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -960,6 +960,15 @@ mod postgres_tests { let profile_search = migrations[55].sql.as_str(); assert!(profile_search.contains("CREATE FUNCTION profile_search_tsv(content TEXT)")); assert!(profile_search.contains("pg_input_is_valid(content, 'jsonb')")); + // PostgreSQL 16 floor: the preflight must run before CREATE FUNCTION so + // an older server reports the requirement, not a missing-function error. + let preflight = profile_search + .find("current_setting('server_version_num')::int < 160000") + .expect("0056 preflights the PostgreSQL 16 floor"); + let create_function = profile_search + .find("CREATE FUNCTION profile_search_tsv") + .expect("0056 creates profile_search_tsv"); + assert!(preflight < create_function); assert!(profile_search.contains("IF NOT EXISTS (SELECT 1 FROM events LIMIT 1)")); assert!(profile_search.contains("CASE WHEN kind = 0 THEN profile_search_tsv(content)")); assert!(!migrations[0].sql.as_str().contains("profile_search_tsv")); diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index 639712b5ea..6e56319215 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -207,10 +207,11 @@ fn normalized_search_text(q: &str) -> Option { /// SELECT [DISTINCT ON (pubkey)] id, kind, pubkey, channel_id, /// EXTRACT(EPOCH FROM created_at)::bigint AS created_at_s, /// ts_rank_cd(search_tsv, query) AS rank -/// [, created_at, @@ query AS name_match] +/// [, created_at, @@ query AS name_match] /// [, search_tsv] -- prefix mode, ≤ 2 chars /// FROM events, /// AS query +/// [, LATERAL (SELECT AS j OFFSET 0) AS profile] /// WHERE community_id = $ctx /// AND deleted_at IS NULL /// AND search_tsv @@ query @@ -276,18 +277,15 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result>`, an empty vector, and `false`. - // `pg_input_is_valid` needs PostgreSQL 16 or later. + // name fields only, so both sides normalize identically. `profile.j` + // is the row's content parsed once by the LATERAL below: NULL for + // invalid JSON, and `->>` is NULL for a non-object document, so either + // yields an empty vector and `false`; `name_match` is never NULL. qb.push( - ", created_at, CASE WHEN pg_input_is_valid(content, 'jsonb') \ - THEN to_tsvector('simple', concat_ws(' ', \ - content::jsonb ->> 'name', \ - content::jsonb ->> 'display_name', \ - content::jsonb ->> 'displayName')) @@ search_query.query \ - ELSE false END AS name_match", + ", created_at, to_tsvector('simple', concat_ws(' ', \ + profile.j ->> 'name', \ + profile.j ->> 'display_name', \ + profile.j ->> 'displayName')) @@ search_query.query AS name_match", ); if prioritize_exact_profile_lexeme { // The outer exact-lexeme term reads `search_tsv` through the wrapper. @@ -296,7 +294,20 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result