# 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` is the entire backing store. - TF × field-weight scoring loop with deterministic ordering across identical scores (relies on `Map` insertion 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`) using `localeCompare` for 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 (`` 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 via `context.statusCode`. - Five OTel instruments wired into every call: `queriesTotal`, `queryDurationMs`, `documentsIndexedTotal`, `indexesTotal`, `suggestRequestsTotal`. ## Usage: Basic ```ts 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: ```ts 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 … ``` ## 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.