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 2

results 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 } with op ∈ 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).