366 lines
16 KiB
Markdown
366 lines
16 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# Capability Spec Delta: api-plugin-search (add-api-plugin-search)
|
||
|
||
**Package**: `@suaveplan/api-plugin-search`
|
||
**Status**: PROPOSED
|
||
|
||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||
|
||
## ADDED 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"`
|