From e349d76498d70ed11bbdc2ef3398f01352f8247d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Fri, 26 Jun 2026 16:00:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(lane0):=20fold=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20FTS=20column,=20drop=20stale=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last Lane-0 schema items before the frozen base: - events.search_tsv TSVECTOR GENERATED ALWAYS AS to_tsvector('simple', content) STORED + GIN idx_events_search_tsv. The Typesense->Postgres FTS data shape, landed in Lane 0 because it touches the just-locked events table (Quinn option A). GENERATED ALWAYS = single source of truth: proven against PG that a client cannot forge search_tsv out of sync with content (generated_always rejection). Index left minimal single-column GIN; the search lane picks the final spelling after EXPLAIN (Max's caveat). - Delete stale 0002_backfill_d_tag.sql / 0003_event_reminders.sql. In the consolidated-from-scratch model 0001 already carries d_tag, not_before, delivered_at, and idx_events_not_before; re-running the old additive migrations would error (duplicate column / duplicate index name). audit_log DDL shape confirmed for the audit-crate collapse (Dawn's lane): PRIMARY KEY (community_id, seq), UNIQUE (community_id, hash), community_id NOT NULL on every row. 0001 is the single source; buzz-audit drops its own schema.rs / AUDIT_SCHEMA_SQL / ensure_schema() in the audit lane. Re-proven against real Postgres — full fence suite green: T1 re-tenant rejected, T6 cross-community member FK rejected, T6b same-community ok, T7 same channel UUID in two communities allowed, T8 host case-collision rejected, T9 same event id in two communities allowed, plus the FTS generated+GIN match and the forge-rejection. buzz-core: 189 + 2 doctests. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- migrations/0001_initial_schema.sql | 14 ++++++++++++++ migrations/0002_backfill_d_tag.sql | 17 ----------------- migrations/0003_event_reminders.sql | 17 ----------------- 3 files changed, 14 insertions(+), 34 deletions(-) delete mode 100644 migrations/0002_backfill_d_tag.sql delete mode 100644 migrations/0003_event_reminders.sql diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index edda65c77..041a452da 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -195,6 +195,15 @@ CREATE TABLE events ( kind INT NOT NULL, tags JSONB NOT NULL, content TEXT NOT NULL, + -- Full-text search vector (Typesense → Postgres FTS). Generated/STORED so + -- it is a single source of truth — no sidecar indexer to keep coherent + -- (Quinn option A, Lane-0 call). 'simple' config = no stemming/stopwords, + -- matching the existing substring-ish search semantics; the search lane can + -- revisit the config behind evidence. Tenant scoping is by the + -- community-leading btree filters BitmapAnd-ed with the GIN probe, so the + -- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat: + -- avoid btree_gin unless EXPLAIN proves it buys something). + search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, sig BYTEA NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), channel_id UUID, @@ -242,6 +251,11 @@ CREATE INDEX idx_events_parameterized WHERE d_tag IS NOT NULL AND deleted_at IS NULL; CREATE INDEX idx_events_not_before ON events (community_id, not_before) WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; +-- Full-text search. Minimal GIN over the generated tsvector; community scoping +-- is supplied by the community-leading btree filters above (BitmapAnd), so this +-- stays a single-column GIN. The search lane confirms the final spelling with +-- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). +CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to diff --git a/migrations/0002_backfill_d_tag.sql b/migrations/0002_backfill_d_tag.sql deleted file mode 100644 index 74301271f..000000000 --- a/migrations/0002_backfill_d_tag.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Backfill d_tag for existing NIP-33 range events (kind 30000–39999). --- Idempotent: only updates rows where d_tag is still NULL. --- Includes soft-deleted rows so the column is fully populated. --- Run once after adding the d_tag column to the events table. --- --- Managed by sqlx migrations. - -UPDATE events -SET d_tag = COALESCE( - (SELECT elem->>1 - FROM jsonb_array_elements(tags) AS elem - WHERE elem->>0 = 'd' - LIMIT 1), - '' -) -WHERE kind BETWEEN 30000 AND 39999 - AND d_tag IS NULL; diff --git a/migrations/0003_event_reminders.sql b/migrations/0003_event_reminders.sql deleted file mode 100644 index 846928c30..000000000 --- a/migrations/0003_event_reminders.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add NIP-ER event-reminder columns and due-delivery index to the events table. --- --- `not_before` is the reminder's scheduled delivery time (Unix seconds); --- `delivered_at` records when the scheduler published it. Both are nullable — --- non-reminder events leave them NULL. The partial index covers only --- undelivered, live reminders so the scheduler's due-query stays cheap. --- --- `events` is partitioned by RANGE (created_at); ALTER TABLE on the parent --- cascades the columns to every partition, and CREATE INDEX on the parent --- builds a partitioned index that propagates to each partition. --- --- Managed by sqlx migrations. - -ALTER TABLE events ADD COLUMN not_before BIGINT; -ALTER TABLE events ADD COLUMN delivered_at BIGINT; -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;