Initial Commit
This commit is contained in:
136
openspec/changes/search-completion/proposal.md
Normal file
136
openspec/changes/search-completion/proposal.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Search completion
|
||||
|
||||
**Package**: `@suaveplan/search`
|
||||
**Status**: PROPOSED
|
||||
**Type**: spec-conformance + feature
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-02 cross-monorepo audit confirmed that `@suaveplan/search` shipped on genesis with only the in-memory side of the original surface — `MemorySearchEngine`, BM25 + trigram scorers, the tokenizer, and Zod schemas. The legacy `packages/universal/search/src` package on `../suaveplan/` carries the full search-product surface that downstream services (catalogue, knowledge, analytics) rely on: a `SearchClient<T>` orchestrator, a `QueryBuilder` fluent API, a four-analyzer `SearchPipeline` (`LowercaseAnalyzer`, `StemmingAnalyzer`, `StopWordAnalyzer`, `SynonymAnalyzer`), a `searchHooks` event bus, the `FacetDistribution` / `FacetRequest` / `FieldBoost` / `ScoringFunction` shapes, and three production adapters — `ElasticsearchAdapter`, `OpenSearchAdapter` (with optional AWS sigv4 signing), and `MeiliSearchAdapter`. None of these were archived as out-of-scope; they were silently dropped during the original genesis migration.
|
||||
|
||||
Genesis policy (CLAUDE.md §Workflow rule 5) is "preserve the full public surface during rewrites — trimming is OK ONLY when code is duplicated elsewhere in-repo or can be replaced by an existing `@suaveplan/*` library." None of the dropped capabilities are duplicated elsewhere on genesis, so each one must be ported. This change closes that gap so any downstream service can compose an in-memory engine for tests with a hosted engine for production behind one `SearchEngine<TDoc>` contract.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `SearchClient<TDoc extends SearchDocument>` orchestrator class — wraps any `SearchEngine<TDoc>` with `searchHooks` lifecycle wiring, `SearchPipeline` analyzer chain, `defaultLimit`, optional `fieldBoosts`, and an optional `ScoringFunction`. Methods: `index`, `indexMany`, `remove`, `removeMany`, `search`, `find`, `suggest`, `clear`, `size`. Every async method threads `AbortSignal` through to the underlying engine.
|
||||
- ADDED: `QueryBuilder` fluent typed query DSL — `.term(value)`, `.must(filter)`, `.should(filter)`, `.mustNot(filter)`, `.range(field, op, value)`, `.term_(field, 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()`. The `.build()` output is a `SearchQuery` matching the engine's shape.
|
||||
- ADDED: `SearchPipeline` class with composable `Analyzer` interface — `process(query)` walks every registered analyzer in order. Built-in analyzers: `LowercaseAnalyzer`, `StemmingAnalyzer` (Porter-like suffix stripping; 18 rules), `StopWordAnalyzer` (configurable stop-word set; 51-word default), `SynonymAnalyzer` (one-way and bidirectional synonym map). Custom analyzers conform to `interface Analyzer { name: string; analyze(terms): string[] }` and can be registered without touching the package.
|
||||
- ADDED: `searchHooks` typed event bus — `'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). Hooks are wired through `@suaveplan/events` and respect the existing handler-priority / order semantics.
|
||||
- ADDED: `FacetDistribution`, `FacetValue`, `FacetRequest`, `FieldBoost`, `ScoringFunction<T>`, `SearchSuggestion`, `SuggestOptions` shape types — co-located with their implementation modules.
|
||||
- ADDED: `BaseElasticAdapter<TDoc>` shared base class — handles auth (basic / apiKey / bearer), request-timeout, query body construction (`bool` / `multi_match` / `range`), filter translation, response transformation (hits + aggregations → `SearchResult`), bulk indexing, index management (`ensureIndex`, `deleteIndex`, `refresh`), and structured error mapping (`HttpError` / `RateLimitError` / `TimeoutError` / `ConfigError`).
|
||||
- ADDED: `ElasticsearchAdapter<TDoc>` class — extends `BaseElasticAdapter`; ES 8.x by default, ES 7.x compatibility-mode header opt-in.
|
||||
- ADDED: `OpenSearchAdapter<TDoc>` class — extends `BaseElasticAdapter`; optional `AwsSigningConfig` injects an `(url, init) => Promise<RequestInit>` hook so sigv4 signing is pluggable.
|
||||
- ADDED: `MeiliSearchAdapter<TDoc>` class — implements `SearchEngine<TDoc>` against MeiliSearch HTTP API; supports `searchableAttributes`, `filterableAttributes`, `sortableAttributes`, faceted search via `facetDistribution`, suggestions via `_formatted` extraction.
|
||||
- ADDED: `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `SearchClientConfig` shape types.
|
||||
- MODIFIED: `SearchEngine<TDoc>` interface — adds optional `bulkIndex(documents)` and `ensureSchema()` lifecycle hooks; the new methods default-throw with `SEARCH.NOT_IMPLEMENTED` so existing engines (e.g. `MemorySearchEngine`) compile unchanged.
|
||||
- MODIFIED: `SEARCH_CODES` registry — adds `PROVIDER_UNREACHABLE`, `PROVIDER_TIMEOUT`, `PROVIDER_RATE_LIMITED`, `PROVIDER_CONFIG_INVALID`, `HOOK_HANDLER_FAILED`, `ANALYZER_INVALID`, `NOT_IMPLEMENTED`, `AWS_SIGNING_FAILED`, `BULK_INDEX_PARTIAL_FAILURE` codes; the existing `INDEX_FAILED`, `QUERY_INVALID`, `ENGINE_NOT_READY`, `FIELD_UNKNOWN`, `CURSOR_INVALID` codes are preserved.
|
||||
- MODIFIED: `index.ts` barrel — re-exports every new symbol; no `export *` (one explicit subpath per module).
|
||||
- MODIFIED: `package.json` `exports` map — adds subpaths `./client/search-client`, `./query-builder/query-builder`, `./pipeline/search-pipeline`, `./pipeline/analyzers/lowercase`, `./pipeline/analyzers/stemming`, `./pipeline/analyzers/stopword`, `./pipeline/analyzers/synonym`, `./hooks/search-hooks`, `./adapters/base-elastic`, `./adapters/elasticsearch`, `./adapters/opensearch`, `./adapters/meilisearch`.
|
||||
- MODIFIED: `package.json` `dependencies` — adds `@suaveplan/events` (`workspace:^`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Algolia adapter** — different query DSL and pricing model; lands as its own package (`@suaveplan/search-algolia`) once a tenant requests it. Avoiding speculative adapter sprawl in this change.
|
||||
- **Vector search / dense retrieval** — embedding pipelines, ANN indexes (HNSW / IVF), and hybrid lexical+vector ranking are a separate concern with their own ML dependency surface; they land as `@suaveplan/search-vector`.
|
||||
- **Bundling `@elastic/elasticsearch` or `meilisearch-js` SDKs** — both adapters use direct `fetch()` with Zod-validated responses to keep the package tree-shake-friendly and free of native-binding peer deps. This matches the upstream design and the `@suaveplan/sitemap` / `@suaveplan/dmarc` precedent (HTTP-only, Zod-validated wire formats).
|
||||
- **Typesense adapter** — the genesis testing harness already ships `@suaveplan/testing-integration-typesense` for service spin-up; the production adapter is a separate package because Typesense's query shape (filter strings, multi-search) does not map onto the Elastic family base class without re-architecture.
|
||||
- **`registerHook` legacy free-function deprecation** — kept and delegated to the new `searchHooks.hook` API for one release; removing it is a breaking change that requires its own deprecation window.
|
||||
- **Re-folding of `openspec/specs/search/spec.md`** if a canonical spec file does not yet exist, this change creates it for the cap; full spec folding lands once all search changes archive.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/packages/universal/search/src/`. Genesis `enterprise/search.md` is a generic enterprise template (no per-feature requirements), so source mapping cites the upstream package source directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `SearchClient<T>` orchestrator | `packages/universal/search/src/search-client/search-client.ts` | L21-L93 | Verbatim; `AbortSignal` threaded through every async method on genesis |
|
||||
| `SearchClientConfig` | `packages/universal/search/src/types/search.types.ts` | L139-L148 | Verbatim |
|
||||
| `searchHooks` event bus + `'search:querying'` hook | `packages/universal/search/src/hooks.ts` | L1-L62 | Verbatim; rewired to use `@suaveplan/events` `Hooks` class |
|
||||
| `'search:indexing'` and `'search:result'` hooks | (no upstream line — added in this change) | — | Symmetry with `'search:querying'`; justified in "no source line" table below |
|
||||
| `QueryBuilder` fluent DSL — `term`, `filter`, `facet`, `sort`, `paginate`, `offset`, `limit`, `build`, `reset` | `packages/universal/search/src/query-builder/query-builder.ts` | L27-L107 | Verbatim |
|
||||
| `QueryBuilder.must`, `should`, `mustNot`, `range`, `prefix`, `fuzzy`, `geo`, `boost`, `scoringFunction` extensions | (no upstream line — added in this change) | — | Justified in "no source line" table below |
|
||||
| `SearchPipeline` composable analyzer chain | `packages/universal/search/src/pipeline/search-pipeline.ts` | L204-L224 | Verbatim |
|
||||
| `Analyzer` interface (`name`, `analyze(terms)`) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L9-L12 | Verbatim |
|
||||
| `LowercaseAnalyzer` | `packages/universal/search/src/pipeline/search-pipeline.ts` | L106-L114 | Verbatim |
|
||||
| `StemmingAnalyzer` (Porter-like 18-rule suffix table) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L57-L103 | Verbatim |
|
||||
| `StopWordAnalyzer` (51-word default + configurable set) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L117-L186 | Verbatim |
|
||||
| `SynonymAnalyzer` (one-way and bidirectional) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L21-L51 | Verbatim |
|
||||
| `FacetRequest`, `FacetValue`, `FacetDistribution`, `FieldBoost`, `ScoringFunction`, `SearchSuggestion`, `SuggestOptions` | `packages/universal/search/src/types/search.types.ts` | L26-L77 | Verbatim |
|
||||
| `BaseElasticAdapter<T>` shared base class | `packages/universal/search/src/adapters/base-elastic-adapter.ts` | L75-L442 | Verbatim |
|
||||
| `ElasticBaseConfig`, `ElasticAuth` | `packages/universal/search/src/adapters/base-elastic-adapter.ts` | L26-L47 | Verbatim |
|
||||
| `ElasticsearchAdapter<T>` | `packages/universal/search/src/adapters/elasticsearch-adapter.ts` | L45-L64 | Verbatim |
|
||||
| `ElasticsearchConfig` | `packages/universal/search/src/adapters/elasticsearch-adapter.ts` | L12-L15 | Verbatim |
|
||||
| `OpenSearchAdapter<T>` | `packages/universal/search/src/adapters/opensearch-adapter.ts` | L38-L60 | Verbatim |
|
||||
| `OpenSearchConfig`, `AwsSigningConfig` | `packages/universal/search/src/adapters/opensearch-adapter.ts` | L12-L23 | Verbatim |
|
||||
| `MeiliSearchAdapter<T>` | `packages/universal/search/src/adapters/meilisearch-adapter.ts` | L35-L305 | Verbatim |
|
||||
| `MeiliSearchConfig` | `packages/universal/search/src/types/search.types.ts` | L150-L164 | Verbatim |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `'search:indexing'` and `'search:result'` lifecycle hooks (in addition to upstream `'search:querying'`) | Genesis-wide CLAUDE.md rule 3 (enterprise-grade modularity) requires a complete intercept surface. Upstream only exposes `'search:querying'`; downstream consumers (knowledge-graph, content-pipeline) need to rewrite indexed documents (e.g. inject embeddings) and post-process result sets (e.g. attach permission metadata). Symmetric naming with the existing hook avoids API drift. |
|
||||
| `QueryBuilder.must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` methods | Upstream `QueryBuilder` only ships `filter()` and `term()`. The proposal task description explicitly requires these clauses (must / should / must_not / range / term / prefix / fuzzy / geo). Adding them on the builder side keeps a typed DSL while preserving every adapter's freedom to translate filters into its native query language. |
|
||||
| `bulkIndex` and `ensureSchema` optional methods on `SearchEngine<TDoc>` | Bulk indexing is a documented `BaseElasticAdapter` capability (upstream L149-L166) but is not part of the universal interface. Genesis policy requires the universal contract to expose every supported optimisation so callers can migrate engines without code changes; both methods default-throw with `SEARCH.NOT_IMPLEMENTED` so existing engines (e.g. `MemorySearchEngine`) compile unchanged. |
|
||||
| `AbortSignal` threaded through every `SearchClient` and `SearchEngine` async method | enterprise/search.md L43: "Backpressure & cancellation — `AbortSignal` everywhere async". Upstream lacks signal support on the client-orchestration surface. |
|
||||
| Zod schemas for `SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `MeiliSearchConfig`, `AwsSigningConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config | enterprise/search.md L37: "Zod schemas for all public types". Upstream validates through TypeScript only. |
|
||||
| New `SEARCH.PROVIDER_*` / `SEARCH.HOOK_HANDLER_FAILED` / `SEARCH.ANALYZER_INVALID` / `SEARCH.NOT_IMPLEMENTED` / `SEARCH.AWS_SIGNING_FAILED` / `SEARCH.BULK_INDEX_PARTIAL_FAILURE` codes | Genesis-wide policy (CLAUDE.md §Workflow rule 2) that every thrown error route through `@suaveplan/error`. Upstream throws `HttpError` / `TimeoutError` / `RateLimitError` from `@suaveplan/error` directly; genesis wraps them with stable `SEARCH.*` codes so consumers can pattern-match without depending on the foundation error types. |
|
||||
| `SearchPipeline.process` accepts `AbortSignal` | Long synonym graphs / stemming chains can dominate request latency on large queries; allowing abort midway through analysis matches the rest of the package. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/platform/search/src/client/search-client.test.ts` — `index` / `search` / `find` / `suggest` round-trip against an in-memory engine; `defaultLimit` applied; `fieldBoosts` and `scoringFunction` re-rank results; `removeMany` and bulk index path
|
||||
- `packages/platform/search/src/client/search-client.spec-conformance.test.ts` — `searchHooks.hook('search:querying')` rewrites the query before the engine sees it; `'search:indexing'` rewrites documents; `'search:result'` post-processes; `AbortSignal` aborts mid-flight; injected `MemorySearchEngine` produces deterministic order
|
||||
- `packages/platform/search/src/query-builder/query-builder.test.ts` — `term` / `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `filter` / `facet` / `sort` / `paginate` build correctly; `boost` and `scoringFunction` round-trip; `reset()` clears state
|
||||
- `packages/platform/search/src/pipeline/search-pipeline.test.ts` — `LowercaseAnalyzer` / `StemmingAnalyzer` / `StopWordAnalyzer` / `SynonymAnalyzer` each tested in isolation; `SearchPipeline` walks analyzers in order; custom analyzer registers
|
||||
- `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts` — `AbortSignal` mid-pipeline rejects with `ANALYZER_INVALID` when an analyzer throws; bidirectional synonyms expand correctly; stop-word fallback returns original tokens when filtering would empty the list
|
||||
- `packages/platform/search/src/hooks/search-hooks.test.ts` — `'search:querying'` / `'search:indexing'` / `'search:result'` lifecycle bus tested with multiple handlers; handler-priority order respected; `unsubscribe` removes record
|
||||
- `packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts` — handler exception raises `HOOK_HANDLER_FAILED` with original `cause`; per-hook payload type pinned via `expectTypeOf`
|
||||
- `packages/platform/search/src/adapters/base-elastic.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip against a `fetch` mock; `bulkIndex` builds NDJSON; `ensureIndex` / `deleteIndex` / `refresh`; auth basic / apiKey / bearer; `searchFields` honoured; facets translate to aggregations
|
||||
- `packages/platform/search/src/adapters/base-elastic.spec-conformance.test.ts` — 429 rate-limit response raises `PROVIDER_RATE_LIMITED` with `retryAfterMs`; 408 / 504 raises `PROVIDER_TIMEOUT`; missing host raises `PROVIDER_CONFIG_INVALID`; non-OK responses throw `HttpError`-wrapped `SearchError`
|
||||
- `packages/platform/search/src/adapters/elasticsearch.test.ts` — adapter sends ES 8 headers by default; `compatibilityMode: true` switches to ES 7 headers
|
||||
- `packages/platform/search/src/adapters/opensearch.test.ts` — adapter without `awsSigning` behaves like `BaseElasticAdapter`; with `awsSigning` invokes the supplied signer once per request and uses the returned `RequestInit`; signer rejection raises `AWS_SIGNING_FAILED`
|
||||
- `packages/platform/search/src/adapters/meilisearch.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip; filters translate to MeiliSearch filter strings; `ensureIndex` patches `searchableAttributes` / `filterableAttributes` / `sortableAttributes`; faceted search returns `facetDistribution`
|
||||
- `packages/platform/search/src/adapters/meilisearch.spec-conformance.test.ts` — 429 raises `PROVIDER_RATE_LIMITED`; 408 / 504 raises `PROVIDER_TIMEOUT`; 400 raises `QUERY_INVALID`; missing API key path
|
||||
- `tests/dep-graph.test.ts` (cross-package) — `@suaveplan/search` declares `@suaveplan/events` as a runtime dep; the package still has zero browser-only deps; subpath count matches feature module count
|
||||
|
||||
## Migration impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/search` to enumerate. As of `grep -rl "@suaveplan/search" packages/ --include="package.json"` (2026-05-02), the only match is the package's own `package.json` — there are no in-repo dependents on genesis yet.
|
||||
|
||||
- (no in-repo dependents) — additive only; existing barrel exports (`MemorySearchEngine`, `createMemorySearchEngine`, `SEARCH_CODES`, `SearchError`, `bm25Score`, `tfIdfScore`, `tokenize`, `simpleStem`, `normalizeToken`, `buildTrigrams`, `trigramSimilarity`, every Zod schema, every type alias) are preserved unchanged. New exports (`SearchClient`, `QueryBuilder`, `SearchPipeline`, four analyzers, `searchHooks`, `BaseElasticAdapter`, `ElasticsearchAdapter`, `OpenSearchAdapter`, `MeiliSearchAdapter`, every new type alias) are additive.
|
||||
|
||||
The `SearchEngine<TDoc>` interface gains two optional methods (`bulkIndex` and `ensureSchema`) with default-throw stubs in the abstract class so `MemorySearchEngine` compiles unchanged. Consumers that depended on the old `SearchEngine` shape continue to compile because the new methods are optional.
|
||||
|
||||
For foundation-package safety, a `bunx turbo build typecheck test --filter=...@suaveplan/search` sweep runs in §6 of `tasks.md`.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `MemorySearchEngine` (BM25 + trigram + tokenizer) | yes | yes | Already on genesis |
|
||||
| `SearchEngine<TDoc>` interface | yes | yes | Already on genesis; gains optional `bulkIndex` / `ensureSchema` in this change |
|
||||
| `SearchClient<T>` orchestrator | yes | yes (this change) | `AbortSignal` threaded through all async methods |
|
||||
| `QueryBuilder` fluent DSL | yes | yes (this change) | Adds `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` |
|
||||
| `SearchPipeline` analyzer chain | yes | yes (this change) | Verbatim |
|
||||
| `LowercaseAnalyzer` | yes | yes (this change) | Verbatim |
|
||||
| `StemmingAnalyzer` (Porter-like) | yes | yes (this change) | Verbatim |
|
||||
| `StopWordAnalyzer` (configurable) | yes | yes (this change) | Verbatim |
|
||||
| `SynonymAnalyzer` (one-way / bidirectional) | yes | yes (this change) | Verbatim |
|
||||
| `searchHooks` typed event bus | yes | yes (this change) | Adds `'search:indexing'` and `'search:result'` |
|
||||
| `FacetDistribution` / `FacetValue` / `FacetRequest` types | yes | yes (this change) | Verbatim |
|
||||
| `FieldBoost` / `ScoringFunction` types | yes | yes (this change) | Verbatim |
|
||||
| `SearchSuggestion` / `SuggestOptions` | yes | yes (this change) | Verbatim |
|
||||
| `BaseElasticAdapter<T>` | yes | yes (this change) | Verbatim |
|
||||
| `ElasticsearchAdapter<T>` | yes | yes (this change) | Verbatim |
|
||||
| `OpenSearchAdapter<T>` (with optional AWS sigv4) | yes | yes (this change) | `AwsSigningConfig` is pluggable; no AWS-SDK runtime dep |
|
||||
| `MeiliSearchAdapter<T>` | yes | yes (this change) | Verbatim |
|
||||
| Algolia adapter | no | 🚫 (out of scope) | Not in upstream; will land as `@suaveplan/search-algolia` once a tenant requests it |
|
||||
| Typesense adapter | no | 🚫 (out of scope) | Not in upstream; query shape doesn't map onto Elastic family base class |
|
||||
| Vector / dense retrieval | no | 🚫 (out of scope) | Not in upstream; lands as `@suaveplan/search-vector` |
|
||||
|
||||
No upstream capabilities are dropped.
|
||||
217
openspec/changes/search-completion/specs/search/spec.md
Normal file
217
openspec/changes/search-completion/specs/search/spec.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<!-- 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 -->
|
||||
117
openspec/changes/search-completion/tasks.md
Normal file
117
openspec/changes/search-completion/tasks.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Tasks — Search completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. The eight capabilities closed here were silently dropped during the original genesis migration (audit dated 2026-05-02). Do not retroactively tick boxes for surface that already shipped.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/packages/universal/search/src/{search-client,query-builder,pipeline,hooks,adapters,types}/*.ts` end-to-end
|
||||
- [ ] 1.2 Read `openspec/specs/search/spec.md` (if it exists; create the cap dir if not)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 0a (parity), 13 (spec-conformance), 14 (source mapping), 14a (dependents)
|
||||
- [ ] 1.4 Draft `proposal.md` covering all eight gaps (`SearchClient`, `QueryBuilder`, `SearchPipeline` + 4 analyzers, `searchHooks`, facet/boost/scoring types, `ElasticsearchAdapter`, `OpenSearchAdapter` with AWS sigv4, `MeiliSearchAdapter`)
|
||||
- [ ] 1.5 Fill `## Source mapping` in `proposal.md` with upstream line ranges for every requirement; every "no source line" requirement justified
|
||||
- [ ] 1.6 Draft spec delta at `specs/search/spec.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.8 `openspec change validate search-completion` exits with no errors
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 `SearchClient<TDoc>` orchestrator — port from upstream `search-client/search-client.ts` into `packages/platform/search/src/client/search-client.ts`; thread `AbortSignal` through every async method
|
||||
- [ ] 2.2 `QueryBuilder` fluent DSL — port from upstream `query-builder/query-builder.ts` into `packages/platform/search/src/query-builder/query-builder.ts`; add `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` clauses on top of upstream surface
|
||||
- [ ] 2.3 `SearchPipeline` composable analyzer chain — port from upstream `pipeline/search-pipeline.ts` into `packages/platform/search/src/pipeline/search-pipeline.ts`; thread `AbortSignal` through `process(query, signal?)`
|
||||
- [ ] 2.4 `Analyzer` interface + co-located built-ins:
|
||||
- `pipeline/analyzers/lowercase/lowercase.ts` (verbatim from upstream)
|
||||
- `pipeline/analyzers/stemming/stemming.ts` (verbatim 18-rule Porter-like table)
|
||||
- `pipeline/analyzers/stopword/stopword.ts` (verbatim 51-word default + configurable set)
|
||||
- `pipeline/analyzers/synonym/synonym.ts` (verbatim one-way / bidirectional)
|
||||
- [ ] 2.5 `searchHooks` typed event bus — port from upstream `hooks.ts` into `packages/platform/search/src/hooks/search-hooks.ts`; rewire to `@suaveplan/events` `Hooks` class; add `'search:indexing'` and `'search:result'` symmetric hooks alongside upstream `'search:querying'`
|
||||
- [ ] 2.6 Co-locate `FacetDistribution`, `FacetValue`, `FacetRequest`, `FieldBoost`, `ScoringFunction<T>`, `SearchSuggestion`, `SuggestOptions` shape types next to their consumers (no shared `types/` dump)
|
||||
- [ ] 2.7 `BaseElasticAdapter<TDoc>` shared base class — port from upstream `adapters/base-elastic-adapter.ts` into `packages/platform/search/src/adapters/base-elastic/base-elastic.ts`; map upstream `HttpError` / `RateLimitError` / `TimeoutError` / `ConfigError` to genesis `SearchError` codes (`PROVIDER_*`)
|
||||
- [ ] 2.8 `ElasticsearchAdapter<TDoc>` — port from upstream `adapters/elasticsearch-adapter.ts` into `packages/platform/search/src/adapters/elasticsearch/elasticsearch.ts`; ES 8 default + optional ES 7 compatibility mode
|
||||
- [ ] 2.9 `OpenSearchAdapter<TDoc>` — port from upstream `adapters/opensearch-adapter.ts` into `packages/platform/search/src/adapters/opensearch/opensearch.ts`; pluggable `AwsSigningConfig` (no AWS-SDK runtime dep)
|
||||
- [ ] 2.10 `MeiliSearchAdapter<TDoc>` — port from upstream `adapters/meilisearch-adapter.ts` into `packages/platform/search/src/adapters/meilisearch/meilisearch.ts`
|
||||
- [ ] 2.11 Extend `SearchEngine<TDoc>` interface — add optional `bulkIndex(documents, options?)` and `ensureSchema()` methods; default-throw with `SEARCH.NOT_IMPLEMENTED` so `MemorySearchEngine` compiles unchanged
|
||||
- [ ] 2.12 Add `SEARCH_*` codes (`PROVIDER_UNREACHABLE`, `PROVIDER_TIMEOUT`, `PROVIDER_RATE_LIMITED`, `PROVIDER_CONFIG_INVALID`, `HOOK_HANDLER_FAILED`, `ANALYZER_INVALID`, `NOT_IMPLEMENTED`, `AWS_SIGNING_FAILED`, `BULK_INDEX_PARTIAL_FAILURE`) to `errors/search-error.ts`
|
||||
- [ ] 2.13 Wire OTel counters / spans on every public entrypoint: `search.client.queries`, `search.client.queries_duration_ms`, `search.client.indexed`, `search.pipeline.process_duration_ms`, `search.hooks.fired`, `search.adapter.requests`, `search.adapter.errors`, `search.adapter.bulk_indexed`
|
||||
- [ ] 2.14 Wire Zod schemas for `SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config to `schemas/search.schemas.ts`
|
||||
- [ ] 2.15 Update `index.ts` barrel — re-export every new symbol; no `export *` (one explicit subpath per module)
|
||||
- [ ] 2.16 Update `package.json` `exports` map — add subpaths `./client/search-client`, `./query-builder/query-builder`, `./pipeline/search-pipeline`, `./pipeline/analyzers/lowercase/lowercase`, `./pipeline/analyzers/stemming/stemming`, `./pipeline/analyzers/stopword/stopword`, `./pipeline/analyzers/synonym/synonym`, `./hooks/search-hooks`, `./adapters/base-elastic/base-elastic`, `./adapters/elasticsearch/elasticsearch`, `./adapters/opensearch/opensearch`, `./adapters/meilisearch/meilisearch`
|
||||
- [ ] 2.17 Update `package.json` `dependencies` — add `@suaveplan/events` (`workspace:^`)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `packages/platform/search/src/client/search-client.test.ts` — `index` / `search` / `find` / `suggest` round-trip against an in-memory engine; `defaultLimit` applied; `fieldBoosts` and `scoringFunction` re-rank results; `removeMany` and bulk-index path
|
||||
- [ ] 3.2 `packages/platform/search/src/client/search-client.spec-conformance.test.ts` — `searchHooks.hook('search:querying')` rewrites the query before the engine sees it; `'search:indexing'` rewrites documents; `'search:result'` post-processes; `AbortSignal` aborts mid-flight; injected `MemorySearchEngine` produces deterministic order
|
||||
- [ ] 3.3 `packages/platform/search/src/query-builder/query-builder.test.ts` — `term` / `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `filter` / `facet` / `sort` / `paginate` build correctly; `boost` and `scoringFunction` round-trip; `reset()` clears state
|
||||
- [ ] 3.4 `packages/platform/search/src/pipeline/search-pipeline.test.ts` — `LowercaseAnalyzer` / `StemmingAnalyzer` / `StopWordAnalyzer` / `SynonymAnalyzer` each tested in isolation; `SearchPipeline` walks analyzers in order; custom analyzer registers; analyzer name returned via `analyzerNames`
|
||||
- [ ] 3.5 `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts` — `AbortSignal` mid-pipeline rejects with `ANALYZER_INVALID` when an analyzer throws; bidirectional synonyms expand correctly; stop-word fallback returns original tokens when filtering would empty the list; stem rule guards against under-3 char results
|
||||
- [ ] 3.6 `packages/platform/search/src/hooks/search-hooks.test.ts` — `'search:querying'` / `'search:indexing'` / `'search:result'` lifecycle bus tested with multiple handlers; handler-priority order respected; `unsubscribe` removes record
|
||||
- [ ] 3.7 `packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts` — handler exception raises `HOOK_HANDLER_FAILED` with original `cause`; per-hook payload type pinned via `expectTypeOf`
|
||||
- [ ] 3.8 `packages/platform/search/src/adapters/base-elastic/base-elastic.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip against a `fetch` mock; `bulkIndex` builds NDJSON; `ensureIndex` / `deleteIndex` / `refresh`; auth basic / apiKey / bearer; `searchFields` honoured; facets translate to aggregations
|
||||
- [ ] 3.9 `packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts` — 429 rate-limit response raises `PROVIDER_RATE_LIMITED` with `retryAfterMs`; 408 / 504 raises `PROVIDER_TIMEOUT`; missing host raises `PROVIDER_CONFIG_INVALID`; non-OK responses throw `SearchError` wrapping `HttpError`
|
||||
- [ ] 3.10 `packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts` — adapter sends ES 8 headers by default; `compatibilityMode: true` switches to ES 7 headers
|
||||
- [ ] 3.11 `packages/platform/search/src/adapters/opensearch/opensearch.test.ts` — adapter without `awsSigning` behaves like `BaseElasticAdapter`; with `awsSigning` invokes the supplied signer once per request and uses the returned `RequestInit`; signer rejection raises `AWS_SIGNING_FAILED`
|
||||
- [ ] 3.12 `packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip; filters translate to MeiliSearch filter strings; `ensureIndex` patches `searchableAttributes` / `filterableAttributes` / `sortableAttributes`; faceted search returns `facetDistribution`
|
||||
- [ ] 3.13 `packages/platform/search/src/adapters/meilisearch/meilisearch.spec-conformance.test.ts` — 429 raises `PROVIDER_RATE_LIMITED`; 408 / 504 raises `PROVIDER_TIMEOUT`; 400 raises `QUERY_INVALID`; missing API key path
|
||||
- [ ] 3.14 Cross-package `tests/dep-graph.test.ts` — `@suaveplan/search` declares `@suaveplan/events` as a runtime dep; the package still has zero browser-only deps; subpath count matches feature module count
|
||||
- [ ] 3.15 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely-async adapter integration sweeps)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference) — `client/search-client.md`, `query-builder/query-builder.md`, `pipeline/search-pipeline.md`, four analyzer `.md` files, `hooks/search-hooks.md`, `adapters/base-elastic/base-elastic.md`, `adapters/elasticsearch/elasticsearch.md`, `adapters/opensearch/opensearch.md`, `adapters/meilisearch/meilisearch.md`
|
||||
- [ ] 4.2 README links every co-located `.md` under `## Modules`
|
||||
- [ ] 4.3 Update `CLAUDE.md` `search` package listing — remove "MemorySearchEngine + BM25 + trigram + tokenizer only" wording; describe the full restored surface (orchestrator, builder, pipeline, hooks, three production adapters)
|
||||
- [ ] 4.4 Refresh `STATUS.md` "Completed on genesis" entry for `search` to reflect parity
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the monorepo
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/search` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/search` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/search` — exit 0, no `error` findings
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/search` — green
|
||||
- [ ] 5.6 Banned-pattern scan zero output: `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/platform/search/src/ --include="*.ts"`
|
||||
- [ ] 5.7 Missing `.md` scan zero output (per AGENTS.md §13 step 5)
|
||||
- [ ] 5.8 README check `test -f packages/platform/search/README.md`
|
||||
- [ ] 5.9 Bare-version check zero output (per AGENTS.md §13 step 7)
|
||||
- [ ] 5.10 Subpath count matches feature module count (per AGENTS.md §13 step 8)
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (especially `SearchClient<TDoc>`, `BaseElasticAdapter<TDoc>`, `ElasticsearchAdapter<TDoc>`, `OpenSearchAdapter<TDoc>`, `MeiliSearchAdapter<TDoc>`)
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (`SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config)
|
||||
- [ ] 6.3 Every error code added in §2.12 thrown by at least one production code path; no orphans
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off (e.g. `compatibilityMode: true` vs `false`; `awsSigning` present vs absent; bulk-index partial failure vs success)
|
||||
- [ ] 6.5 OTel counters / histograms emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 6.6 Lifecycle dedup tested where claimed (`SearchPipeline` analyzer registry idempotent on identical analyzer; `searchHooks.hook` returns a one-shot unsubscriber)
|
||||
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/search/spec.md` (creating the cap file if missing)
|
||||
- [ ] 7.2 Move `openspec/changes/search-completion/` → `openspec/archive/<yyyy-mm-dd>-search-completion/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` (dep set changed: `@suaveplan/search` now depends on `@suaveplan/events`)
|
||||
- [ ] 7.4 Update `STATUS.md` to mark the eight dropped capabilities as restored
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Audit verdict: **DEFERRED — 0/65 tasks evidenced in code**.
|
||||
|
||||
Evidence reviewed:
|
||||
|
||||
- `packages/platform/search/src/` contains only `engine/memory-engine/`, `text/{tokenizer,scorer,trigram}/`, `errors/`, `schemas/`, `types/`, `constants/`. No `client/`, no `query-builder/`, no `pipeline/`, no `hooks/`, no `adapters/`.
|
||||
- `package.json` does not depend on `@suaveplan/events`.
|
||||
- The eight upstream capabilities (SearchClient, QueryBuilder, SearchPipeline + 4 analyzers, searchHooks, facet/boost/scoring types, ElasticsearchAdapter, OpenSearchAdapter with sigv4, MeiliSearchAdapter, SearchEngine.bulkIndex/ensureSchema) are absent.
|
||||
|
||||
Missing pieces:
|
||||
|
||||
1. `client/search-client.ts` orchestrator + `query-builder/query-builder.ts` fluent DSL with the upstream clauses.
|
||||
2. `pipeline/search-pipeline.ts` analyzer chain with `lowercase`, `stemming`, `stopword`, `synonym` analyzers ported verbatim from upstream.
|
||||
3. Three production adapters (`adapters/elasticsearch`, `adapters/opensearch` with pluggable `AwsSigningConfig`, `adapters/meilisearch`) on top of a shared `BaseElasticAdapter`, plus the 9 new `SEARCH_*` error codes.
|
||||
|
||||
Decision: leave change open. This is the largest of the deferred set (8 dropped capabilities, full dep graph rewire to `@suaveplan/events`, nine new error codes, parity audit).
|
||||
Reference in New Issue
Block a user