138 lines
4.5 KiB
Markdown
138 lines
4.5 KiB
Markdown
# 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.
|