---
title: "Search and vector search"
description: "Index records and search them with facets, highlights and typo tolerance, with every result filtered by tenant and by what the searcher may read."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "search"
canonical: "https://mainsdk.dev/docs/interfaces/search"
updated: "2026-10-28"
---

# Search and vector search

> Index records and search them with facets, highlights and typo tolerance, with every result filtered by tenant and by what the searcher may read.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/search · Updated: 2026-10-28

`search` indexes your records and returns only what the searcher may see: tenant and [`access`](https://mainsdk.dev/docs/interfaces/access) filters run inside the engine's query. Reach for it for a catalogue, a feed or a help centre.

> **Note:** Built in 0.4: the `db` driver (a portable SQL inverted index, not native full-text search) and the Typesense and Meilisearch drivers, the latter two tested against in-repo fakes. Vector search and Algolia are planned later.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/search.md
- Category: Data

Calls:

- `search.index()`
- `search.query({ for: user })`
- `search.remove(index, ids)`
- `search.status(index)`
- `search.reindex(index, { region?, tenant? })`

In main.yaml:

```yaml
search:
  driver: db
  timeout: 2s
  max-depth: 1000
  # used when the driver is typesense or meilisearch
  typesense: { key: "secret:TYPESENSE_KEY", regions: { eu: "https://eu.search.example.com" } }
  meilisearch: { key: "secret:MEILI_KEY", regions: { eu: "https://eu.meili.example.com" } }
  indexes:
    posts:
      model: posts
      access: posts.read
      fields:
        text: { search: true, weight: 2 }
        tags: { facet: true, filter: true }
        createdAt: { sort: true, filter: true }
```

Guarantees, whatever the driver:

- Every query is filtered by the tenant and by access inside the engine's query, never after it
- Facet counts and totals count only what the searcher may read
- Index updates go through the job outbox, never the request path
- One index per region; a document is indexed only in its tenant's region
- Labelled fields stay out of indexes unless allowed; erasure reaches every index


## Drivers

Pick one in main.yaml with `search.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in on db (Postgres full-text search, SQLite FTS5 locally) | `db` | yes |  |
| Typesense | `typesense` |  |  |
| Meilisearch | `meilisearch` |  |  |
| Local (in process | `local` |  |  |
| Algolia | `algolia` |  | planned driver |
| pgvector | `pgvector` |  | planned driver |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-SEARCH-0010` | check | An index's access filter needs a field the index doesn't store, or the access driver's profile can't list for that resource type |
| `MAIN-SEARCH-0011` | check | A field labelled `sensitive`, `phi`, `child` or `secret` is indexed without `index: allow` on the field |
| `MAIN-SEARCH-0012` | check | An engine driver has no endpoint for a region the app stores data in |
| `MAIN-SEARCH-0013` | check | An engine SDK (`typesense`, `meilisearch`, `algoliasearch`) imported outside a driver |
| `MAIN-SEARCH-0014` | check | `search.query` without `for` in a file outside `tenancy.system.allow` |
| `MAIN-SEARCH-0001` | runtime | The index isn't declared under `search.indexes` (400) |
| `MAIN-SEARCH-0002` | runtime | The access filter for this searcher can't be expressed in the engine's query; refused, never post-filtered (403) |
| `MAIN-SEARCH-0003` | runtime | `search.query` without `for`, outside `tenant.system` (400) |
| `MAIN-SEARCH-0004` | runtime | The engine is unreachable or the query passed `timeout` (503, retryable); index changes stay queued |
| `MAIN-SEARCH-0005` | runtime | A filter, sort or facet names a field not declared for it, or a reserved field (`_tenant`, `_region`, `_v`) (400) |
| `MAIN-SEARCH-0006` | runtime | No index endpoint for the tenant's region; the change stays queued and is sent nowhere else (logged) |
| `MAIN-SEARCH-0007` | runtime | A cursor would go past `max-depth` (400) |
| `MAIN-SEARCH-0008` | runtime | `q` is longer than 512 characters, or a filter value has the wrong type for its field (400) |
| `MAIN-SEARCH-0009` | runtime | An index change failed after its last retry and is in the dead-letter queue; counted in `search.status` |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| search@1 contract and index declarations | 1 | 0.4 | `search#contract` |
| db driver on Postgres full-text search and SQLite FTS5 | 1 | 0.4 | `search#db-driver` |
| Typo tolerance | 1 | 0.4 | `search#typo` |
| Facets | 1 | 0.4 | `search#facets` |
| Cursor pages up to max-depth | 1 | 0.4 | `search#paging` |
| Tenant and access filter inside the query | 1 | 0.4 | `search#access-filter` |
| Index updates through the outbox | 1 | 0.4 | `search#outbox` |
| Fail fast when the engine is down | 1 | 0.4 | `search#fail-static` |
| One index per region | 1 | 0.4 | `search#regions` |
| mainsdk search reindex | 1 | 0.4 | `search#reindex` |
| Labels and erasure | 1 | 0.4 | `search#privacy` |
| Typesense and Meilisearch drivers | 1 | 0.4 | `search#engines` |
| Search checks | 1 | 0.4 | `search#checks` |
| Vector search | 2 | 0.6 |  |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

Declare an index that mirrors a `db` model, then query it with `for`.

```yaml title="main.yaml"
search:
  driver: db
  indexes:
    posts:
      model: posts
      access: posts.read
      fields:
        text:   { search: true, weight: 2 }
        tags:   { facet: true, filter: true }
```

```ts title="app/api/search/route.ts"
import { auth, search } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  const q = new URL(req.url).searchParams.get("q") ?? "";
  const page = await search.query({ index: "posts", q, for: { type: "user", id: session.user }, highlight: true, limit: 10 });
  return Response.json(page);
}
```

`for` is required: a query without it fails with `MAIN-SEARCH-0003`. Writes to the `posts` model reach the index through the job outbox, never the request path.

## Configuration

```yaml title="main.yaml"
search:
  driver: db                       # db (default) | typesense | meilisearch; local for tests
  timeout: 2s                      # a slower query fails with MAIN-SEARCH-0004
  max-depth: 1000                  # hits a cursor chain may reach
  indexes:
    posts:
      model: posts
      access: posts.read
      language: english            # "simple" for no stemming
      fields:
        text:       { search: true, weight: 2 }
        author:     { search: true, facet: true }
        createdAt:  { sort: true, filter: true }
        visibility: { filter: true }
      typo: { one: 4, two: 8 }     # word length for one and two typos; false turns it off
  typesense:
    key: secret:TYPESENSE_API_KEY
    regions: { eu: https://eu.search.example.com, us: https://us.search.example.com }
```

| Key | What it does |
| --- | --- |
| `driver` | `db` uses Postgres full-text search (SQLite FTS5 locally); `typesense` and `meilisearch` use an engine, with one endpoint per region |
| `indexes.<name>.model` | The `db` model the index mirrors |
| `indexes.<name>.access` | The access action that decides who may read a hit |
| `indexes.<name>.fields.<f>` | `search`, `weight`, `facet`, `filter` or `sort` for the field |
| `timeout`, `max-depth` | Query time limit and the deepest cursor chain |

## What you get

```text
search.index(index, docs, { idempotencyKey? })      // queued through the outbox
search.remove(index, ids)
search.query({ index, q, for, filter?, facets?, sort?, highlight?, typo?, limit?, cursor? })
search.status(index)                                // pending, failed, lag and version per region
search.reindex(index, { region?, tenant? })         // a workflow; resumes after a crash
```

`search.query` returns `{ items, next, facets, estimated, tookMs }`. Each item is `{ id, doc, score, highlights }`; highlights are HTML-escaped and only `<mark>` is markup. Tenant and region come from context, never from the document you index.

## Examples

### Facets and filters

```ts title="lib/browse.ts"
import { search } from "mainsdk";

export function browse(user: { type: "user"; id: string }) {
  return search.query({
    index: "posts",
    q: "running",
    for: user,
    filter: { tags: { in: ["go", "rust"] } },
    facets: ["tags"],
  });
}
```

### Ground an AI answer in search results

```ts title="lib/answer.ts"
import { ai, search } from "mainsdk";

export async function answer(question: string, user: { type: "user"; id: string }) {
  const page = await search.query({ index: "articles", q: question, for: user, limit: 5 });
  return ai.generate({
    model: "anthropic/claude-sonnet-4-5",
    prompt: `Answer from these help articles only.\n\n${JSON.stringify(page.items)}\n\nQuestion: ${question}`,
    feature: "help-answer",
    user,
  });
}
```

Because hits are scoped to `user`, the model only sees records that person may read.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-SEARCH-0001` | The index is not declared under `search.indexes` | Declare it |
| `MAIN-SEARCH-0002` | The access filter cannot be expressed in the engine's query | Narrow the policy; results are never filtered after the fact |
| `MAIN-SEARCH-0003` | `search.query` without `for` | Pass the searcher |
| `MAIN-SEARCH-0004` | The engine is unreachable or the query passed `timeout` (503, retryable) | Retry; index changes stay queued |
| `MAIN-SEARCH-0005` | A filter, sort or facet names a field not declared for it, or a reserved field | Declare the field for that use |

The spec also defines `MAIN-SEARCH-0006` to `0014` (regions, paging depth, dead letters and the check rules). See [check codes](https://mainsdk.dev/docs/reference/check-codes) once the spec is built.

## Swapping drivers

```yaml title="main.yaml"
search:
  driver: typesense   # was db
```

Your calls stay the same. An index lives in the engine, so moving engines means a reindex (`search.reindex`). See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

The `db` driver runs on SQLite in tests, so no fake is needed. The test that matters most proves a user never gets another user's records. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Security and privacy

- Results are scoped to the tenant and to what the searcher may read, inside the engine's query.
- An index is a copy of your data. A field labelled `sensitive`, `phi`, `child` or `secret` needs `index: allow` to be indexed, or the check fails with `MAIN-SEARCH-0011`.
- Each region has its own index; a change is never sent to another region. `privacy.erase` removes a person's documents.

## FAQ

**Do I need a separate engine?** No. The default `db` driver runs on your own database.

**Is vector search available?** Not in the 0.4 spec. It is planned later.

## Next

- [Data and records](https://mainsdk.dev/docs/interfaces/db)
- [AI calls](https://mainsdk.dev/docs/interfaces/ai)
- [Permissions](https://mainsdk.dev/docs/interfaces/access)
