Search with Meilisearch (search-meilisearch)
Full-text search for the resources you choose, with Meilisearch. Records follow in the index as they are created, updated and deleted (in the background, with retries); a reindex route pages through the collection; and each resource gets GET /api/<resource>/search?q=. Meilisearch only finds ids: records are then loaded through the resource's own rules (list policy, service scope(), read policy, field visibility), so search never shows a record, or a field, that the caller could not read through the REST API. No SDK, no dependencies.
npx calm add search-meilisearch| Adds | |
|---|---|
| Files | app/blocks/search-meilisearch/index.js (fields, sync, search, routes), meili.js (HTTP client) |
| Environment | MEILI_HOST, MEILI_MASTER_KEY (required in production), MEILI_SEARCH_KEY (optional) |
Setup#
Meilisearch Cloud: create a project, copy its URL and the Default Admin API Key (and the Default Search API Key). Self-hosted: run it with a master key (meilisearch --master-key=..., at least 16 bytes) behind https.
MEILI_HOST=https://ms-xxxxxxxx.meilisearch.io
MEILI_MASTER_KEY=... # the master key or an admin API key
MEILI_SEARCH_KEY=... # optional: a search-only key for queriesChoose what to index:
export default defineConfig({
blocks: {
'search-meilisearch': {
resources: {
post: true, // every public field, searchable = the fields marked .searchable()
product: { fields: ['name', 'description', 'category', 'price'], searchable: ['name', 'description'], filterable: ['category'], sortable: ['price'], index: 'products' },
},
},
},
});Then fill the indexes once (and after any write that skips the services, such as a migration):
curl -X POST http://localhost:5001/api/search/post/reindex -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -d '{ "clear": true }'{ "data": { "resource": "post", "index": "posts", "indexed": 1234, "batches": 3, "cleared": true } }What is indexed#
The document stored for a record is what an anonymous visitor would get from the API, limited to the chosen fields: id, the fields, createdAt, updatedAt. Never indexed: hidden, write-only and password fields, .visibleTo() fields, .pii() fields and .encrypted() fields. Listing one in fields stops the boot with a message, because whoever holds a search key can read the whole index.
Searching#
GET /api/posts/search?q=launch&limit=20&offset=0
200 { "data": [ { "id": "...", "title": "Alpha launch", ... } ], "meta": { "q": "launch", "limit": 20, "offset": 0, "nextOffset": null } }- The route uses the resource's
listpolicy (401 or 403 likeGET /api/posts). - Hits are loaded with the resource service (
scope(): drafts, tenants) and checked withapp.canRead()(read policy, owner), then serialized for the caller (visibleTo()applies). Records the caller cannot read are skipped, and the page is filled from the following hits (up tomaxScansMeilisearch requests). - Use
meta.nextOffsetfor the next page (null: no more). There is no total: Meilisearch's count would include records the caller cannot see. limitis capped bymaxSearchLimit(50),qby 200 characters.
From code: await ctx.app.ext.search.search('post', { q, limit, offset, filter, sort }, ctx) (with ctx.user), reindex(name, { clear }), configure(name) (push settings only), stats.
Sync#
<resource>.created, .updated and .restored (soft-delete) upsert the record, .deleted removes it. Listeners run after the response. Failed writes are retried (retries: ['1s', '5s', '30s']); after the last one a warning is logged and a reindex repairs the index. Meilisearch processes writes as asynchronous tasks, so a record becomes searchable a moment later.
Options#
| Option | Default | |
|---|---|---|
resources | {} | name to true or { fields, index, searchable, filterable, sortable } |
indexPrefix | '' | staging_ to share one Meilisearch |
host | MEILI_HOST | |
batchSize | 500 | documents per request when reindexing |
retries | ['1s', '5s', '30s'] | waits before each retry |
searchLimit / maxSearchLimit | 20 / 50 | |
maxScans | 5 | Meilisearch requests per search while filling a page |
timeoutMs | 10000 |
Security notes#
- Never give
MEILI_MASTER_KEYorMEILI_SEARCH_KEYto browsers or mobile apps: the index contains records (drafts, other users' items) that only this API filters. Search through/api/<resource>/search. - Use an admin API key rather than the master key where you can, and a search-only key for
MEILI_SEARCH_KEY. - Keep Meilisearch on https (or a private network);
calm doctorwarns abouthttp://to public hosts in production.
Official documentation#
Read these before changing the block; they are also in app/blocks/search-meilisearch/AGENTS.md and in the devtools Blocks panel.
- Meilisearch documents API
- Meilisearch search API
- Meilisearch index settings API
- Securing your project (API keys)
- Asynchronous operations (tasks)
For AI agents#
Coding agents read these notes in app/blocks/search-meilisearch/AGENTS.md:
- Read
app/blocks/search-meilisearch/index.js(field selection, sync,search()) andmeili.js(HTTP client) first. - Choose what is indexed in calm.config.js
blocks['search-meilisearch'].resources; do not edit the block to add a resource. - Invariant: hidden, write-only, password, visibleTo(), pii() and encrypted fields are never indexed.
indexedFields()throws at boot if config lists one. Never weakenprivateReason(). - Invariant: search results are ids only; records are loaded through the resource service (scope()) and
app.canRead(), then serialized for the caller. Never return Meilisearch hits or_formattedvalues to clients directly. - Do not expose
estimatedTotalHits: it counts records the caller cannot see. - Keys live in
.env. Never sendMEILI_MASTER_KEYorMEILI_SEARCH_KEYto browsers: the index holds records that not everybody may read. - Sync happens in event listeners (
<resource>.created|updated|deleted|restored). Writes made without the service (insertMany, updateMany, migrations) are not synced: runPOST /api/search/<resource>/reindexorapp.ext.search.reindex(name)afterwards. - After changing searchable, filterable or sortable fields, reindex so the settings are pushed.
- Meilisearch writes are asynchronous tasks: a 202 means queued, a document may take a moment to be searchable.
- Tests: point
MEILI_HOSTat a local node:http fake (seetest/blocks/search-meilisearch.test.jsin the CalmAPI repo).