first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
# 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.