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

16 KiB

Capability Spec Delta: search (search-completion)

Package: @suaveplan/search Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: SearchClient orchestrator wraps any SearchEngine

The package SHALL expose a SearchClient<TDoc extends SearchDocument> class that wraps any SearchEngine<TDoc> implementation and applies the searchHooks lifecycle bus, an optional SearchPipeline analyzer chain, defaultLimit, optional fieldBoosts, and an optional ScoringFunction. The client SHALL expose index, indexMany, remove, removeMany, search, find, suggest, clear, size. Every async method SHALL accept and forward an AbortSignal to the underlying engine.

Scenario: search applies defaultLimit when query.limit is unset

  • WHEN client.search({ text: "foo" }) is called and defaultLimit is 25
  • THEN the engine receives a query with pagination.limit: 25 AND the response carries total reflecting the engine's count

Scenario: fieldBoosts re-rank results

  • WHEN the client is configured with fieldBoosts: [{ field: "title", weight: 3 }] and a search returns hits with overlapping body and title matches
  • THEN the result order surfaces title-matching documents above body-only matches with the same baseline score

Scenario: AbortSignal propagates to engine

  • WHEN client.search(query, { signal }) is called and controller.abort() fires
  • THEN the engine's search invocation also receives the same signal AND the call rejects with AbortError

Verified by: packages/platform/search/src/client/search-client.test.ts::"applies defaultLimit when query limit is unset" AND packages/platform/search/src/client/search-client.test.ts::"re-ranks hits using configured fieldBoosts" AND packages/platform/search/src/client/search-client.spec-conformance.test.ts::"propagates AbortSignal to the engine search call"


Requirement: searchHooks lifecycle event bus

The package SHALL expose a searchHooks typed event bus implementing three lifecycle hooks: 'search:querying' (intercept and rewrite query before the engine sees it), 'search:indexing' (intercept and rewrite document before index), 'search:result' (post-process result set before return). The bus SHALL be implemented over @suaveplan/events Hooks and respect handler-priority / order semantics. Handler exceptions SHALL raise SearchError(SEARCH.HOOK_HANDLER_FAILED) with the original cause preserved.

Scenario: search:querying handler rewrites the query

  • WHEN a handler subscribes to 'search:querying' and adds a default filter { field: "tenantId", op: "eq", value: "t-1" }
  • THEN the engine receives a query that includes the injected filter AND the original caller's query is unchanged (the handler operates on a copy)

Scenario: handler-priority order respected

  • WHEN two handlers subscribe to the same hook with priorities 10 and 5
  • THEN the priority-10 handler runs first AND its mutation is visible to the priority-5 handler

Scenario: handler exception raises HOOK_HANDLER_FAILED

  • WHEN a subscribed handler throws synchronously
  • THEN the dispatch rejects with SearchError(SEARCH.HOOK_HANDLER_FAILED) AND cause is the original exception AND context.hook is the hook name

Verified by: packages/platform/search/src/hooks/search-hooks.test.ts::"applies search:querying handler to rewrite the engine query" AND packages/platform/search/src/hooks/search-hooks.test.ts::"runs handlers in priority order" AND packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts::"raises HOOK_HANDLER_FAILED when a handler throws"


Requirement: QueryBuilder fluent typed query DSL

The package SHALL expose a QueryBuilder class with a fluent typed DSL: term(value), must(filter), should(filter), mustNot(filter), range(field, op, value), prefix(field, value), fuzzy(field, value, distance?), geo(field, lat, lng, radiusKm?), filter(field, op, value) (legacy alias), facet(field, maxValues?), sort(field, direction), paginate(offset, limit), boost(field, weight), scoringFunction(fn), build(), reset(). build() SHALL return a SearchQuery matching the engine's shape.

Scenario: build composes filters, facets, sort, pagination

  • WHEN new QueryBuilder().term("typescript").must({ field: "category", op: "eq", value: "docs" }).facet("category").sort("title", "asc").paginate(0, 20).build()
  • THEN the returned SearchQuery has text: "typescript", includes the must-filter, declares the facet, sorts by title ascending, and paginates from offset 0 with limit 20

Scenario: range and fuzzy clauses build correctly

  • WHEN .range("price", "lt", 100).fuzzy("title", "widgit", 1) is added to a query
  • THEN the built query carries a range filter { field: "price", op: "lt", value: 100 } AND a fuzzy clause with edit-distance 1

Scenario: reset clears state

  • WHEN reset() is called after building
  • THEN a subsequent build() returns a query with empty filters, no sort, default pagination (offset: 0, limit: 20), and no facets

Verified by: packages/platform/search/src/query-builder/query-builder.test.ts::"builds a SearchQuery composing filters, facets, sort, and pagination" AND packages/platform/search/src/query-builder/query-builder.test.ts::"adds range and fuzzy clauses to the built query" AND packages/platform/search/src/query-builder/query-builder.test.ts::"reset returns the builder to default state"


