# `@platform-modules/util/fts`

Pure, zero-dependency Postgres full-text-search helper leaves: `sanitize-tsquery` (injection sanitizer), `safe-headline` (XSS-safe `ts_headline`→`<mark>`), `rank` (`ts_rank` vs `ts_rank_cd` chooser).

## Host SQL-safety contract (security)

This is a **security contract**, not a convenience note. `sanitizeTsquery` strips tsquery metacharacters (`` !&|():*'"`<>\ ``), but sanitizing the input is only half the boundary — the output is injection-safe **end-to-end** only if the host honors its half:

- **Feed the sanitized output to `to_tsquery()` / `plainto_tsquery()`, or pass it as a bound parameter.**
- **NEVER string-interpolate it into raw SQL.** A sanitizer in front of raw interpolation still leaves the hole open the moment the host concatenates.
- `websearch_to_tsquery` is the **throw-proof alternative**: it accepts arbitrary user text without raising on malformed operators, so a host can hand it raw user input (still via a bound parameter).

The module sanitizes; the host must not re-open the hole.

## FTS migration recipe (host adopts — NOT shipped module code)

The module never owns host tables, so the schema below is a **host-side migration recipe** (from a prior app's mature FTS setup), not code shipped by `@platform-modules/util`. Adapt the placeholder table/column names to your domain.

A `GENERATED ALWAYS AS (...) STORED` tsvector column requires an **IMMUTABLE** expression. Single-arg `to_tsvector(body)` is **not** immutable (it reads `default_text_search_config`) and Postgres will reject it in a generated column — you **must** pass an explicit text-search-config literal as the first argument.

```sql
-- pg_trgm enables trigram / ILIKE fallback (the naive tier when FTS is overkill).
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- Stored, GENERATED tsvector column. Note the explicit 'simple' regconfig
-- (required for IMMUTABLE); swap for 'english' / a per-language config as needed.
ALTER TABLE documents
  ADD COLUMN search_tsv tsvector
  GENERATED ALWAYS AS (
    to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, ''))
  ) STORED;

-- GIN index over the generated tsvector — the primary FTS index.
CREATE INDEX documents_search_tsv_gin
  ON documents USING GIN (search_tsv);

-- pg_trgm GIN index for trigram / ILIKE fallback on a raw text column.
CREATE INDEX documents_title_trgm
  ON documents USING GIN (title gin_trgm_ops);
```

**Ranking:** use `ts_rank_cd` (cover-density — rewards proximity of matched terms) for richer relevance, or `ts_rank` for a simpler term-frequency score. This choice is exposed by the `fts/rank` leaf so a host picks the function consistently across queries.
