/** * The one place a collection's `search` block becomes SQL. * * Four things describe a Postgres table in this codebase — the DDL generator, * the Drizzle schema generator, the runtime table builder for BaaS mode, and * the boot-time schema ensure — and each of them has, at some point, described * a column differently from the others. The `varchar(255)` note in * `generate-postgres-ddl-logic` is one such scar: the same property produced a * capped column down one path and an uncapped one down the other, and nothing * failed until a user hit the cap. * * So the search column is not implemented four times. It is computed once, * here, and every generator renders the same {@link SearchColumnSpec}. There is * a test asserting exactly that (`search-column-contract.test.ts`); the point of * this module is that the test has something to assert *about*. * * ## Why the expressions look the way they do * * A `GENERATED ALWAYS AS … STORED` expression must be strictly IMMUTABLE, and * Postgres is stricter here than intuition. Verified against PostgreSQL 18: * * | expression | immutable | * |-----------------------------------------|-----------| * | `to_tsvector('spanish', col)` | yes | * | `to_tsvector(col)` (1-arg) | **no** — depends on `default_text_search_config` | * | `array_to_string(col, ' ')` | **no** | * | `col::text` on `text[]` | **no** | * | `to_jsonb(col)` | **no** | * | `unaccent(col)` | **no** — dictionary lookup is STABLE | * | `jsonb_to_tsvector('spanish', j, '["string"]')` | yes | * | `setweight(...) || setweight(...)` | yes | * * Three of the four things a real search column needs are therefore unavailable * directly, which is why {@link searchHelperFunctions} exists: each wraps a * stable built-in in an SQL function declared IMMUTABLE. That declaration is a * promise, and it is a true one for these three — array joining, JSON string * extraction and accent folding are all deterministic for a given input; the * built-ins are marked stable only because they must account for element types * and dictionaries in general. * * The alternative was to skip `unaccent` and text arrays entirely. That is not * a real option in an accented language: Postgres stems `auditoría` to * `auditor` and `auditoria` to `auditori` — *different lexemes* — so a query * typed without accents misses every row that carries them. */ import { CollectionConfig, SearchConfig, SearchWeight } from "@rebasepro/types"; /** * Names of the helper functions. Frozen: they are recorded in the stored * generation expression of every search column ever created, so renaming one * orphans every table that already has a search column. */ export declare const SEARCH_TEXT_FN = "public.rebase_search_text"; export declare const SEARCH_UNACCENT_FN = "public.rebase_search_unaccent"; /** How a declared path reaches text, which decides the SQL that extracts it. */ type FieldKind = "text" | "text_array" | "jsonb"; /** One resolved field: where it lives, how to read it, what it is worth. */ export interface ResolvedSearchField { /** The path exactly as the author wrote it, for error messages. */ path: string; /** The physical column the path starts at. */ column: string; /** Dotted remainder addressed inside a JSONB column, if any. */ jsonPath: string[]; kind: FieldKind; weight: SearchWeight; /** The `setweight(to_tsvector(…), 'X')` term this field contributes. */ sql: string; /** The plain-text term this field contributes, for the fuzzy column. */ textSql: string; } /** Everything the generators need to render one collection's search column. */ export interface SearchColumnSpec { schema: string; table: string; /** The generated `tsvector` column. */ column: string; language: string; unaccent: boolean; fields: ResolvedSearchField[]; /** Body of `GENERATED ALWAYS AS ( … ) STORED` for the tsvector column. */ expression: string; indexName: string; /** Extensions that must exist before the column can be created. */ extensions: string[]; fuzzy?: { column: string; expression: string; indexName: string; threshold: number; }; } /** Raised when a `search` block names something that cannot be searched. */ export declare class SearchConfigError extends Error { constructor(message: string); } /** The `search` block of a collection, or undefined when it has none. */ export declare const getSearchConfig: (collection: CollectionConfig) => SearchConfig | undefined; /** * Refuse a `search` block on a collection this engine does not store. * * The type only permits one on a `PostgresCollectionConfig`, so TypeScript * already stops the ordinary case. This catches the rest — a JS config, a cast, * a collection whose `engine` was changed after the block was written — because * the alternative is the exact failure the block exists to prevent: a developer * who declared what to index, saw no error, and got the substring fallback. * * Called with *every* collection, before the Postgres ones are filtered out. */ export declare const assertSearchIsPostgresOnly: (collections: CollectionConfig[]) => void; /** * Build the full spec for a collection, or undefined when it has not opted in. * * Throws {@link SearchConfigError} on a config that cannot be honoured. Callers * at boot surface that as a startup failure — a search block that half-works is * worse than one that refuses. */ export declare const buildSearchColumnSpec: (collection: CollectionConfig) => SearchColumnSpec | undefined; /** * The IMMUTABLE wrappers the generated expressions call. * * `CREATE OR REPLACE` so a boot against an existing database is a no-op rather * than an error, and idempotent for the same reason every other boot-time DDL * statement here is. * * The bodies are stable built-ins wrapped in an immutable promise — see the * module comment for why that promise is sound. `STRICT` matters: it makes NULL * in mean NULL out without executing the body, which is what the `coalesce` at * each call site then absorbs. */ export declare const searchHelperFunctions: (spec: SearchColumnSpec) => string[]; /** * `CREATE EXTENSION` statements the spec's expressions depend on. * * `WITH SCHEMA public` is load-bearing, not tidiness. An unqualified * `CREATE EXTENSION` installs into the first schema on `search_path`, which * defaults to `"$user", public` — and the scaffold's database role is named * `rebase`, the same as the schema the generator creates one statement earlier. * So the moment that schema exists, `CREATE EXTENSION unaccent` puts the * dictionary in `rebase`, and every reference to `public.unaccent` below fails * with "text search dictionary does not exist". Observed, not theorised. */ export declare const searchExtensionStatements: (spec: SearchColumnSpec) => string[]; /** The column definition as it appears inside `CREATE TABLE`. */ export declare const searchColumnDefinition: (spec: SearchColumnSpec) => string; /** The fuzzy column definition, when the spec asks for one. */ export declare const fuzzyColumnDefinition: (spec: SearchColumnSpec) => string | undefined; /** * Index statements for the spec. * * `CONCURRENTLY` is deliberately *not* used here. This form is emitted into a * SQL file replayed as one unit — a migration, or `search.sql` — where a * concurrent build is not allowed. The boot-time ensure path runs statement by * statement against tables that are live and populated, and uses the * concurrent form instead; see `ensureSearchColumns`. */ export declare const searchIndexStatements: (spec: SearchColumnSpec) => string[]; /** * Marker on the comment of every generated search column this module creates. * * Versioned because the fingerprint below is only comparable against itself: a * future change to how it is computed has to read as "not stamped by this * version" rather than as drift on every existing column. */ export declare const SEARCH_STAMP_PREFIX = "rebase:search:v1:"; /** * A stable fingerprint of one generated column's expression. * * Why a stamp rather than reading the expression back: Postgres stores a * generated column's expression *parsed*, and hands it back deparsed — casts * made explicit, identifiers requoted, schema qualifications added or dropped * according to `search_path`. Comparing that text to the text we generated * would report drift on wording, and this comparison decides whether a boot * refuses, so a false positive is an outage. The stamp is written by the same * code that writes the column, so equality means what it says. */ export declare const searchExpressionFingerprint: (expression: string) => string; /** One generated column, with the fingerprint that identifies its expression. */ export interface SearchColumnStamp { column: string; /** The expression the column is generated from. */ expression: string; fingerprint: string; /** `COMMENT ON COLUMN …`, which is where the fingerprint is recorded. */ sql: string; } /** * The stamps for a spec's generated columns — one per column, never shared. * * Per column on purpose: turning `fuzzy` on adds a second column and changes * nothing about the first, and a spec-wide fingerprint would report the * untouched `tsvector` column as drifted and refuse a boot over a change that * is purely additive. */ export declare const searchColumnStamps: (spec: SearchColumnSpec) => SearchColumnStamp[]; /** * The same drift check as the boot ensure, for the SQL file. * * Needed because {@link searchColumnStamps} would otherwise *launder* drift on * the migration path: `ADD COLUMN IF NOT EXISTS` does nothing to a column that * exists, so a re-generated `search.sql` would stamp a stale column with the * new block's fingerprint and the next boot would find them in agreement. * Guarding first means the file refuses instead — `rebase db push` is attended, * and the operator reading the failure is the person who changed the block. */ export declare const searchStampGuards: (spec: SearchColumnSpec) => string[]; /** * The index names the spec creates. * * Needed by name, not just by statement, so Atlas can be told to exclude them * from its diff — see `searchExcludePatterns`. */ export declare const searchIndexNames: (spec: SearchColumnSpec) => string[]; /** * The generated column names a collection's search block adds, if any. * * These are physical columns on the table, so `SELECT *` returns them. They are * an index in column form — a list of lexeme positions, or a concatenation of * every searchable field on the row — and nothing outside the query planner has * any use for them. Left in, every list response carries a second, larger copy * of the row's text. */ export declare const searchColumnNames: (collection: CollectionConfig) => string[]; /** * True for a column whose type only ever holds a search index. * * Independent of any collection config on purpose: an introspected database * (BaaS mode) can carry a `tsvector` column this framework never created — * Pagila's `film.fulltext` is the canonical one — and it should not be returned * to callers either. `isDerivedIndexColumn` already keeps such a column out of * the *properties*; this keeps it out of the *rows*. */ export declare const isSearchIndexColumn: (column: { getSQLType?: () => string; }) => boolean; /** * A drizzle select projection over `table` with the search columns dropped. * * Returns undefined when nothing needs dropping, so the common case keeps using * a plain `select()` and this stays invisible in the generated SQL. */ export declare const visibleColumnProjection: (tableColumns: Record string; }> | undefined, collection?: CollectionConfig) => Record | undefined; /** The same exclusion as a drizzle `db.query` `columns` denylist. */ export declare const hiddenColumnsOption: (tableColumns: Record string; }> | undefined, collection?: CollectionConfig) => Record | undefined; export {};