16 KiB
Capability Spec Delta: search (search-completion)
Package: @suaveplan/search
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.mdSection 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 anddefaultLimitis25 - THEN the engine receives a query with
pagination.limit: 25AND the response carriestotalreflecting 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 overlappingbodyandtitlematches - 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 andcontroller.abort()fires - THEN the engine's
searchinvocation also receives the same signal AND the call rejects withAbortError
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
10and5 - THEN the priority-
10handler runs first AND its mutation is visible to the priority-5handler
Scenario: handler exception raises HOOK_HANDLER_FAILED
- WHEN a subscribed handler throws synchronously
- THEN the dispatch rejects with
SearchError(SEARCH.HOOK_HANDLER_FAILED)ANDcauseis the original exception ANDcontext.hookis 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
SearchQueryhastext: "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)andsignal.abortedis true - THEN the call rejects with
SearchError(SEARCH.ANALYZER_INVALID)ANDcontext.analyzeris the failing analyzer'sname
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
FieldBoostliteral{ field: "title", weight: 3 }is constructed - THEN
expectTypeOf(boost.weight).toEqualTypeOf<number>()passes ANDboost.fieldis typed asstring
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, andsearch({ text: "widget" })is called - THEN the response contains a hit whose
document.title === "Widget"ANDdocument.id === "1"
Scenario: faceted search returns aggregations
- WHEN a search query specifies
facets: [{ field: "category" }] - THEN the request body includes an
aggs.category.termsclause AND the response'sfacetDistributionis keyed bycategorywith bucket counts
Scenario: compatibilityMode switches headers
- WHEN the adapter is constructed with
compatibilityMode: true - THEN every request carries
Content-Type: application/json; compatible-with=7andAccept: 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)ANDcontext.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
OpenSearchAdapteris constructed withoutawsSigning - THEN every request goes through
BaseElasticAdapter.requestunchanged AND nosign()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 underlyingfetchAND the returnedRequestInitis the one that hits the wire
Scenario: signer rejection raises AWS_SIGNING_FAILED
- WHEN the supplied
sign()rejects withnew Error("invalid creds") - THEN the adapter call rejects with
SearchError(SEARCH.AWS_SIGNING_FAILED)ANDcauseis 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"]andensureIndex()is called - THEN a
POST /indexesis dispatched followed byPATCH /indexes/<name>/settingswith 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)ANDcause.messageincludes 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
MemorySearchEngineis instantiated andbulkIndex/ensureSchemaare not implemented - THEN the type still satisfies
SearchEngine<TDoc>AND callingengine.bulkIndex?.(docs)isundefined(the optional method is absent)
Scenario: NOT_IMPLEMENTED carries engine and method context
- WHEN
BaseElasticAdapter.bulkIndexis replaced with the default-throw stub and invoked - THEN the call rejects with
SearchError(SEARCH.NOT_IMPLEMENTED)ANDcontext.engine === "elastic"ANDcontext.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"