Search
Keep an external search index (Typesense / Meilisearch / Algolia) in sync with your tables via the ChangeEvent tap, query it tenant-scoped through a typed action + hook.
@voltro/plugin-search mirrors your tables into an external search engine. It rides the ChangeEvent tap — every insert/update/delete on a configured table maps into the index automatically; a typed search.query action reads it back, tenant-scoped, with a useSearch hook on the client.
Wiring
// app.config.ts
import { searchPlugin } from '@voltro/plugin-search'
export default {
type: 'api' as const,
name: 'api',
plugins: [
searchPlugin({
// backend: defaults to memory (dev). Swap for production:
// backend: typesenseBackend({ url, apiKey }) | meilisearchBackend({ ... }) | algoliaBackend({ ... })
indexes: {
posts: {
index: 'posts',
tenantField: 'tenantId', // index documents carry the tenant → query scopes by it
map: (row) => ({ id: String(row.id), title: String(row.title), body: String(row.body) }),
},
},
}),
],
}How sync works
The plugin declares onChangeEvent. On every committed write to a configured table, applyChange maps the row via map(row) and upserts (insert/update) or removes (delete) the index document. No *.subscribe.ts, no manual indexing calls — the tap is the single sync path, and it runs under both voltro dev and voltro serve.
For pre-existing rows, backfillIndex(backend, spec, rows) indexes the rows you supply (call it from a *.startup.tsx or a one-off script) — or use the Reindex button / POST /reindex inspect endpoint, which reads the table's current rows for you.
Querying
search.query is a typed action (not a streaming query — search results aren't reactive):
import { useSearch } from '@voltro/plugin-search/web'
const { results, facets, run, pending } = useSearch('posts')
run('voltro effect') // → results scoped to the caller's tenant
run('voltro effect', { limit: 20, offset: 20 }) // offset paging — page 2results are the hit documents; facets are the facet counts from the last run. The query is automatically filtered to the caller's tenantId (from the resolved Subject) against the index's tenantField — no cross-tenant leak.
run(q, { limit, offset }) pages the results: offset skips leading hits, limit bounds the window. It maps to each engine's native paging (memory slice, Typesense/Meilisearch offset, Algolia offset+length).
Query features
Every option is plain JSON and round-trips through the action's Schema:
import { memoryBackend } from '@voltro/plugin-search'
const backend = memoryBackend()
const res = await backend.query('posts', {
q: 'kubernetis', // matches the typo below
limit: 20,
offset: 20, // offset paging
fuzziness: 'auto', // typo tolerance (or a number: max edits, 0 = exact)
filters: [
{ field: 'status', op: 'neq', value: 'draft' }, // negation
{ field: 'score', op: 'gte', value: 50 }, // numeric range
{ field: 'publishedAt', op: 'lt', value: '2026-06-01T00:00:00Z' }, // date range
{ field: 'tag', op: 'in', value: ['a', 'b'] }, // set membership
],
facets: ['status', 'tag'], // facet counts per field
highlight: { fields: ['title'], preTag: '<mark>', postTag: '</mark>' },
engineParams: { num_typos: 1 }, // escape hatch → forwarded verbatim to the engine
})
// res.hits: [{ doc, highlights? }, …] res.facets: { status: { open: 12, done: 3 }, … }- filters — a list of clauses ANDed together, each
{ field, op, value }withop ∈ eq | neq | gt | gte | lt | lte | in | nin. Range (gt/lte/…) and negation (neq/nin), not equality-only. - facets — per-value counts for the named fields (over the full matched set, before paging).
- highlight — matched-term snippets per field, returned as
hit.highlights[field]. - fuzziness — a max edit distance (
0= exact) or'auto'. - engineParams — an escape-hatch bag forwarded verbatim into the underlying engine's search call. Ignored by the memory backend.
Native support degrades honestly: memory, Typesense, Meilisearch and Algolia all do filters/facets/highlighting; Typesense honors a numeric typo count (num_typos 0–2) while Meilisearch and Algolia only toggle typo tolerance on/off (fuzziness: 0 disables it, other values keep their built-in tolerance). A backend op fails with a typed SearchBackendError.
Backends
| Backend | Notes |
|---|---|
memoryBackend (default) |
Fully in-process; dev + tests. Not for production scale. |
typesenseBackend |
Optional dep typesense. Lazy-loaded. |
meilisearchBackend |
Optional dep meilisearch. Lazy-loaded. |
algoliaBackend |
Optional dep algoliasearch. Lazy-loaded. |
A backend is the SearchBackend interface (upsert / remove / query) — bring your own (OpenSearch, Elastic, …).
Dashboard panel
Both dashboards ship a Search panel (api apps): the configured indexes with per-index sync stats (docs synced/removed, last reindex) + the resolved backend, and a Reindex button per index that re-seeds it from the table's current rows (backfillIndex). Reindex gates on the canReindexSearch capability. Backed by /_voltro/inspect/plugins/search/{indexes,reindex}.
The sync stats behind this panel are durable and aggregated across replicas. They live in a framework-owned _voltro_search_stats table (contributed via extendSchema.tables; the plugin declares store:write), one row per (index, replica), each bumped with an atomic compare-and-set. /indexes sums every replica's row and takes the most-recent reindex — so the counts are truthful under multiple instances and survive a restart (they no longer reset with the process, and no replica under-reports the fleet total). Zero-infra dev/tests use an in-process stats store; bindDataStore swaps in the durable one at boot.
Permissions
store:changes:read (the ChangeEvent tap) + store:write (the durable _voltro_search_stats counters) + inspect:read (dashboard panel) + network:outbound:<host> (for a remote backend).