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

16 KiB
Raw Permalink Blame History

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 MeterProviders
  • 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"