121 lines
6.3 KiB
Markdown
121 lines
6.3 KiB
Markdown
# 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 `<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` (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.
|