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

4.5 KiB
Raw Blame History

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 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

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.