Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 32);
assert_eq!(migrations.len(), 33);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -844,6 +844,18 @@ mod tests {
assert!(migrations[13].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30350"));

// NIP-PMA kind:30179 FTS exclusion (0033): same wrap-the-existing-
// expression shape as 0014 so brownfield databases stop tokenizing
// private managed-agent ciphertext without a policy rewrite. (The
// migration itself still rewrites the events heap and rebuilds the
// GIN index — see the 0033 header for the operational cost.)
assert_eq!(migrations[32].version, 33);
assert!(migrations[32].sql.as_str().contains("kind = 30179"));
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../schema/schema.sql")
.contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)"));

// Public push-gateway authority is intentionally deployment-global and
// durable: immediate revocation and hostile-relay admission cannot be
// honestly provided by a stateless gateway.
Expand Down Expand Up @@ -1915,7 +1927,7 @@ mod tests {

#[tokio::test]
#[ignore = "requires Postgres"]
async fn populated_upgrade_preserves_search_policy_except_for_push_leases() {
async fn populated_upgrade_preserves_search_policy_except_for_private_kinds() {
let pool = connect_test_pool().await;
reset_public_schema(&pool).await;
MIGRATOR
Expand All @@ -1931,7 +1943,7 @@ mod tests {
.await
.expect("insert community");

for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32)] {
for (marker, kind) in [(1_u8, 1_i32), (2_u8, 30_350_i32), (3_u8, 30_179_i32)] {
sqlx::query(
"INSERT INTO events \
(community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \
Expand All @@ -1958,19 +1970,37 @@ mod tests {
.fetch_all(&pool)
.await
.expect("read pre-push search behavior");
assert_eq!(before, vec![(1, true), (30_350, true)]);
assert_eq!(before, vec![(1, true), (30_179, true), (30_350, true)]);

// 0014 fixes 30350 only. A brownfield database that stopped here still
// tokenized kind:30179 ciphertext — the gap 0033 closes.
MIGRATOR
.run_to(32, &pool)
.await
.expect("apply migrations through 32");
let pre_0033: Vec<(i32, Option<bool>)> = sqlx::query_as(
"SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \
FROM events ORDER BY kind",
)
.fetch_all(&pool)
.await
.expect("read pre-0033 search behavior");
assert_eq!(
pre_0033,
vec![(1, Some(true)), (30_179, Some(true)), (30_350, None)]
);

run_migrations(&pool)
.await
.expect("apply push migrations to populated database");
.expect("apply remaining migrations to populated database");
let after: Vec<(i32, Option<bool>)> = sqlx::query_as(
"SELECT kind, search_tsv @@ plainto_tsquery('simple', 'needle') \
FROM events ORDER BY kind",
)
.fetch_all(&pool)
.await
.expect("read post-push search behavior");
assert_eq!(after, vec![(1, Some(true)), (30_350, None)]);
.expect("read post-upgrade search behavior");
assert_eq!(after, vec![(1, Some(true)), (30_179, None), (30_350, None)]);
}

#[tokio::test]
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-search/tests/fts_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_r
const MIGRATION_0008_SQL: &str =
include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql");
const MIGRATION_0014_SQL: &str = include_str!("../../../migrations/0014_push_lease_fts.sql");
const MIGRATION_0033_SQL: &str =
include_str!("../../../migrations/0033_private_managed_agent_fts.sql");

async fn setup() -> (PgPool, String) {
let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
Expand Down Expand Up @@ -81,6 +83,9 @@ async fn setup() -> (PgPool, String) {
pool.execute(MIGRATION_0014_SQL)
.await
.expect("apply 0014 migration");
pool.execute(MIGRATION_0033_SQL)
.await
.expect("apply 0033 migration");
(pool, schema)
}

Expand Down
46 changes: 46 additions & 0 deletions migrations/0033_private_managed_agent_fts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- NIP-PMA kind:30179 carries the owner's private managed-agent payload
-- (agent nsec, env vars, prompt) as NIP-44 ciphertext and is author-only.
-- Exclude it from full-text search without changing the search policy of
-- existing installations. Fresh installs are already safe via the positive
-- allowlist from migration 0008; this closes the brownfield gap where a
-- populated database still runs the legacy negative skip-set (0001/0005)
-- and would tokenize the ciphertext into search_tsv.
--
-- Same shape as 0014 (kind:30350): PostgreSQL cannot alter a generated
-- expression in place, so capture the current expression, drop the column,
-- and re-add it wrapped with the new exclusion. Every other kind keeps
-- whatever policy the database had before.
--
-- Operational cost: this is not free on large databases. DROP COLUMN +
-- ADD ... GENERATED ... STORED rewrites the entire events heap and then
-- rebuilds the GIN index, all under an ACCESS EXCLUSIVE lock inside the
-- migration transaction (CREATE INDEX CONCURRENTLY is not possible
-- here), with no lock_timeout. Expect relay downtime proportional to
-- the size of events. The index is recreated from the stock definition
-- below; any non-stock indexes or storage parameters on search_tsv are
-- not captured or replayed. 0014 set this precedent on smaller tables;
-- operators with large brownfield databases should schedule a window.
DO $$
DECLARE
existing_expression TEXT;
BEGIN
SELECT pg_get_expr(d.adbin, d.adrelid)
INTO existing_expression
FROM pg_attrdef d
JOIN pg_attribute a
ON a.attrelid = d.adrelid
AND a.attnum = d.adnum
WHERE d.adrelid = 'events'::regclass
AND a.attname = 'search_tsv';

IF existing_expression IS NULL THEN
RAISE EXCEPTION 'events.search_tsv generated expression not found';
END IF;

ALTER TABLE events DROP COLUMN search_tsv;
EXECUTE format(
'ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (CASE WHEN kind = 30179 THEN NULL::tsvector ELSE (%s) END) STORED',
existing_expression
);
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
END $$;
4 changes: 2 additions & 2 deletions schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@ CREATE TABLE events (
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
-- never matches `@@`.
-- Keep in sync with migrations (final state: 0001 + 0005 + 0009).
-- Keep in sync with migrations (final state: 0001 + 0005 + 0014 + 0033).
search_tsv TSVECTOR GENERATED ALWAYS AS (
CASE WHEN kind IN (1059, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
CASE WHEN kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200) THEN NULL::tsvector
ELSE to_tsvector('simple', content)
END
) STORED,
Expand Down
Loading