Files
skills/docs/api-stack/api/api-plugin-search/src/service/service.md
2026-05-29 08:20:56 +01:00

138 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# service/service
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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 `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 (`<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 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 <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.