4.5 KiB
service/service
(no submodules)
Purpose
The service module is the in-memory search engine that powers
@suaveplan/api-plugin-search. It owns index creation and deletion,
per-document analysis and storage, TF-weighted scoring, scalar/array
filtering, multi-key sorting, facet aggregation, optional highlighting,
and the prefix-based suggest pass. Every route handler in the package
delegates to the functions here, every code path that fails throws a
typed SearchError(SEARCH.*), and every consumer-visible operation
emits to the OTel meter bundle so dashboards reflect actual hot-path
behaviour.
Features
- Synchronous, dependency-free engine —
Map<string, SearchIndex>is the entire backing store. - TF × field-weight scoring loop with deterministic ordering across
identical scores (relies on
Mapinsertion order). - Filter pass supporting scalar equality (
actual === expected) and array membership (expected.includes(actual)) so a single Zod schema accepts both shapes. - Five-way sort (
relevance,date_asc,date_desc,alpha_asc,alpha_desc) usinglocaleComparefor stable cross-locale ordering. - Facet aggregation restricted to fields with
facet: true; counts are built over the filtered (not raw) document set. - Optional highlighting wraps matching whole words in the
caller-supplied tag (
<mark>by default). - Prefix-based suggest derives candidates from the last analysed token and ranks by occurrence count.
- Three hard caps (
maxIndexes,maxDocumentsPerIndex) enforced with typed errors that map to HTTP 400 viacontext.statusCode. - Five OTel instruments wired into every call:
queriesTotal,queryDurationMs,documentsIndexedTotal,indexesTotal,suggestRequestsTotal.
Usage: Basic
import {
createSearchIndex,
indexDocument,
search,
} from "@suaveplan/api-plugin-search";
const indexes = new Map();
createSearchIndex(
{ name: "posts", fields: [{ name: "title", weight: 2, facet: false }] },
indexes
);
indexDocument(
"posts",
{ id: "1", fields: { title: "Hello world" } },
indexes
);
const result = search(
{
query: "hello",
index: "posts",
sort: "relevance",
limit: 20,
offset: 0,
highlight: false,
highlightTag: "mark",
},
indexes
);
Usage: Advanced
Bulk-index 500 documents, search with a facet + filter, and observe the emitted OTel metrics:
import {
bulkIndexDocuments,
search,
} from "@suaveplan/api-plugin-search";
bulkIndexDocuments(
"posts",
docs,
indexes,
{ maxIndexes: 100, maxDocumentsPerIndex: 1_000, defaultAnalyzer: "standard" }
);
const result = search(
{
query: "search",
index: "posts",
filters: { tag: ["blog", "release"] },
facets: ["tag"],
sort: "relevance",
limit: 20,
offset: 0,
highlight: true,
highlightTag: "em",
},
indexes
);
// result.facets[0].field === "tag", result.hits[0].highlights.title contains <em>…</em>
API Reference
| Function | Throws |
|---|---|
createSearchIndex(config, indexes, serviceConfig?) |
INDEX_ALREADY_EXISTS, INDEX_LIMIT_REACHED |
deleteSearchIndex(name, indexes) |
— |
getSearchIndex(name, indexes) |
— |
listSearchIndexes(indexes, query?) |
— |
indexDocument(name, doc, indexes, serviceConfig?) |
INDEX_NOT_FOUND, DOCUMENT_LIMIT_REACHED |
bulkIndexDocuments(name, docs, indexes, serviceConfig?) |
propagates from indexDocument |
removeDocument(name, docId, indexes) |
INDEX_NOT_FOUND |
search(query, indexes) |
INDEX_NOT_FOUND, UNKNOWN_ANALYZER |
suggest(query, indexes) |
INDEX_NOT_FOUND, UNKNOWN_ANALYZER |
SearchServiceConfig carries the three hard caps; DEFAULT_SEARCH_CONFIG provides the defaults
(maxIndexes: 100, maxDocumentsPerIndex: 100_000, defaultAnalyzer: "standard").
Implementation Notes
scoreDocuments filters out documents with score === 0 so they
never appear in result sets — this matches upstream behaviour. The
sortResults switch is closed by the SearchSortOrder Zod enum so
the default arm is provably unreachable; it is preserved as a
forward-defensive carve-out. buildFacets walks facetFields in
caller order so consumers can rely on response shape. suggest reads
tokens.at(-1) rather than indexing [len - 1] so the empty-array
case is statically impossible — even though the caller already short-
circuits on zero tokens, the at call is the documented narrow form.