16 KiB
Capability Spec: api-plugin-search (add-api-plugin-search)
Package: @suaveplan/api-plugin-search
Status: SHIPPED
Every requirement below carries a
**Verified by:**line at authoring time.
Requirements
Requirement: Plugin factory shape
The package SHALL export createSearchPlugin(options?: SearchPluginOptions)
returning a PluginDescriptor<SearchPluginConfig> whose name is "search",
whose dependencies is empty, whose optionalDependencies includes
"database" and "events", and whose tags include "search", "fulltext",
"facets", and "relevance".
Scenario: Descriptor metadata
- WHEN
createSearchPlugin()is called with no arguments - THEN the returned descriptor has the exact name, version, dependencies, optional-dependencies, and tags listed above
Scenario: Invalid options reject at the boundary
- WHEN
createSearchPlugin({ maxIndexes: 0 })is called - THEN the factory throws
SearchErrorwith codeSEARCH.INVALID_OPTIONSand the originalZodErrorascause
Verified by: src/plugin/plugin.test.ts::"returns a descriptor with the canonical search-plugin metadata" AND src/plugin/plugin.test.ts::"throws SEARCH.INVALID_OPTIONS when options fail schema validation"
Requirement: Lifecycle (onInit / onReady / onClose)
The descriptor SHALL implement onInit, onReady, and onClose. onInit
registers the in-memory Map<string, SearchIndex> under the canonical
searchIndexes service slot. onReady builds the route tree and mounts it
under the configured routePrefix (default /search). onClose clears the
index map.
Scenario: onInit registers the index map
- WHEN
onInit(ctx)runs - THEN
ctx.getService("searchIndexes")returns a non-undefinedMap
Scenario: onReady mounts routes at custom prefix
- WHEN the descriptor's
onInitthenonReadyruns with options{ routePrefix: "/api/fts" } - THEN the host
app.routewas called with"/api/fts"as the mount point
Scenario: onClose empties the index map
- WHEN
onInitruns, an index is inserted, thenonCloseruns - THEN the index map has size 0
Verified by: src/plugin/plugin.test.ts::"onInit registers the searchIndexes service" AND src/plugin/plugin.test.ts::"onReady mounts routes at the configured routePrefix" AND src/plugin/plugin.test.ts::"onClose empties the searchIndexes map"
Requirement: In-memory index lifecycle
The package SHALL expose createSearchIndex, deleteSearchIndex,
getSearchIndex, and listSearchIndexes. Creating a duplicate index throws
SearchError(SEARCH.INDEX_ALREADY_EXISTS). Exceeding maxIndexes throws
SearchError(SEARCH.INDEX_LIMIT_REACHED). listSearchIndexes paginates by
offset/limit and optionally filters by status.
Scenario: Duplicate index name
- WHEN
createSearchIndexis called twice with the same name - THEN the second call throws
SearchErrorwith codeSEARCH.INDEX_ALREADY_EXISTS
Scenario: Index limit enforcement
- WHEN the registered map already contains
maxIndexesentries and a newcreateSearchIndexcall is made - THEN the call throws
SearchErrorwith codeSEARCH.INDEX_LIMIT_REACHED
Scenario: List with pagination + status filter
- WHEN three indexes exist (one with status
"building") and the caller passes{ status: "active", limit: 2 } - THEN the returned
itemsarray has length ≤ 2 and contains only active indexes, andtotalreports the unpaged active count
Verified by: src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_ALREADY_EXISTS on duplicate name" AND src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_LIMIT_REACHED when maxIndexes is reached" AND src/service/service.test.ts::"listSearchIndexes paginates and filters by status"
Requirement: Document indexing with limits and field-aware analysis
The package SHALL expose indexDocument, bulkIndexDocuments, and
removeDocument. indexDocument runs the index's configured analyzer over
every declared field, stores per-field token lists, and rejects with
SearchError(SEARCH.INDEX_NOT_FOUND) when the index is missing. When the
document map would exceed maxDocumentsPerIndex AND the doc id is new, it
throws SearchError(SEARCH.DOCUMENT_LIMIT_REACHED). removeDocument returns
false when the doc id is absent and true when it was removed.
Scenario: Per-field tokenisation
- WHEN an index declares fields
[title, body]and a document with both fields is indexed - THEN the stored
IndexedDocument.tokensmap carries one entry per field with non-empty token arrays
Scenario: Document-limit guard
- WHEN the index already contains
maxDocumentsPerIndexdocuments and a new document id is indexed - THEN the call throws
SearchError(SEARCH.DOCUMENT_LIMIT_REACHED)
Scenario: Re-indexing an existing doc never exceeds the limit
- WHEN the index is at the document limit and
indexDocumentis called with an id that already exists - THEN the call succeeds and replaces the stored tokens
Verified by: src/service/service.test.ts::"indexDocument analyses every declared field" AND src/service/service.test.ts::"indexDocument throws SEARCH.DOCUMENT_LIMIT_REACHED for new ids beyond the cap" AND src/service/service.test.ts::"indexDocument replaces tokens for existing ids without triggering the limit"
Requirement: Search with TF-IDF scoring, filters, sort, facets, highlights
The package SHALL expose search(query, indexes) returning a SearchResult.
Scoring uses term-frequency × field-weight. Filters accept scalar equality and
Array.includes for array values. Sort honours relevance, date_asc,
date_desc, alpha_asc, alpha_desc. Facets are computed only over fields
declared with facet: true and reflect the FILTERED document set. Highlights
wrap matching whole words in the configured highlightTag.
Scenario: Relevance sort by default
- WHEN three documents match a query with different term frequencies
- THEN the response
hitsare sorted by descendingscore
Scenario: Filter restricts the result set
- WHEN a query carries
filters: { tag: "blog" } - THEN only documents whose
tagfield equals"blog"appear inhits, andtotalreflects the filtered count
Scenario: Facet honours facet: true only
- WHEN the index declares
categorywithfacet: trueandbodywithfacet: false, and the query requests["category", "body"] - THEN the response carries exactly one facet block keyed on
category
Scenario: Highlight wraps matching words
- WHEN
highlight: trueandhighlightTag: "em"are set and a document matches the query token - THEN the response hit's
highlights[fieldName]contains<em>word</em>strings
Scenario: Empty-query token list yields empty result
- WHEN the analyzer returns zero tokens for the input query
- THEN
searchreturns{ hits: [], total: 0, ... }and never throws
Scenario: Unknown index throws
- WHEN the query references an index that does not exist
- THEN
searchthrowsSearchErrorwith codeSEARCH.INDEX_NOT_FOUND
Verified by: src/service/service.test.ts::"search ranks hits by TF × field weight" AND src/service/service.test.ts::"search applies scalar and array filters" AND src/service/service.test.ts::"search honours sort order" AND src/service/service.test.ts::"search emits facets only for fields with facet: true" AND src/service/service.test.ts::"search wraps matching words with the configured highlight tag" AND src/service/service.test.ts::"search returns empty result when query analyses to zero tokens" AND src/service/service.test.ts::"search throws SEARCH.INDEX_NOT_FOUND for an unknown index"
Requirement: Suggest endpoint (prefix autocomplete)
The package SHALL expose suggest(query, indexes) returning ranked prefix
matches over every indexed field. The last analysed token is used as the
prefix, hits are deduplicated by surface form, and results are sorted by
descending occurrence count and truncated to query.limit.
Scenario: Prefix expansion ranks by frequency
- WHEN the index contains documents whose words begin with the query's last token
- THEN
suggestreturns suggestions ordered by descending occurrence count and truncated tolimit
Scenario: Suggest returns empty for empty token analysis
- WHEN the analysed query produces zero tokens
- THEN
suggestreturns{ suggestions: [], query }
Verified by: src/service/service.test.ts::"suggest ranks prefix matches by occurrence count" AND src/service/service.test.ts::"suggest returns empty result when query analyses to zero tokens"
Requirement: Four built-in analyzers via the registry
The package SHALL register four analyzers — standard, simple,
whitespace, and keyword — through getAnalyzer(name). listAnalyzers()
returns the four registered names. Unknown analyzer names throw
SearchError(SEARCH.UNKNOWN_ANALYZER).
Scenario: Standard analyzer tokenises, removes stop words, stems
- WHEN the standard analyzer analyses
"Running the cats quickly" - THEN the returned token list contains the stemmed forms
run,cat,quickand excludes the stop wordthe
Scenario: Keyword analyzer treats input as single token
- WHEN the keyword analyzer analyses
"Hello World" - THEN the returned token list is
["Hello World"]
Scenario: Unknown analyzer name fails fast
- WHEN
getAnalyzer("does-not-exist")is called - THEN the call throws
SearchErrorwith codeSEARCH.UNKNOWN_ANALYZER
Verified by: src/analyzer/analyzer.test.ts::"standard analyzer tokenises, removes stop words, and stems" AND src/analyzer/analyzer.test.ts::"keyword analyzer returns the trimmed input as a single token" AND src/analyzer/analyzer.test.ts::"getAnalyzer throws SEARCH.UNKNOWN_ANALYZER for an unregistered name"
Requirement: 9 HTTP routes mounted under routePrefix
The package SHALL expose createSearchRoutes(deps) returning an
OpenAPIHono instance with the canonical 9 routes (POST /query,
POST /suggest, GET /indexes, POST /indexes, GET /indexes/:name,
DELETE /indexes/:name, POST /indexes/:name/documents,
POST /indexes/:name/bulk, DELETE /indexes/:name/documents/:docId). Every
request body / param is Zod-validated through @hono/zod-openapi. Error
responses are { error: string } and the HTTP status reflects the typed
SearchError (SEARCH.INDEX_NOT_FOUND → 404, SEARCH.DOCUMENT_NOT_FOUND →
404, every other failure → 400).
Scenario: POST /query returns hits and emits search:query:executed
- WHEN a populated index receives a POST /query body
- THEN the response status is 200, the body matches
SearchResultSchema, anddeps.emitEventwas invoked with"search:query:executed"and aSearchQueryExecutedEventpayload
Scenario: 404 for missing index
- WHEN POST /query references an unknown index
- THEN the response status is 404 with body
{ error: "..." }
Scenario: POST /indexes creates an index
- WHEN POST /indexes receives a valid
CreateIndexSchemabody - THEN the response status is 201 with body matching
IndexInfoSchema
Scenario: DELETE /indexes/:name returns 404 for unknown index
- WHEN DELETE /indexes/missing is hit on an empty registry
- THEN the response status is 404
Scenario: DELETE /indexes/:name/documents/:docId removes a doc
- WHEN a doc was indexed and then DELETE is invoked on the doc id
- THEN the response status is 200 with
{ success: true }anddeps.emitEventwas invoked with"search:index:updated"andoperation: "document_removed"
Verified by: src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed" AND src/routes/build-routes.test.ts::"POST /query returns 404 when the index does not exist" AND src/routes/build-routes.test.ts::"POST /indexes creates an index and returns 201" AND src/routes/build-routes.test.ts::"DELETE /indexes/:name returns 404 for an unknown index" AND src/routes/build-routes.test.ts::"DELETE /indexes/:name/documents/:docId returns 200 and emits search:index:updated"
Requirement: Typed error registry
The package SHALL expose SEARCH_CODES via defineCodes("SEARCH", [...])
covering exactly 10 codes — INDEX_NOT_FOUND, INDEX_ALREADY_EXISTS,
INDEX_LIMIT_REACHED, DOCUMENT_LIMIT_REACHED, DOCUMENT_NOT_FOUND,
UNKNOWN_ANALYZER, INVALID_OPTIONS, INVALID_QUERY,
SERVICE_NOT_AVAILABLE, ROUTE_PREFIX_COLLISION — and a SearchError
class extending SuaveplanError<SearchErrorCode> such that
instanceof SearchError narrows .code to a SearchErrorCode literal
union.
Scenario: Registry shape is stable
- WHEN
Object.values(SEARCH_CODES)is inspected - THEN the array has exactly 10 unique entries, each beginning with
"SEARCH."
Scenario: SearchError preserves cause and code narrowing
- WHEN
new SearchError(SEARCH_CODES.INDEX_NOT_FOUND, "msg", { cause })is constructed - THEN
error instanceof SearchErroris true,error.code === SEARCH_CODES.INDEX_NOT_FOUND, anderror.causeis the supplied cause
Verified by: src/error-codes/error-codes.test.ts::"declares 10 SEARCH.* codes" AND src/error-codes/error-codes.test.ts::"SearchError narrows code to SearchErrorCode and preserves cause"
Requirement: OTel instruments on every hot path
The package SHALL emit metrics via a meter named
@suaveplan/api-plugin-search. The instrument bundle SHALL include
search.queries_total{outcome}, search.query_duration_ms (histogram),
search.documents_indexed_total{op}, search.indexes_total{op}, and
search.suggest_requests_total{outcome}. A resetSearchInstruments()
helper SHALL allow tests to rebind against a freshly-installed
MeterProvider.
Scenario: Reset rebinds the meter
- WHEN
resetSearchInstruments()is invoked between twogetSearchInstruments()calls under two differentMeterProviders - THEN the second call returns a fresh instrument bundle bound to the second provider
Scenario: Successful query bumps queries_total and records duration
- WHEN a successful
search()call completes - THEN the in-memory exporter records a
search.queries_total{outcome="success"}+= 1 and a non-emptysearch.query_duration_mshistogram observation
Verified by: src/otel/instruments.test.ts::"resetSearchInstruments rebinds against a fresh MeterProvider" AND src/service/service.test.ts::"search increments queries_total and records duration"
Requirement: ServiceRegistry augmentation
The package SHALL augment @suaveplan/api-plugin/service-registry's
ServiceRegistry interface with a searchIndexes slot typed as
Map<string, SearchIndex>. Importing the package barrel SHALL surface the
augmentation as a side effect.
Scenario: getService("searchIndexes") types to Map<string, SearchIndex>
- WHEN a consumer calls
ctx.getService("searchIndexes")after importing@suaveplan/api-plugin-search - THEN the inferred return type is
Map<string, SearchIndex> | undefined(verified at compile time viaexpectTypeOf)
Verified by: src/api-plugin-search.spec-conformance.test.ts::"augments the ServiceRegistry with the searchIndexes slot"
Requirement: Typed event-bus emissions
The package SHALL emit nine search events through the host
EventBusLike — search:initialized, search:ready,
search:index:created, search:index:deleted, search:index:updated,
search:document:indexed, search:bulk:completed,
search:document:removed, search:query:executed — with strongly typed
payload signatures declared via a module augmentation. The plugin call
sites SHALL NOT use as any to bypass the event-map type system.
Scenario: Lifecycle events fire on init / ready
- WHEN
onInitthenonReadyrun - THEN
eventBus.emitwas called with"search:initialized"and"search:ready"
Scenario: Query route emits typed SearchQueryExecutedEvent
- WHEN POST /query handles a successful query
- THEN the emitter receives
"search:query:executed"and a payload matchingSearchQueryExecutedEvent
Verified by: src/plugin/plugin.test.ts::"onInit emits search:initialized and onReady emits search:ready" AND src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed"