Files
2026-05-27 23:29:53 +01:00

366 lines
16 KiB
Markdown
Raw Permalink 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.
<!-- markdownlint-disable MD024 -->
# 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 `SearchError` with code
`SEARCH.INVALID_OPTIONS` and the original `ZodError` as `cause`
**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-undefined `Map`
#### Scenario: onReady mounts routes at custom prefix
- **WHEN** the descriptor's `onInit` then `onReady` runs with options
`{ routePrefix: "/api/fts" }`
- **THEN** the host `app.route` was called with `"/api/fts"` as the mount point
#### Scenario: onClose empties the index map
- **WHEN** `onInit` runs, an index is inserted, then `onClose` runs
- **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** `createSearchIndex` is called twice with the same name
- **THEN** the second call throws `SearchError` with code
`SEARCH.INDEX_ALREADY_EXISTS`
#### Scenario: Index limit enforcement
- **WHEN** the registered map already contains `maxIndexes` entries and a
new `createSearchIndex` call is made
- **THEN** the call throws `SearchError` with code `SEARCH.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 `items` array has length ≤ 2 and contains only
active indexes, and `total` reports 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.tokens` map carries one entry per field
with non-empty token arrays
#### Scenario: Document-limit guard
- **WHEN** the index already contains `maxDocumentsPerIndex` documents 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 `indexDocument` is 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 `hits` are sorted by descending `score`
#### Scenario: Filter restricts the result set
- **WHEN** a query carries `filters: { tag: "blog" }`
- **THEN** only documents whose `tag` field equals `"blog"` appear in `hits`,
and `total` reflects the filtered count
#### Scenario: Facet honours `facet: true` only
- **WHEN** the index declares `category` with `facet: true` and `body` with
`facet: 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: true` and `highlightTag: "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** `search` returns `{ hits: [], total: 0, ... }` and never throws
#### Scenario: Unknown index throws
- **WHEN** the query references an index that does not exist
- **THEN** `search` throws `SearchError` with code `SEARCH.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** `suggest` returns suggestions ordered by descending occurrence
count and truncated to `limit`
#### Scenario: Suggest returns empty for empty token analysis
- **WHEN** the analysed query produces zero tokens
- **THEN** `suggest` returns `{ 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`,
`quick` and excludes the stop word `the`
#### 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 `SearchError` with code `SEARCH.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`,
and `deps.emitEvent` was invoked with `"search:query:executed"` and a
`SearchQueryExecutedEvent` payload
#### 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 `CreateIndexSchema` body
- **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 }` and
`deps.emitEvent` was invoked with `"search:index:updated"` and
`operation: "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 SearchError` is true, `error.code ===
SEARCH_CODES.INDEX_NOT_FOUND`, and `error.cause` is 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 two
`getSearchInstruments()` calls under two different `MeterProvider`s
- **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-empty
`search.query_duration_ms` histogram 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 via `expectTypeOf`)
**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** `onInit` then `onReady` run
- **THEN** `eventBus.emit` was 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
matching `SearchQueryExecutedEvent`
**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"`