Package reference
Mirrors the package README (single source). Install @basaltkit/search-elasticsearch v2.0.0 — npm · source.
<p align="center"> <a href="https://basaltkit-docs.pages.dev"> <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440"> </a> </p>
@basaltkit/search-elasticsearch
Elasticsearch / OpenSearch driver for @basaltkit/search.
Targets the REST API directly — no SDK, an injectable fetch, so the requests are unit-tested without a cluster. Works against Elasticsearch 8.x and OpenSearch 2.x, which share this query surface.
Tenant isolation is enforced on every operation: documents are stored under a compound id (<tenantId>:<id>) so ids never collide across tenants, and every search is constrained with a tenantId term filter — results never leak between tenants.
Installation
pnpm add @basaltkit/search @basaltkit/search-elasticsearchUsage
import { searchPlugin } from '@basaltkit/search'
import { ElasticsearchDriver } from '@basaltkit/search-elasticsearch'
const driver = new ElasticsearchDriver({
node: process.env.ES_NODE!, // e.g. http://localhost:9200
apiKey: process.env.ES_API_KEY, // or username + password (Basic auth)
indexPrefix: process.env.NODE_ENV === 'production' ? '' : 'dev_',
})
createApp({ plugins: [searchPlugin({ driver, indexes: [/* defineIndex(...) */] })] })Or use it directly:
await driver.register({ name: 'posts', fields: ['title', 'body'], filterable: ['status'] })
await driver.index('posts', { id: 'p1', tenantId: 'acme', title: 'Hello', body: '…', status: 'published' })
const { hits, total } = await driver.search('posts', {
tenantId: 'acme',
q: 'hello',
filters: { status: 'published' },
limit: 20,
})Options
| Option | Default | Notes |
|---|---|---|
node | — | Cluster base URL (required) |
apiKey | — | Authorization: ApiKey <key> |
username / password | — | Basic auth (alternative to apiKey) |
indexPrefix | '' | Prepended to every index name |
refresh | false | true / 'wait_for' makes writes immediately visible (dev/tests) |
fetch | global fetch | Injectable HTTP client |
Mapping
register() maps searchable fields as text with a .keyword sub-field (so they're also filterable/sortable), filterable-only fields as keyword, and id / tenantId as keyword. Searches use multi_match (best_fields) over the searchable fields with track_total_hits for an exact total; filters become term / terms clauses under the tenant scope.
Notes
- Document ids are
<encodeURIComponent(tenantId)>:<encodeURIComponent(id)>, built once and used identically byindex(),bulk()andremove(): the bulk body carries it verbatim, and the/_doc/<id>path percent-encodes it once more because Elasticsearch decodes path segments (until 2.0 the path did not, soremove()of a bulk-indexed id with a URL-special character deleted nothing). Encoding the segments keeps tenanta:b+ idcdistinct from tenanta+ idb:c(which previously overwrote one tenant's document with another's). UUID/slug ids are unaffected. clear()andclearTenant()use_delete_by_query(match_all, or atermontenantId— the same predicate every search is scoped by), sosearch.reindex()can rebuild one tenant and leave the rest. A response that listsfailures(version conflicts, shard errors) throwsElasticsearchErrorinstead of letting the rebuild write over a half-cleared index.clearTenantrelies ontenantIdbeing akeyword, whichregister()maps.- Deep paging is bounded by
@basaltkit/search'smaxOffset(default 10 000), matching the cluster's defaultmax_result_window. - The fetch client is injectable (
options.fetch) — the globalfetchis used by default. No hard HTTP dependency. - Leave
refresh: falsein production and let the cluster's refresh interval handle visibility; use'wait_for'only in tests/dev. - Validated end-to-end against a live Elasticsearch 8.x cluster — index, search,
term/termsfilters, paging with an exacttotal,bulk,remove, and tenant isolation — in addition to the unit tests. Still worth a smoke test against your exact version (especially OpenSearch).