# 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` class that wraps any `SearchEngine` 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`, `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`). #### Scenario: FieldBoost weight is numeric - **WHEN** a `FieldBoost` literal `{ field: "title", weight: 3 }` is constructed - **THEN** `expectTypeOf(boost.weight).toEqualTypeOf()` passes AND `boost.field` is typed as `string` #### Scenario: ScoringFunction signature - **WHEN** a `ScoringFunction<{ id: string }>` is declared - **THEN** `expectTypeOf>().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` class extending `BaseElasticAdapter` that implements `SearchEngine` 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` 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` 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` class implementing `SearchEngine` 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//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` interface SHALL gain two optional methods: `bulkIndex(documents, options?): Promise` and `ensureSchema(): Promise`. 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` 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"`