# Design — `@suaveplan/api-plugin-search` ## File layout ```text 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)` where `tf = matchCount / docTokens.length`. - **Filtering**: scalar equality (`actual === expected`) or array membership (`expected.includes(actual)`) when expected is an array. - **Sorting**: `relevance` (score desc), `date_*` and `alpha_*` (locale-compare on `createdAt` / `title`). - **Facets**: built only over `IndexField.facet === true` fields; counts run over the FILTERED document set (not raw). - **Highlights**: whole-word match wraps `word` in `…` 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` (the > `errorPayload` helper raises `INVALID_QUERY` when an unknown error reaches the service-level > boundary) and `plugin/plugin.ts` (an attempt to register the same plugin twice through > `runOnReady` raises `ROUTE_PREFIX_COLLISION`). See implementation for the call sites.