Search and vector search
searchIndex records and search them with facets, highlights and typo tolerance, with every result filtered by tenant and by what the searcher may read.
searchFree in your app, Apache-2.0Status 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.
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 }import { search } from "mainsdk";
const page = await search.query({ index: "posts", q: "launch", for: user, facets: ["tags"], highlight: 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.
search:
driver: dbChange this one line to swap providers; your calls stay the same.PostgreSQL integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-SEARCH-0010 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | A field labelled sensitive, phi, child or secret is indexed without index: allow on the field |
MAIN-SEARCH-0012 | Checkmainsdk check, before deploy | An engine driver has no endpoint for a region the app stores data in |
MAIN-SEARCH-0013 | Checkmainsdk check, before deploy | An engine SDK (typesense, meilisearch, algoliasearch) imported outside a driver |
MAIN-SEARCH-0014 | Checkmainsdk check, before deploy | search.query without for in a file outside tenancy.system.allow |
MAIN-SEARCH-0001 | RuntimeError responses and logs | The index isn't declared under search.indexes (400) |
MAIN-SEARCH-0002 | RuntimeError responses and logs | The access filter for this searcher can't be expressed in the engine's query; refused, never post-filtered (403) |
MAIN-SEARCH-0003 | RuntimeError responses and logs | search.query without for, outside tenant.system (400) |
MAIN-SEARCH-0004 | RuntimeError responses and logs | The engine is unreachable or the query passed timeout (503, retryable); index changes stay queued |
MAIN-SEARCH-0005 | RuntimeError responses and logs | A filter, sort or facet names a field not declared for it, or a reserved field (_tenant, _region, _v) (400) |
MAIN-SEARCH-0006 | RuntimeError responses and logs | No index endpoint for the tenant's region; the change stays queued and is sent nowhere else (logged) |
MAIN-SEARCH-0007 | RuntimeError responses and logs | A cursor would go past max-depth (400) |
MAIN-SEARCH-0008 | RuntimeError responses and logs | q is longer than 512 characters, or a filter value has the wrong type for its field (400) |
MAIN-SEARCH-0009 | RuntimeError responses and logs | An 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| search@1 contract and index declarations | Phase 1 | 0.4 | search#contract |
| db driver on Postgres full-text search and SQLite FTS5 | Phase 1 | 0.4 | search#db-driver |
| Typo tolerance | Phase 1 | 0.4 | search#typo |
| Facets | Phase 1 | 0.4 | search#facets |
| Cursor pages up to max-depth | Phase 1 | 0.4 | search#paging |
| Tenant and access filter inside the query | Phase 1 | 0.4 | search#access-filter |
| Index updates through the outbox | Phase 1 | 0.4 | search#outbox |
| Fail fast when the engine is down | Phase 1 | 0.4 | search#fail-static |
| One index per region | Phase 1 | 0.4 | search#regions |
| mainsdk search reindex | Phase 1 | 0.4 | search#reindex |
| Labels and erasure | Phase 1 | 0.4 | search#privacy |
| Typesense and Meilisearch drivers | Phase 1 | 0.4 | search#engines |
| Search checks | Phase 1 | 0.4 | search#checks |
| Vector search | Phase 2 | 0.6 | Not specified yet |
Quick start
Declare an index that mirrors a db model, then query it with for.
search:
driver: db
indexes:
posts:
model: posts
access: posts.read
fields:
text: { search: true, weight: 2 }
tags: { facet: true, filter: true }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
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
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 crashsearch.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
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
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 once the spec is built.
Swapping drivers
search:
driver: typesense # was dbYour 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,childorsecretneedsindex: allowto be indexed, or the check fails withMAIN-SEARCH-0011. - Each region has its own index; a change is never sent to another region.
privacy.eraseremoves 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?