API · Search
Full-text search that stays in sync — @voltro/plugin-search mirrors every table write into an index via the post-commit change tap; the synthesized search.query rpc returns tenant-scoped hits. Memory backend (zero infra); swap one line for Typesense/Meilisearch/Algolia.
Search that never drifts from your data. @voltro/plugin-search taps the post-commit ChangeEvent stream: every insert/update to a source table upserts its doc into a search index, every delete removes it — no app code, no cron, no manual reindex. The plugin synthesizes a search.query rpc that returns hits auto-filtered to the caller's tenant. The template runs on memoryBackend() (in-process, zero infra) so it boots and validates with nothing installed; going to production is one line in lib/search.ts. Template id: api-search.
Scaffold
voltro create-project acme --api=api-search
voltro add-app search --template=api-search --to acmeWhat ships
apps/acme/api/
├── app.config.ts # store:'memory', plugins: [searchPlugin({ backend, indexes })]
├── lib/
│ └── search.ts # the SHARED backend instance + the articles index spec
├── database/schema.ts # actors, tenants, articles (tenant-scoped, reactive)
├── mutations/articles.create.* # writes that auto-index via the tap
├── queries/articles.list.* # a live list to sit next to the search box
├── seeds/articles.seed.ts # demo data
├── startup/searchBackfill.startup.tsx # backfillIndex() on boot
├── package.json # + @voltro/plugin-search
└── tsconfig.jsonThe index — searchPlugin + a shared backend
lib/search.ts holds the wiring, shared so app.config.ts (the plugin) and the boot startup (the backfill) use the same backend instance — memoryBackend() is a closure over a Map, so two calls would be two separate indexes:
// lib/search.ts
import { memoryBackend, type IndexSpec, type SearchDoc } from '@voltro/plugin-search'
export const searchBackend = memoryBackend()
export const articlesIndex: IndexSpec = {
index: 'articles',
// Flatten a row → a search doc (must include `id`).
map: (row): SearchDoc => ({
id: row['id'] as string,
title: row['title'] as string,
body: row['body'] as string,
tag: row['tag'] as string,
tenantId: row['tenantId'] as string,
}),
// Makes search.query auto-filter to the caller's tenant — no leakage.
tenantField: 'tenantId',
}// app.config.ts
import { searchPlugin } from '@voltro/plugin-search'
import { searchBackend, articlesIndex } from './lib/search'
export default {
type: 'api' as const, name: 'AcmeSearch', store: 'memory' as const,
plugins: [searchPlugin({ backend: searchBackend, indexes: { articles: articlesIndex } })],
}Live sync — the change tap
There is no indexing code in your handlers. The plugin registers an onChangeEvent tap; when a mutation commits a write to articles, the plugin maps the row and upserts it (or removes it on delete). So a freshly-created article is searchable on the very next request:
# create an article — indexed on commit
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d '{"tag":"articles.create","input":{"tenantId":"acme","title":"Reactive queries","body":"Subscriptions push deltas live.","tag":"guide"}}'
# …then find it
curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json' \
-d '{"tag":"search.query","input":{"index":"articles","q":"reactive"}}'
# → { ok:true, result:[ { id, title:"Reactive queries", … } ] }search.query — tenant-scoped by construction
The plugin synthesizes the search.query rpc — input { index, q, limit?, filters? }, output the matching docs. Because the index spec set tenantField: 'tenantId', the query auto-filters to the caller's tenant: a request resolved to tenant acme only ever sees acme docs, even though every tenant's docs share one index. Searching the same term as a different tenant returns nothing.
Backfill existing rows
The change tap covers writes AFTER boot. startup/searchBackfill.startup.tsx seeds the index from rows already in the table — the demo seed's articles, or every row after a restart or a switch to a new engine. It uses the same searchBackend instance, so the docs it writes are exactly what search.query reads:
import { backfillIndex } from '@voltro/plugin-search'
import { database } from '../database/schema'
import { searchBackend, articlesIndex } from '../lib/search'
export default async ({ store, log }) => {
const rows = await store.query(database.articles.descriptor)
const count = await backfillIndex(searchBackend, articlesIndex, rows)
log.info(`search: backfilled ${count} article(s) into the index`)
}On the web side
import { useSearch } from '@voltro/plugin-search/web'
const { results, run, pending } = useSearch('articles')
// run('reactive') → results auto-scoped to the signed-in tenantThe /web subpath imports nothing server-only — it's just a query over the search.query rpc, browser-safe.
Going to production — one line
memoryBackend() is in-process: great for dev, single-instance only. For real deployments swap it in lib/search.ts for a vendor engine — the indexing + query + backfill code is identical:
import { typesenseBackend } from '@voltro/plugin-search' // or meilisearch / algolia
export const searchBackend = typesenseBackend({ url: process.env.TYPESENSE_URL!, apiKey: process.env.TYPESENSE_KEY! })When to use api-search vs. the other backends
| You want… | Pick |
|---|---|
| Full-text search kept in sync with a table | api-search |
| Semantic / vector search + a RAG agent | api-ai (a vectorEmbedding() index + an agent) |
| The minimal CRUD backend | api-backend |
api-search is keyword/full-text (Typesense-style); api-ai is embeddings/semantic. They compose — hybridSearch fuses both.
Pairs well with
- Any web template — drop a search box wired to
useSearch('articles'). api-backendpatterns for the rest of the CRUD surface around the searchable table.
Anti-patterns
- Two
memoryBackend()instances. It's a closure over a Map — calling it twice gives two separate indexes, so a backfill into one is invisible to asearch.queryreading the other. Create ONE instance inlib/search.tsand share it (this template's whole reason for that file). - Hand-indexing in your mutation. Don't
searchBackend.upsert(...)insidearticles.create— the change tap already does it on commit. Doubling up risks drift and races. Let the tap own the index. - Forgetting
tenantField. Without it,search.queryreturns every tenant's hits — a cross-tenant leak. SettenantFieldon any index over atenant()-scoped table. - Relying on the seed's rows being indexed without the backfill. The seed writes through the RAW store (no subject); whether that fires the tap is store-dependent. The boot
backfillIndexis what guarantees pre-existing rows are in the index — keep it.