|
| 1 | +-- Weighted full-text search column (search_fts) + version stamp, kept fresh by |
| 2 | +-- a BEFORE trigger. Fast, metadata-only DDL only (README D1): the nullable |
| 3 | +-- columns force no table rewrite on PG16, and the function/trigger are instant. |
| 4 | +-- Pre-existing rows are populated by the resumable drainer (ftsBackfill.js); |
| 5 | +-- the GIN index is built CONCURRENTLY in the separate no-transaction migration |
| 6 | +-- 0037 (a $$-quoted plpgsql body cannot survive the no-transaction ; splitter, |
| 7 | +-- so the trigger and the CONCURRENTLY index MUST live in different files). |
| 8 | +-- |
| 9 | +-- The setweight(...) expression MUST stay identical to |
| 10 | +-- lexicalRepo.searchFtsExpr('NEW'); fts_version = 1 matches lexicalRepo.FTS_VERSION. |
| 11 | +-- Idempotent (IF NOT EXISTS / CREATE OR REPLACE / DROP IF EXISTS) because a |
| 12 | +-- crash before the schema_migrations INSERT retries this migration. |
| 13 | + |
| 14 | +ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_fts tsvector; |
| 15 | +ALTER TABLE messages ADD COLUMN IF NOT EXISTS fts_version int; |
| 16 | + |
| 17 | +CREATE OR REPLACE FUNCTION messages_search_fts_refresh() RETURNS trigger AS $$ |
| 18 | +BEGIN |
| 19 | + -- Skip recompute on an UPDATE that changes none of the indexed source |
| 20 | + -- columns (read/star flag flips, snippet-only writes), so the trigger does |
| 21 | + -- not tax hot sync UPSERTs; this also lets the backfill's explicit SET win. |
| 22 | + IF TG_OP = 'UPDATE' |
| 23 | + AND NEW.subject IS NOT DISTINCT FROM OLD.subject |
| 24 | + AND NEW.from_name IS NOT DISTINCT FROM OLD.from_name |
| 25 | + AND NEW.from_email IS NOT DISTINCT FROM OLD.from_email |
| 26 | + AND NEW.to_addresses IS NOT DISTINCT FROM OLD.to_addresses |
| 27 | + AND NEW.cc_addresses IS NOT DISTINCT FROM OLD.cc_addresses |
| 28 | + AND NEW.body_text IS NOT DISTINCT FROM OLD.body_text |
| 29 | + THEN |
| 30 | + RETURN NEW; |
| 31 | + END IF; |
| 32 | + |
| 33 | + BEGIN |
| 34 | + NEW.search_fts := setweight(to_tsvector('english', coalesce(NEW.subject,'')), 'A') || |
| 35 | + setweight(to_tsvector('english', coalesce(NEW.from_name,'') || ' ' || coalesce(NEW.from_email,'')), 'B') || |
| 36 | + setweight(to_tsvector('english', coalesce(NEW.to_addresses::text,'') || ' ' || coalesce(NEW.cc_addresses::text,'')), 'C') || |
| 37 | + setweight(to_tsvector('english', LEFT(coalesce(NEW.body_text,''), 600000)), 'D'); |
| 38 | + NEW.fts_version := 1; |
| 39 | + EXCEPTION WHEN program_limit_exceeded THEN |
| 40 | + -- Even with the 600k LEFT cap, a pathologically dense/multibyte body can |
| 41 | + -- exceed Postgres's ~1MB tsvector limit (SQLSTATE 54000). Never fail the |
| 42 | + -- row write: leave search_fts NULL so the message still persists and stays |
| 43 | + -- findable via the ILIKE fallback; the backfill's row-by-row skip stamps it. |
| 44 | + NEW.search_fts := NULL; |
| 45 | + NEW.fts_version := NULL; |
| 46 | + END; |
| 47 | + |
| 48 | + RETURN NEW; |
| 49 | +END; |
| 50 | +$$ LANGUAGE plpgsql; |
| 51 | + |
| 52 | +DROP TRIGGER IF EXISTS trg_messages_search_fts ON messages; |
| 53 | +CREATE TRIGGER trg_messages_search_fts |
| 54 | + BEFORE INSERT OR UPDATE ON messages |
| 55 | + FOR EACH ROW EXECUTE FUNCTION messages_search_fts_refresh(); |
0 commit comments