Search
pg_trgm trigram search baked into Postgres — fuzzy matching with no extra service to run.
Search ships as pg_trgm trigram indexes on Postgres. No separate service, no extra infra, fast enough for ~1M rows. Migration 0008_pg_trgm.sql enables the extension and creates GIN indexes on the columns that get searched (user name/email, audit action/resource, post title/slug).
Helpers
packages/db/src/search.ts exposes typed wrappers:
searchUsers(q, { limit })— name + email (admin directory search)searchAuditLog(q, { userId, limit })— action + resource (scoped to the caller)searchPosts(q, { userId, limit })— title + slug (scoped to the caller)trigramSearchClause(columns, q)— drop-inWHEREbuilder for ad-hoc queries
Each result row carries a rank field (the strongest column-level similarity() score), so callers can pick a threshold or display "best match first".
import { searchUsers } from "@workspace/db";
const hits = await searchUsers("alise", { limit: 10 });API
GET /api/v1/search?q=<term>&type=users|audit|posts&limit=25The whole route requires the user:list permission (admin/moderator). users matches name + email across the directory; audit and posts are scoped to the caller's userId. Empty q returns an empty result set rather than dumping the table.
UI
The admin app has a working search box at /users that calls the endpoint. Mirror the pattern (debounced input + ranked list) for any other entity you want to search.
When to upgrade
pg_trgm is great until one of these starts hurting:
- More than ~1M rows in the searched table — index size and query time both grow.
- You need real typo tolerance with edit-distance scoring (e.g. "tehre" ↔ "there").
- You want stemming or multi-language ranking ("running" matching "run").
- You want a single search box across many entity types with relevance tuning.
At that point bring in Meilisearch (or Typesense) as a sidecar. The recipe is:
- Index user-scoped documents on write (background job in
apps/worker). - Replace
searchUsersetc. with calls to the search service while keeping the same return shape. - Keep
pg_trgmas the fallback so dev works without the extra service running.
We deliberately don't ship Meilisearch in the box — most apps never need it, and adding a second moving part by default makes the boilerplate harder to deploy.