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.
search.query is a SEPARATE surface with its own access decision, made by the plugin. The boot gate that requires guards: / openAccess: / internal: true reads only the procedures you wrote — a plugin's are the plugin author's call and you cannot edit them. So the two files this template ships, queries/articles.list.query.ts and mutations/articles.create.mutation.ts, each carry their own openAccess: with the reason in the file (the rows are the template's own seeded demo articles; no auth strategy ships here, so a scope guard would deny every caller). Anything you add beside them needs one of the three from the first save, or the app will not boot. See Authorization.
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, and it is not optional
memoryBackend() is in-process: great for dev, and under
NODE_ENV=production it refuses to boot (SearchBackendNotDurable). This
template ships it because it needs zero infrastructure to run locally — it is
not a deployable default, and a deploy that keeps it does not start. 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! })Single-instance is not the exception it sounds like. The heap-resident index
is empty after every restart and deploy, so a genuinely one-process app searches
nothing until something re-seeds it. If that is your deployment and a
*.startup.tsx really does call backfillIndex for every index, declare it —
searchPlugin({ singleProcessMemoryIndex: true, indexes }) — and the plugin
will hold you to both halves, warning if a peer replica ever announces itself.
Full reasoning: the memory backend refuses to boot in production.
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.