@priemskiyyy/scout-elasticsearch
v0.1.0
Published
Elasticsearch adapter for Scout: the portable filter as a JSON bool query, sorts, facets as terms aggregations, per search highlight markers parsed into safe parts, and honest totals.
Maintainers
Readme
@priemskiyyy/scout-elasticsearch
The Elasticsearch adapter for Scout. It translates one portable search request onto one Elasticsearch index and maps the answer back, and it does nothing else.
pnpm add @priemskiyyy/scout@next @priemskiyyy/scout-elasticsearch@next @elastic/elasticsearch@elastic/elasticsearch is a peer dependency and the client is yours. This
package imports no Elasticsearch SDK, not even its types.
src/elasticsearch.contracts.ts pins its structural types to the real ones.
The client runs on Node, so this adapter belongs on a server. A browser
reaches the same index through your own endpoint and
@priemskiyyy/scout-http.
Use
import { Client } from "@elastic/elasticsearch";
import { index, Scout } from "@priemskiyyy/scout";
import { elasticsearch } from "@priemskiyyy/scout-elasticsearch";
const client = new Client({ node, auth: { apiKey } });
const products = index({
schema: ProductSchema,
id: (product) => product.id,
searchable: ["name", "brand"],
filterable: ["brand", "price", "inStock"],
facetable: { brand: "string", price: "number", inStock: "boolean" },
highlightable: ["name"],
sorts: { priceAsc: { price: "asc" } },
profiles: { names: { searchable: ["name"] } },
});
const scout = new Scout({
indexes: { products },
adapters: {
products: elasticsearch({
client,
index: "products",
keywords: { brand: "brand.keyword" },
}),
},
});The index says what a search can do, and the adapter only says how Elasticsearch does it:
clientis aClientthe application built. The adapter never closes it, andscout.index("products").nativehands it back.indexis the index, alias or pattern to search. It is also the fingerprint, so it takes part in every query key. An API key never does.keywordsnames the field holding a field's exact value, such asbrand.keywordbeside abrandmapped astext, which is what dynamic mapping gives every string. Filters, facets and sorts read it, because atermon atextfield compares analysed tokens, soNimbusfinds nothing innimbus, and Elasticsearch refuses to aggregate or sort one. A field not named here is used as it is named, and has to be mapped askeyword, a number or a boolean. The query itself always searches the fields as named.trackTotalHits: truecounts every match rather than stopping at 10,000, at a cost in speed.maxResultWindowis the index'smax_result_window, 10,000 unless you changed it.
Parameters Scout does not write pass through .provider(), typed as
ElasticsearchSearchOptions:
scout.index("products").search("shoe").provider({ min_score: 1.5 });What it translates
- Query. A
boolquery whosemustis amulti_matchof the query over the profile's fields. An empty query browses withmatch_all. - Filters. The normalised filter becomes a JSON query in the
bool'sfilter, so it never changes a score, and the aggregations count the same documents the hits come from. Values travel as JSON, so none needs escaping. Equality is an exacttermand a set isterms. A negation,neqandnotInaremust_not, an or isshouldwithminimum_should_match: 1, and a range is arangewithbetweeninclusive at both ends. - Sorts. A sort becomes the
sortarray, one exact field after another, and relevance sends none. - Pages.
fromis(current - 1) * size. Paging ends atmax_result_window, which the adapter reports asretrievable, so the last page is the last one Elasticsearch will serve, while the total still counts every match. - Totals.
hits.totalis exact when its relation iseqand a lower bound when it isgte, where Elasticsearch stopped counting. A search that hit itstimeoutor lost a shard answers with part of the index, so its total is a lower bound too. - Facets. Each facet is a terms aggregation named for its field. It is
exhaustive only when
sum_other_doc_countanddoc_count_error_upper_boundare both 0,"unknown"when Elasticsearch cannot bound the error, and never exhaustive on a partial answer. A boolean's key is 1 or 0, so itskey_as_stringis passed on, and Scout reads every value back as the type the index declared. - Highlights. Elasticsearch wraps a match in
pre_tagsandpost_tags, and the adapter makes new ones for every search, so no document can be written to contain them.number_of_fragments: 0asks for each value whole. The marked text is split into matched and unmatched parts, and a part never carries markup. A string field's fragment is the whole field; an array field's is one of its values, and which one is not reported. - Cancellation. The signal reaches the client, so an aborted search stops on the wire.
- Time.
tookis the engine's own.
hit.native keeps the whole hit, _id and _score among it, and
result.native keeps the whole answer.
What it refuses
With unsupported-feature, before anything is sent:
- A page that ends past
max_result_window, which Elasticsearch rejects.
With configuration, from Scout itself before the adapter sees the search, a
provider option the adapter writes: index, body, query, from, size,
sort, aggs, aggregations, highlight and track_total_hits. A filter's
values need no check here: each operator's value is typed, and Scout refuses
NaN and infinite numbers.
With provider, from Elasticsearch: a sort or a facet on a text field that
keywords does not map to its exact field.
Tests
The adapter runs the shared conformance suite from @priemskiyyy/scout/testing
against an in-process fake of the client. The fake evaluates what the adapter
sends: bool, term, terms, range, match_all and multi_match over a
near standard analyzer, sorts, pages, terms aggregations over every match, and
highlights of whole values, and like Elasticsearch it compares a term on a
text field with its tokens and refuses to sort or aggregate one. It says
nothing about relevance. src/elasticsearch.contracts.ts pins the fake and the
structural client to the real @elastic/elasticsearch 9.5.1 types.
src/elasticsearch.backend.test.ts runs the same suite against a real
Elasticsearch 9.5.3 server started from its official image, with the corpus
mapped as the fake maps it, where filters, sorts, pages, facets and highlights
are checked on the documents it returned. Run it with
pnpm test:backends elasticsearch, which needs Docker.
MIT
