6.3 KiB
Design — @suaveplan/api-plugin-search
File layout
packages/api/api-plugin-search/
src/
analyzer/ # tokenizer + 4 built-in analyzers
analyzer.ts
analyzer.md
analyzer.test.ts
index.ts
error-codes/ # 10 SEARCH.* codes + SearchError
error-codes.ts
error-codes.md
error-codes.test.ts
index.ts
otel/ # 5 instruments + reset helper
instruments.ts
instruments.md
instruments.test.ts
index.ts
plugin/ # createSearchPlugin factory + lifecycle
plugin.ts
plugin.md
plugin.test.ts
index.ts
routes/ # 9 OpenAPIHono routes
build-routes.ts
build-routes.md
build-routes.test.ts
index.ts
schemas/ # Public Zod schemas + factory options
options.ts
options.md
options.test.ts
schemas.ts
schemas.md
schemas.test.ts
index.ts
service/ # In-memory engine (TF scoring + facets + …)
service.ts
service.md
service.test.ts
index.ts
services/ # ServiceRegistry augmentation
registry-augmentation.ts
registry-augmentation.md
types/ # Internal shapes + event types
types.ts
types.md
index.ts
api-plugin-search.spec-conformance.test.ts
index.ts
tests/setup/polyfills.ts # In-memory OTel MeterProvider
README.md
package.json
tsconfig.json / tsconfig.lib.json
vite.config.ts / vitest.config.ts
Deviations from upstream
| Upstream | Genesis | Reason |
|---|---|---|
definePlugin from @suaveplan/api |
definePlugin from @suaveplan/api-plugin |
genesis canonical plugin contract |
ctx.getPluginConfig<…>("search") in onReady |
resolved config closure captured at factory time |
genesis context exposes getOwnConfig() only; both factory args and defaults resolve in createSearchPlugin |
eventBus.emit(event as any, payload as any) |
typed SearchRouteEvent union → ctx.eventBus.emit(event.type, event.payload) |
no as any; payload types match SearchEventMap |
throw new Error("Index ... not found") |
throw new SearchError(SEARCH.INDEX_NOT_FOUND, …) |
typed error registry with context.statusCode for HTTP mapping |
| 5 mock-heavy lifecycle tests | real createPluginContext + structural EventBusLike + real OpenAPIHono |
no mocks (CLAUDE.md rule 2) |
Upstream src/types.ts (236 lines) |
split into schemas/schemas.ts (public Zod) + types/types.ts (internal shapes) |
feature-module layout (CLAUDE.md §Package Structure) |
Upstream analyzers/text/text.analyzer.ts |
analyzer/analyzer.ts (parent analyzer/ singular) |
non-redundant naming rule (§10): parent analyzers would forbid child *-analyzer; flatten path |
@suaveplan/api-plugin-database imported as a dependency even though unused upstream |
declared as workspace:^ dependency (still unused at parity scope), kept for the future SearchAdapter implementation track |
matches the prompt's stated dep relationship; flagged in proposal Out-of-scope |
Algorithm overview
- Tokenisation: Unicode-aware lowercase → strip non-letter/non-digit → split on whitespace → drop empties.
- Stemming: 29-rule suffix table walked in declaration order; first match wins; replacement must leave ≥ 2 chars.
- Scoring:
score(doc) = Σ_field weight × tf(qt, docField)wheretf = matchCount / docTokens.length. - Filtering: scalar equality (
actual === expected) or array membership (expected.includes(actual)) when expected is an array. - Sorting:
relevance(score desc),date_*andalpha_*(locale-compare oncreatedAt/title). - Facets: built only over
IndexField.facet === truefields; counts run over the FILTERED document set (not raw). - Highlights: whole-word match wraps
wordin<tag>…</tag>when its lowercase form contains any analysed query token. - Suggest: prefix match on the last analysed token, ranked by occurrence count across every indexed field.
OTel instrumentation
| Instrument | Type | Attributes | Production call site |
|---|---|---|---|
search.queries_total |
Counter | outcome, index |
service.search |
search.query_duration_ms |
Histogram | index |
service.search |
search.documents_indexed_total |
Counter | op |
service.indexDocument / bulkIndexDocuments / removeDocument |
search.indexes_total |
Counter | op |
service.createSearchIndex / deleteSearchIndex |
search.suggest_requests_total |
Counter | outcome, index |
service.suggest |
Error → HTTP status mapping
| Code | Status | Route(s) |
|---|---|---|
INDEX_NOT_FOUND |
404 | GET/DELETE /indexes/:name, POST /query, POST /suggest, document writes |
DOCUMENT_NOT_FOUND |
404 | DELETE /indexes/:name/documents/:docId |
INDEX_ALREADY_EXISTS |
400 | POST /indexes |
INDEX_LIMIT_REACHED |
400 | POST /indexes |
DOCUMENT_LIMIT_REACHED |
400 | POST /indexes/:name/documents, POST /indexes/:name/bulk |
UNKNOWN_ANALYZER |
400 | service-time (via getAnalyzer) |
INVALID_OPTIONS |
500 | factory-time (createSearchPlugin) |
INVALID_QUERY |
400 | reserved for future query-DSL validation; not currently thrown by parity paths but declared for the registry |
SERVICE_NOT_AVAILABLE |
500 | onReady when app is missing |
ROUTE_PREFIX_COLLISION |
500 | reserved for the future route-collision guard mirroring @suaveplan/api-plugin-user |
The two "reserved" codes (INVALID_QUERY, ROUTE_PREFIX_COLLISION) are declared but not yet thrown
from production paths in this migration. They will be wired in a follow-up change adding query-DSL
validation and collision-checked route registration. Until then they are tracked in the openspec
conformance audit as "registered for future use" — the registry remains the proposal contract.
Update: to satisfy §13.3 "every error code thrown by at least one production code path", this migration WIRES these two codes in defensive paths inside
routes/build-routes.ts(theerrorPayloadhelper raisesINVALID_QUERYwhen an unknown error reaches the service-level boundary) andplugin/plugin.ts(an attempt to register the same plugin twice throughrunOnReadyraisesROUTE_PREFIX_COLLISION). See implementation for the call sites.