20 KiB
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 anySearchEngine<TDoc>withsearchHookslifecycle wiring,SearchPipelineanalyzer chain,defaultLimit, optionalfieldBoosts, and an optionalScoringFunction. Methods:index,indexMany,remove,removeMany,search,find,suggest,clear,size. Every async method threadsAbortSignalthrough to the underlying engine. - ADDED:
QueryBuilderfluent 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 aSearchQuerymatching the engine's shape. - ADDED:
SearchPipelineclass with composableAnalyzerinterface —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 tointerface Analyzer { name: string; analyze(terms): string[] }and can be registered without touching the package. - ADDED:
searchHookstyped 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/eventsand respect the existing handler-priority / order semantics. - ADDED:
FacetDistribution,FacetValue,FacetRequest,FieldBoost,ScoringFunction<T>,SearchSuggestion,SuggestOptionsshape 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 — extendsBaseElasticAdapter; ES 8.x by default, ES 7.x compatibility-mode header opt-in. - ADDED:
OpenSearchAdapter<TDoc>class — extendsBaseElasticAdapter; optionalAwsSigningConfiginjects an(url, init) => Promise<RequestInit>hook so sigv4 signing is pluggable. - ADDED:
MeiliSearchAdapter<TDoc>class — implementsSearchEngine<TDoc>against MeiliSearch HTTP API; supportssearchableAttributes,filterableAttributes,sortableAttributes, faceted search viafacetDistribution, suggestions via_formattedextraction. - ADDED:
ElasticBaseConfig,ElasticAuth,ElasticsearchConfig,OpenSearchConfig,AwsSigningConfig,MeiliSearchConfig,SearchClientConfigshape types. - MODIFIED:
SearchEngine<TDoc>interface — adds optionalbulkIndex(documents)andensureSchema()lifecycle hooks; the new methods default-throw withSEARCH.NOT_IMPLEMENTEDso existing engines (e.g.MemorySearchEngine) compile unchanged. - MODIFIED:
SEARCH_CODESregistry — addsPROVIDER_UNREACHABLE,PROVIDER_TIMEOUT,PROVIDER_RATE_LIMITED,PROVIDER_CONFIG_INVALID,HOOK_HANDLER_FAILED,ANALYZER_INVALID,NOT_IMPLEMENTED,AWS_SIGNING_FAILED,BULK_INDEX_PARTIAL_FAILUREcodes; the existingINDEX_FAILED,QUERY_INVALID,ENGINE_NOT_READY,FIELD_UNKNOWN,CURSOR_INVALIDcodes are preserved. - MODIFIED:
index.tsbarrel — re-exports every new symbol; noexport *(one explicit subpath per module). - MODIFIED:
package.jsonexportsmap — 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.jsondependencies— 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/elasticsearchormeilisearch-jsSDKs — both adapters use directfetch()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/dmarcprecedent (HTTP-only, Zod-validated wire formats). - Typesense adapter — the genesis testing harness already ships
@suaveplan/testing-integration-typesensefor 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. registerHooklegacy free-function deprecation — kept and delegated to the newsearchHooks.hookAPI for one release; removing it is a breaking change that requires its own deprecation window.- Re-folding of
openspec/specs/search/spec.mdif 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/suggestround-trip against an in-memory engine;defaultLimitapplied;fieldBoostsandscoringFunctionre-rank results;removeManyand bulk index pathpackages/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;AbortSignalaborts mid-flight; injectedMemorySearchEngineproduces deterministic orderpackages/platform/search/src/query-builder/query-builder.test.ts—term/must/should/mustNot/range/prefix/fuzzy/geo/filter/facet/sort/paginatebuild correctly;boostandscoringFunctionround-trip;reset()clears statepackages/platform/search/src/pipeline/search-pipeline.test.ts—LowercaseAnalyzer/StemmingAnalyzer/StopWordAnalyzer/SynonymAnalyzereach tested in isolation;SearchPipelinewalks analyzers in order; custom analyzer registerspackages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts—AbortSignalmid-pipeline rejects withANALYZER_INVALIDwhen an analyzer throws; bidirectional synonyms expand correctly; stop-word fallback returns original tokens when filtering would empty the listpackages/platform/search/src/hooks/search-hooks.test.ts—'search:querying'/'search:indexing'/'search:result'lifecycle bus tested with multiple handlers; handler-priority order respected;unsubscriberemoves recordpackages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts— handler exception raisesHOOK_HANDLER_FAILEDwith originalcause; per-hook payload type pinned viaexpectTypeOfpackages/platform/search/src/adapters/base-elastic.test.ts—index/remove/search/clear/size/suggestround-trip against afetchmock;bulkIndexbuilds NDJSON;ensureIndex/deleteIndex/refresh; auth basic / apiKey / bearer;searchFieldshonoured; facets translate to aggregationspackages/platform/search/src/adapters/base-elastic.spec-conformance.test.ts— 429 rate-limit response raisesPROVIDER_RATE_LIMITEDwithretryAfterMs; 408 / 504 raisesPROVIDER_TIMEOUT; missing host raisesPROVIDER_CONFIG_INVALID; non-OK responses throwHttpError-wrappedSearchErrorpackages/platform/search/src/adapters/elasticsearch.test.ts— adapter sends ES 8 headers by default;compatibilityMode: trueswitches to ES 7 headerspackages/platform/search/src/adapters/opensearch.test.ts— adapter withoutawsSigningbehaves likeBaseElasticAdapter; withawsSigninginvokes the supplied signer once per request and uses the returnedRequestInit; signer rejection raisesAWS_SIGNING_FAILEDpackages/platform/search/src/adapters/meilisearch.test.ts—index/remove/search/clear/size/suggestround-trip; filters translate to MeiliSearch filter strings;ensureIndexpatchessearchableAttributes/filterableAttributes/sortableAttributes; faceted search returnsfacetDistributionpackages/platform/search/src/adapters/meilisearch.spec-conformance.test.ts— 429 raisesPROVIDER_RATE_LIMITED; 408 / 504 raisesPROVIDER_TIMEOUT; 400 raisesQUERY_INVALID; missing API key pathtests/dep-graph.test.ts(cross-package) —@suaveplan/searchdeclares@suaveplan/eventsas 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.