Files
suaveplan/openspec/changes/search-completion/specs/search/spec.md
2026-05-27 23:29:53 +01:00

218 lines
16 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->