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 acme

What 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.json

The 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 tenant

The /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-backend patterns 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 a search.query reading the other. Create ONE instance in lib/search.ts and share it (this template's whole reason for that file).
  • Hand-indexing in your mutation. Don't searchBackend.upsert(...) inside articles.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.query returns every tenant's hits — a cross-tenant leak. Set tenantField on any index over a tenant()-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 backfillIndex is what guarantees pre-existing rows are in the index — keep it.