Requirement: SearchPipeline composable analyzer chain

The package SHALL expose a SearchPipeline class that walks a registered list of Analyzer implementations in order. The Analyzer interface SHALL be { name: string; analyze(terms: readonly string[]): string[] }. The package SHALL ship four built-in analyzers — LowercaseAnalyzer, StemmingAnalyzer, StopWordAnalyzer, SynonymAnalyzer — plus the SearchPipeline.process(query, signal?) method honouring AbortSignal.

Scenario: pipeline walks analyzers in order

  • WHEN a pipeline new SearchPipeline([new LowercaseAnalyzer(), new StopWordAnalyzer(), new StemmingAnalyzer()]) processes "Running The Tests"
  • THEN the output reflects lowercase → stop-word removal → stem (e.g. includes "running" and "run")

Scenario: SynonymAnalyzer expands one-way and bidirectional synonyms

  • WHEN new SynonymAnalyzer({ laptop: ["notebook", "computer"] }) analyzes ["laptop"]
  • THEN the result is ["laptop", "notebook", "computer"]

Scenario: StopWordAnalyzer falls back to original tokens when filter would empty the list

  • WHEN the input is ["the", "and"] and both are stop-words
  • THEN the analyzer returns ["the", "and"] unchanged (so search isn't completely blank)

Scenario: AbortSignal mid-pipeline rejects with ANALYZER_INVALID

  • WHEN an analyzer throws during process(query, signal) and signal.aborted is true
  • THEN the call rejects with SearchError(SEARCH.ANALYZER_INVALID) AND context.analyzer is the failing analyzer's name

Verified by: packages/platform/search/src/pipeline/search-pipeline.test.ts::"walks analyzers in registration order" AND packages/platform/search/src/pipeline/search-pipeline.test.ts::"SynonymAnalyzer expands one-way and bidirectional synonyms" AND packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts::"StopWordAnalyzer falls back when filtering would empty the list" AND packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts::"raises ANALYZER_INVALID when an analyzer throws under aborted signal"


Requirement: Facet, boost, and scoring shape types

The package SHALL expose FacetDistribution, FacetValue, FacetRequest, FieldBoost, ScoringFunction<T>, SearchSuggestion, SuggestOptions shape types co-located with the modules that consume them. Each SHALL be a typed interface (FieldBoost, FacetRequest, etc.) or a typed function alias (ScoringFunction<T>).

Scenario: FieldBoost weight is numeric

  • WHEN a FieldBoost literal { field: "title", weight: 3 } is constructed
  • THEN expectTypeOf(boost.weight).toEqualTypeOf<number>() passes AND boost.field is typed as string

Scenario: ScoringFunction signature

  • WHEN a ScoringFunction<{ id: string }> is declared
  • THEN expectTypeOf<ScoringFunction<{ id: string }>>().toEqualTypeOf<(doc: { id: string }, baseScore: number) => number>() passes

Verified by: packages/platform/search/src/query-builder/query-builder.test.ts::"FieldBoost weight is typed as number" AND packages/platform/search/src/client/search-client.spec-conformance.test.ts::"ScoringFunction signature is enforced via expectTypeOf"


Requirement: ElasticsearchAdapter against Elasticsearch 8.x REST API

The package SHALL expose an ElasticsearchAdapter<TDoc> class extending BaseElasticAdapter that implements SearchEngine<TDoc> against the Elasticsearch 8.x REST API via native fetch(). The adapter SHALL support full-text search via multi_match, faceted search via aggregations, sort/filter/pagination, suggestions via phrase_prefix, bulk indexing (NDJSON), index management (ensureIndex, deleteIndex, refresh), and authentication (basic, apiKey, bearer). Optional compatibilityMode: true SHALL switch request headers to ES 7 compatibility format.

Scenario: index + search round-trip

  • WHEN a document is indexed via adapter.index("1", { title: "Widget" }), the index is refreshed, and search({ text: "widget" }) is called
  • THEN the response contains a hit whose document.title === "Widget" AND document.id === "1"

Scenario: faceted search returns aggregations

  • WHEN a search query specifies facets: [{ field: "category" }]
  • THEN the request body includes an aggs.category.terms clause AND the response's facetDistribution is keyed by category with bucket counts

Scenario: compatibilityMode switches headers

  • WHEN the adapter is constructed with compatibilityMode: true
  • THEN every request carries Content-Type: application/json; compatible-with=7 and Accept: application/json; compatible-with=7

Scenario: 429 raises PROVIDER_RATE_LIMITED

  • WHEN the cluster responds 429 with Retry-After: 5
  • THEN the call rejects with SearchError(SEARCH.PROVIDER_RATE_LIMITED) AND context.retryAfterMs === 5000

Verified by: packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts::"index and search round-trip via fetch" AND packages/platform/search/src/adapters/base-elastic/base-elastic.test.ts::"faceted search translates to aggregations" AND packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts::"compatibilityMode switches headers to ES 7 format" AND packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts::"raises PROVIDER_RATE_LIMITED with retryAfterMs on 429"


Requirement: OpenSearchAdapter with optional AWS sigv4 signing

The package SHALL expose an OpenSearchAdapter<TDoc> class extending BaseElasticAdapter that targets OpenSearch 1.x/2.x (compatible with Amazon OpenSearch Service). The adapter SHALL accept an optional AwsSigningConfig whose sign(url, init) => Promise<RequestInit> hook is invoked once per request to add sigv4 headers. The package SHALL NOT bundle any AWS SDK; the signer is supplied by the caller. Signer rejection SHALL raise SearchError(SEARCH.AWS_SIGNING_FAILED).

Scenario: without awsSigning behaves like the base adapter

  • WHEN an OpenSearchAdapter is constructed without awsSigning
  • THEN every request goes through BaseElasticAdapter.request unchanged AND no sign() callback is invoked

Scenario: with awsSigning calls the signer once per request

  • WHEN an adapter is constructed with awsSigning: { region: "us-east-1", sign } and a search is dispatched
  • THEN sign(url, init) is invoked exactly once before the underlying fetch AND the returned RequestInit is the one that hits the wire

Scenario: signer rejection raises AWS_SIGNING_FAILED

  • WHEN the supplied sign() rejects with new Error("invalid creds")
  • THEN the adapter call rejects with SearchError(SEARCH.AWS_SIGNING_FAILED) AND cause is the original error

Verified by: packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"behaves like base adapter without awsSigning" AND packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"invokes the supplied signer once per request" AND packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"raises AWS_SIGNING_FAILED when the signer rejects"


Requirement: MeiliSearchAdapter against MeiliSearch HTTP API

The package SHALL expose a MeiliSearchAdapter<TDoc> class implementing SearchEngine<TDoc> against the MeiliSearch HTTP API via native fetch(). The adapter SHALL support indexing, removal, search with filter strings (translated from SearchFilter), faceted search via facetDistribution, suggestions via _formatted extraction, sort, pagination, and index management (ensureIndex patches searchableAttributes / filterableAttributes / sortableAttributes; deleteIndex removes the index entirely).

Scenario: filters translate to MeiliSearch filter strings

  • WHEN a search query carries filters [{ field: "category", op: "eq", value: "docs" }, { field: "price", op: "gte", value: 10 }]
  • THEN the outgoing request body has filter: ['category = "docs"', "price >= 10"]

Scenario: ensureIndex patches index settings

  • WHEN the adapter is configured with searchableAttributes: ["title"] and ensureIndex() is called
  • THEN a POST /indexes is dispatched followed by PATCH /indexes/<name>/settings with the supplied attributes in the body

Scenario: 400 raises QUERY_INVALID

  • WHEN MeiliSearch responds 400 with a malformed-query body
  • THEN the call rejects with SearchError(SEARCH.QUERY_INVALID) AND cause.message includes the response text

Verified by: packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts::"translates filters to MeiliSearch filter strings" AND packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts::"ensureIndex patches searchableAttributes" AND packages/platform/search/src/adapters/meilisearch/meilisearch.spec-conformance.test.ts::"raises QUERY_INVALID on 400 response"


Requirement: SearchEngine interface gains optional bulkIndex and ensureSchema

The SearchEngine<TDoc> interface SHALL gain two optional methods: bulkIndex(documents, options?): Promise<void> and ensureSchema(): Promise<void>. Both SHALL be optional so existing implementations (e.g. MemorySearchEngine) compile unchanged. When invoked on an engine that does not implement them, the default behaviour SHALL throw SearchError(SEARCH.NOT_IMPLEMENTED) carrying context.engine (the engine's name) and context.method.

Scenario: MemorySearchEngine compiles with the extended interface

  • WHEN MemorySearchEngine is instantiated and bulkIndex / ensureSchema are not implemented
  • THEN the type still satisfies SearchEngine<TDoc> AND calling engine.bulkIndex?.(docs) is undefined (the optional method is absent)

Scenario: NOT_IMPLEMENTED carries engine and method context

  • WHEN BaseElasticAdapter.bulkIndex is replaced with the default-throw stub and invoked
  • THEN the call rejects with SearchError(SEARCH.NOT_IMPLEMENTED) AND context.engine === "elastic" AND context.method === "bulkIndex"

Verified by: packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts::"NOT_IMPLEMENTED carries engine and method context" AND tests/dep-graph.test.ts::"MemorySearchEngine still satisfies SearchEngine without bulkIndex"