MainSDK
Search⌘K
Interfaces

Data

Search and vector search

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.

Building for 0.4ships as a beta APIPhase 1 · Core, public betaSpec searchFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

search indexes your records and returns only what the searcher may see: tenant and access filters run inside the engine's query. Reach for it for a catalogue, a feed or a help centre.

At a glance

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 }

Calls

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

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

6 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in on db (Postgres full-text search, SQLite FTS5 locally) (default)
search:
  driver: db

Change this one line to swap providers; your calls stay the same.PostgreSQL integration

Codes

CodeWhere it appearsMeaning
MAIN-SEARCH-0010Checkmainsdk check, before deployAn 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-0011Checkmainsdk check, before deployA field labelled sensitive, phi, child or secret is indexed without index: allow on the field
MAIN-SEARCH-0012Checkmainsdk check, before deployAn engine driver has no endpoint for a region the app stores data in
MAIN-SEARCH-0013Checkmainsdk check, before deployAn engine SDK (typesense, meilisearch, algoliasearch) imported outside a driver
MAIN-SEARCH-0014Checkmainsdk check, before deploysearch.query without for in a file outside tenancy.system.allow
MAIN-SEARCH-0001RuntimeError responses and logsThe index isn't declared under search.indexes (400)
MAIN-SEARCH-0002RuntimeError responses and logsThe access filter for this searcher can't be expressed in the engine's query; refused, never post-filtered (403)
MAIN-SEARCH-0003RuntimeError responses and logssearch.query without for, outside tenant.system (400)
MAIN-SEARCH-0004RuntimeError responses and logsThe engine is unreachable or the query passed timeout (503, retryable); index changes stay queued
MAIN-SEARCH-0005RuntimeError responses and logsA filter, sort or facet names a field not declared for it, or a reserved field (_tenant, _region, _v) (400)
MAIN-SEARCH-0006RuntimeError responses and logsNo index endpoint for the tenant's region; the change stays queued and is sent nowhere else (logged)
MAIN-SEARCH-0007RuntimeError responses and logsA cursor would go past max-depth (400)
MAIN-SEARCH-0008RuntimeError responses and logsq is longer than 512 characters, or a filter value has the wrong type for its field (400)
MAIN-SEARCH-0009RuntimeError responses and logsAn index change failed after its last retry and is in the dead-letter queue; counted in search.status

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
search@1 contract and index declarationsPhase 10.4search#contract
db driver on Postgres full-text search and SQLite FTS5Phase 10.4search#db-driver
Typo tolerancePhase 10.4search#typo
FacetsPhase 10.4search#facets
Cursor pages up to max-depthPhase 10.4search#paging
Tenant and access filter inside the queryPhase 10.4search#access-filter
Index updates through the outboxPhase 10.4search#outbox
Fail fast when the engine is downPhase 10.4search#fail-static
One index per regionPhase 10.4search#regions
mainsdk search reindexPhase 10.4search#reindex
Labels and erasurePhase 10.4search#privacy
Typesense and Meilisearch driversPhase 10.4search#engines
Search checksPhase 10.4search#checks
Vector searchPhase 20.6Not specified yet

Quick start

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

main.yaml
search:
  driver: db
  indexes:
    posts:
      model: posts
      access: posts.read
      fields:
        text:   { search: true, weight: 2 }
        tags:   { facet: true, filter: true }
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

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 }
KeyWhat it does
driverdb uses Postgres full-text search (SQLite FTS5 locally); typesense and meilisearch use an engine, with one endpoint per region
indexes.<name>.modelThe db model the index mirrors
indexes.<name>.accessThe access action that decides who may read a hit
indexes.<name>.fields.<f>search, weight, facet, filter or sort for the field
timeout, max-depthQuery 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

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

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

CodeWhenFix
MAIN-SEARCH-0001The index is not declared under search.indexesDeclare it
MAIN-SEARCH-0002The access filter cannot be expressed in the engine's queryNarrow the policy; results are never filtered after the fact
MAIN-SEARCH-0003search.query without forPass the searcher
MAIN-SEARCH-0004The engine is unreachable or the query passed timeout (503, retryable)Retry; index changes stay queued
MAIN-SEARCH-0005A filter, sort or facet names a field not declared for it, or a reserved fieldDeclare 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 once the spec is built.

Swapping drivers

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.

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet