Full-text search
.fullTextIndex() + .matching() for tsvector / FULLTEXT INDEX search across columns. Cross-dialect per-engine where available.
.fullTextIndex(name, columns, options?) declares an FTS index on
one or more text columns. .matching('indexName', 'query') on the
query builder runs the search.
Quick start
import { id, text, table } from '@voltro/database'
export const posts = table('posts', {
id: id(),
title: text(),
body: text(),
}).fullTextIndex('postSearch', ['title', 'body'], {
config: 'english',
weights: { title: 'A', body: 'B' },
})
// Query
const hits = await ctx.store.query(
queryFor(database.posts).matching('postSearch', 'voltro effect').descriptor,
)What the framework emits
Postgres — tsvector + GIN
Postgres' canonical pattern:
ALTER TABLE "posts"
ADD COLUMN IF NOT EXISTS "postSearch_tsv" tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
setweight(to_tsvector('english', coalesce("body", '')), 'B')
) STORED;
CREATE INDEX IF NOT EXISTS "postSearch" ON "posts" USING GIN ("postSearch_tsv");The framework adds the synthetic <index>_tsv STORED tsvector
column + a GIN index. .matching(...) queries compile to:
"postSearch_tsv" @@ plainto_tsquery('voltro effect')MySQL / MariaDB — FULLTEXT INDEX
CREATE FULLTEXT INDEX `postSearch` ON `posts` (`title`, `body`);.matching(...) queries compile to:
MATCH("title", "body") AGAINST('voltro effect' IN NATURAL LANGUAGE MODE)The IN NATURAL LANGUAGE MODE is mysql's default-tier ranking —
relevance-weighted, no Boolean operators. For Boolean mode
(+voltro +effect), use the raw SQL escape hatch.
SQLite — native FTS5
The migrator creates a native FTS5 virtual table named
<table>_<index>_fts plus three triggers that keep it in lockstep
with the base table on every INSERT / UPDATE / DELETE:
CREATE VIRTUAL TABLE IF NOT EXISTS "posts_postSearch_fts"
USING fts5("title", "body", row_id UNINDEXED, tokenize='porter');
-- + AFTER INSERT / UPDATE / DELETE triggers syncing row_id = posts.idrow_id carries the base row's string id (FTS5's integer rowid
can't hold a TypeID). .matching(...) queries compile to:
"id" IN (SELECT "row_id" FROM "posts_postSearch_fts" WHERE "posts_postSearch_fts" MATCH 'voltro effect')tokenize='porter' gives stemming comparable to postgres' 'english'
config + mysql's natural-language mode.
MSSQL — ranked LIKE fallback
MSSQL native full-text needs a server-managed CREATE FULLTEXT CATALOG (operations work, not always available). Rather than require
that, the framework emits a portable ranked-LIKE search at query
time — no catalog, no CONTAINS, works on every MSSQL install. The
migrator emits no FTS DDL (every base column is already present) and
notes the fallback at boot:
[voltro:migrate] full-text index 'postSearch' on 'posts':
mssql uses a ranked LIKE search at query time (no native FTS catalog required).
.matching(...) compiles to a per-column case-insensitive LIKE
disjunction:
(LOWER("title") LIKE LOWER('%voltro effect%') ESCAPE '\'
OR LOWER("body") LIKE LOWER('%voltro effect%') ESCAPE '\')This is substring matching, not tokenised FTS — it won't stem or rank
on term frequency the way postgres/mysql/sqlite do. It's the honest
portable path; for true server FTS on MSSQL set up a catalog + use raw
SQL with CONTAINS.
Turso — the same LIKE fallback
Turso's MVCC mode has no virtual tables, so there is no FTS5 index to
create. The migrator creates none, and the turso store runs
.matching(...) as the same case-insensitive LIKE disjunction as
mssql, with .rankBy() counting the columns that hit. Queries, the
table and the .fullTextIndex() declaration stay the same on every
dialect; only the ranking is coarser.
In-memory store — word match
The in-memory store (store: 'memory', and every @voltro/testing context)
answers .matching(...) itself, so a test means what the query means on a SQL
store: a row matches when every word of the query starts a word in one of
the index's columns, case-insensitively. It does not stem. .rankBy() scores a
weighted hit count (weights A 4, B 2, C 1, D 0.5; unweighted columns 1).
In migrations — created after the plan, never in it
The objects above are not schema operations. voltro dev, voltro db apply and
voltro db deploy create them after the schema plan, on every dialect, and
voltro db plan never lists them: after a deploy the next plan is empty, and a
later deploy neither drops nor re-creates an index that exists. Removing a
.fullTextIndex() declaration leaves its objects in place — drop them in a
file migration if you want them gone.
Word beginnings — { prefix: true }
queryFor(posts).matching('postSearch', 'tomato bed', { prefix: true })Each word of the query then matches as a word beginning, and every word is
required: tomato bed finds "Tomatoes in raised beds". Postgres compiles it to
to_tsquery(cfg, 'tomato:* & bed:*'), MySQL/MariaDB to AGAINST('+tomato* +bed*' IN BOOLEAN MODE), SQLite to the FTS5 query "tomato"* "bed"*; MSSQL's LIKE
fallback and the in-memory store already match that way. Only the letters and
digits of the query reach the database — operators a user types (|, !,
-, OR, NOT, quotes) are dropped, so no dialect's query syntax can be
injected. A query with no letters or digits falls back to the plain match.
Configuring per-language
.fullTextIndex('postSearch', ['title', 'body'], {
config: 'german', // postgres tsvector config
weights: { title: 'A', body: 'B', tags: 'C' },
})config defaults to 'english'. Postgres ships configs for
~20 languages; install additional ones via CREATE TEXT SEARCH CONFIGURATION if needed. The framework emits the config name
verbatim into the to_tsvector(...) call.
weights is ignored on non-postgres dialects (no per-column
weight concept in FULLTEXT INDEX).
Multi-column with per-column weights
.fullTextIndex('postSearch', ['title', 'body', 'tags'], {
weights: { title: 'A', body: 'B', tags: 'C' },
})Postgres combines them in the generated tsvector — a query that
matches title ranks higher than one that only matches tags.
Relevance ranking — .rankBy()
Chain .rankBy() after .matching(...) to surface a relevance score
column AND order results best-match-first — on every dialect:
queryFor(database.posts)
.matching('postSearch', input.query)
.rankBy() // adds a `rank` column + ORDER BY rank DESC
.limit(20)Each returned row carries a numeric rank (higher = better). The
score expression is per-dialect, but the API is identical:
| Dialect | Score expression |
|---|---|
| postgres | ts_rank(<tsvCol>, plainto_tsquery(...)) |
| mysql / mariadb | MATCH(cols) AGAINST(...) score |
| sqlite | -bm25(<fts>) (negated so higher = better) |
| mssql | count of columns whose LOWER LIKE the query |
| memory | weighted count of word hits |
Options:
.rankBy({ alias: 'score' })— name the columnscoreinstead ofrank..rankBy({ order: false })— surface the score column WITHOUT forcingORDER BY(combine with your own.orderBy(...)).
// Score column, but sort by recency with relevance as a secondary key:
queryFor(database.posts)
.matching('postSearch', input.query)
.rankBy({ order: false })
.orderBy('createdAt', 'desc')
.limit(20)On postgres the ts_rank reads the same stored tsvector column the
GIN index covers, so ranking stays index-accelerated.
Querying
.matching('indexName', 'query') is composable with other chain
methods:
queryFor(database.posts)
.where(eq('orgId', oid)) // pre-filter
.matching('postSearch', input.query) // FTS
.orderBy('createdAt', 'desc') // tie-breaker
.limit(20)Multiple .matching() calls override — FTS is a single-string
concept, no AND-merge across queries.
Limitations
- MSSQL is a ranked LIKE fallback, not tokenised FTS. It matches
substrings (
LOWER(col) LIKE '%query%') and ranks by per-column hit count — no stemming, no term-frequency weighting. For true server FTS on MSSQL, set up aCREATE FULLTEXT CATALOGand use raw SQL withCONTAINS. - Boolean / phrase queries on postgres —
.matching(...)usesplainto_tsquerywhich strips operators. For Boolean (+voltro -effect) drop tostore.rawwithto_tsquery(...):
The query string binds as a parameter;import { sql } from '@voltro/database/sql' await ctx.store.raw!<{ id: string }>(sql` SELECT id FROM posts WHERE "postSearch_tsv" @@ to_tsquery(${input.query}) `, { dependsOn: ['posts'] })to_tsqueryparses its operators (+,-,&,|,<->). - Reactivity — FTS subscriptions ARE reactive on postgres (the tsvector column is a regular column the dispatcher tracks). On mysql/mariadb the MATCH AGAINST predicate isn't bucketed into the matcher's index — subscriptions re-evaluate on every write to the source columns.
When NOT to use
- Trigram / fuzzy match — postgres'
pg_trgmextension is a different access pattern. Install + use raw SQL. - Vector search — see Vector columns for embeddings-based semantic search.
- Substring search on a small table —
LIKE '%term%'is fine up to ~100k rows. Don't carry the FTS overhead for tiny tables.
See also
- Generated columns — the FTS pattern is "generated tsvector + GIN expression index"
- Indexes —
.expressionIndex({ kind: 'gist' })for the GIN/GiST plumbing under FTS - Vector columns — for embedding-based semantic search (different problem from keyword FTS)