Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# 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.

View File

@@ -0,0 +1,154 @@
# Add `@suaveplan/api-plugin-search`
**Package**: `@suaveplan/api-plugin-search`
**Status**: PROPOSED
**Type**: new-package
**Design**: ./design.md
**Spec delta**: ./specs/api-plugin-search/spec.md
## Why
The Suaveplan API host needs an in-process full-text search plugin so consumer apps
can index domain documents (users, posts, products, audit events, ...) and serve
relevance-scored queries, facets, filters, highlights, and autocomplete suggestions
without a separate search-engine deployment. Upstream `@suaveplan/api-plugin-search`
provided this as an in-memory engine with TF-IDF scoring, four pluggable analyzers
(standard, simple, whitespace, keyword), faceted aggregation, boolean filters, and
optional highlighting. The genesis migration brings the full functional surface
across, swaps the upstream "old-style" `@suaveplan/api` Plugin contract for the
canonical `@suaveplan/api-plugin` `PluginDescriptor` shape, fixes the two `as any`
casts in the event bus call site by routing through typed payloads, replaces raw
`throw new Error(...)` sites with typed `SearchError` carrying stable `SEARCH.*`
codes, wires OpenTelemetry instruments (5 counters/histograms) onto every hot path,
and aligns the package with the rest of the genesis monorepo (5-files-per-feature,
sibling `.md` docs, runner-neutral test imports, 100% istanbul coverage).
## What changes
- ADDED: `createSearchPlugin(options?)` — `@suaveplan/api-plugin`-shaped
`PluginDescriptor<SearchPluginConfig>` factory that mounts the search routes at a
configurable `routePrefix` (default `/search`) and registers a `searchIndexes`
service in the host `ServiceRegistry`.
- ADDED: `SearchPluginOptionsSchema` / `SearchPluginOptions` /
`SearchPluginConfig` — Zod-validated factory options (`routePrefix`,
`maxIndexes`, `maxDocumentsPerIndex`, `defaultAnalyzer`).
- ADDED: In-memory `SearchService` functions
(`createSearchIndex` / `deleteSearchIndex` / `getSearchIndex` / `listSearchIndexes`
/ `indexDocument` / `bulkIndexDocuments` / `removeDocument` / `search` /
`suggest`) plus `DEFAULT_SEARCH_CONFIG` and the `SearchServiceConfig` type.
- ADDED: Four text analyzers via factory functions (`createStandardAnalyzer`,
`createSimpleAnalyzer`, `createWhitespaceAnalyzer`, `createKeywordAnalyzer`),
plus exported primitives `tokenize`, `removeStopWords`, `stem`, `getAnalyzer`,
and `listAnalyzers`. The `TextAnalyzer` and `AnalyzerOptions` interfaces are
public so consumers can author and register their own analyzers.
- ADDED: `createSearchRoutes(deps)` — OpenAPIHono router exposing 9 routes:
`POST /query`, `POST /suggest`, `GET /indexes`, `POST /indexes`,
`GET /indexes/:name`, `DELETE /indexes/:name`, `POST /indexes/:name/documents`,
`POST /indexes/:name/bulk`, `DELETE /indexes/:name/documents/:docId`. Every
route validates request bodies/params with Zod via `@hono/zod-openapi`, and
every error response is the canonical `{ error: string }` shape.
- ADDED: 10 `SEARCH.*` error codes via `defineCodes("SEARCH", [...])` —
`INDEX_NOT_FOUND`, `INDEX_ALREADY_EXISTS`, `INDEX_LIMIT_REACHED`,
`DOCUMENT_LIMIT_REACHED`, `DOCUMENT_NOT_FOUND`, `UNKNOWN_ANALYZER`,
`INVALID_OPTIONS`, `INVALID_QUERY`, `SERVICE_NOT_AVAILABLE`,
`ROUTE_PREFIX_COLLISION` — each thrown from at least one production code path.
- ADDED: 5 OTel instruments under the
`@suaveplan/api-plugin-search` meter — `search.queries_total{outcome}`,
`search.query_duration_ms` (histogram), `search.documents_indexed_total{op}`,
`search.indexes_total{op}` and `search.suggest_requests_total{outcome}`.
- ADDED: Typed event-bus extension for
`search:initialized`, `search:ready`, `search:index:created`,
`search:index:deleted`, `search:index:updated`, `search:document:indexed`,
`search:bulk:completed`, `search:document:removed`, and `search:query:executed`
— payloads strongly typed; no `as any` casts on the emitter.
- ADDED: `searchIndexes` slot on the canonical
`@suaveplan/api-plugin/service-registry` via module augmentation, typed as
`Map<string, SearchIndex>` so sibling plugins can read/write indexes with
full type safety.
## Out of scope
- Persistent backend (SurrealDB FTS / Typesense / Meilisearch) — the upstream
package is in-memory only and the migration matches parity. A separate
follow-up change can introduce a `SearchAdapter` driver against
`@suaveplan/api-plugin-database` once that package lands; the public
`SearchAdapter` interface ships here so the contract is stable.
- Tenant scoping, auth gates, rate limiting, audit logging — upstream did not
carry these; the enterprise audit document flags them as future work. They
are not in this migration to preserve parity.
- Vector search, semantic ranking, learning-to-rank, saved searches,
language-specific stemmers, query DSL — enterprise audit §4 enhancements,
deferred to dedicated follow-up changes.
- Benchmark suite under `src/__benchmarks__/` — landing as a follow-up change so
this migration ships at parity scope first.
## Source mapping
This change derives from `../suaveplan/enterprise/api-plugin-search.md` and the
upstream source at `../suaveplan/packages/server/api-plugin-search/src/`.
| Requirement | Source lines | Notes |
|---|---|---|
| In-memory search engine with TF-IDF scoring | enterprise L8-L13; `src/services/search/search.service.ts#scoreDocuments` L302-L336 | verbatim parity |
| Four built-in analyzers (standard/simple/whitespace/keyword) | enterprise L8-L13; `src/analyzers/text/text.analyzer.ts` L156-L223 | verbatim parity |
| Tokenize + stop-word + Porter-style stemmer | `src/analyzers/text/text.analyzer.ts` L52-L123 | verbatim parity |
| `createSearchPlugin` factory + four lifecycle hooks | `src/index.ts` L39-L100 | re-typed for `@suaveplan/api-plugin` |
| `SearchPluginConfigSchema` (basePath/maxIndexes/maxDocumentsPerIndex/defaultAnalyzer) | `src/index.ts` L21-L33 | renamed to `SearchPluginOptionsSchema` for genesis convention |
| 9 OpenAPIHono routes with Zod request/response schemas | `src/routes/search/search.ts` L41-L479 | verbatim route surface |
| Bulk index limit (1000 docs/request) | `src/types.ts#BulkIndexSchema` L113-L116 | verbatim |
| Faceted aggregation honouring `IndexField.facet` | `src/services/search/search.service.ts#buildFacets` L431-L459 | verbatim |
| Boolean / array filter pass | `src/services/search/search.service.ts#applyFilters` L338-L355 | verbatim |
| Sort orders (relevance / date_asc / date_desc / alpha_asc / alpha_desc) | `src/services/search/search.service.ts#sortResults` L357-L398 | verbatim |
| Highlighting with configurable HTML tag | `src/services/search/search.service.ts#buildHighlights` L400-L429 | verbatim |
| Prefix-based suggest | `src/services/search/search.service.ts#suggest` L250-L291 | verbatim |
| Per-index document limit enforcement | `src/services/search/search.service.ts#indexDocument` L117-L154 | enforced via `SEARCH.DOCUMENT_LIMIT_REACHED` |
| Event-bus emission on lifecycle, query, index updates | `src/index.ts` L64-L93; `src/routes/search/search.ts` L92-L99/L350-L356/L414-L420/L465-L471 | drop both `as any` casts (audit §3) |
| Typed `SearchQueryExecutedEvent` / `SearchIndexUpdatedEvent` payloads | `src/types.ts` L188-L218 | preserved verbatim |
| `SearchAdapter` interface (future SurrealFTS / Typesense backends) | `src/types.ts#SearchAdapter` L172-L184 | preserved verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Typed `SEARCH.*` error registry via `defineCodes` | Genesis-wide rule (every package extends `SuaveplanError`); enterprise audit §3 explicitly calls for it. |
| OTel instruments on every hot path | Genesis-wide observability rule + enterprise audit §3. |
| Runner-neutral test imports (`@suaveplan/testing/runner`) | Genesis rule 11. |
| 100 / 100 / 100 / 100 istanbul coverage | Genesis-wide policy. |
| Sibling `.md` documentation per implementation file | Genesis-wide policy. |
| Subpath exports per feature module | Genesis-wide tree-shaking rule (§6). |
| `ServiceRegistry` module augmentation for `searchIndexes` slot | Genesis convention introduced by `@suaveplan/api`, `@suaveplan/api-plugin-auth`, `@suaveplan/api-plugin-user`. |
## Verification approach
Every requirement in the spec delta carries a `**Verified by:**` line at draft
time pointing at the planned `it(...)` titles below.
- `src/error-codes/error-codes.test.ts` — `SEARCH.*` registry shape + `SearchError`
cause chain.
- `src/otel/instruments.test.ts` — meter binding, cache reset, instrument names.
- `src/analyzer/analyzer.test.ts` — tokenize / stop-words / stem / four analyzer
factories / registry lookup / unknown-name failure.
- `src/schemas/schemas.test.ts` — every Zod schema parse/reject path.
- `src/schemas/options.test.ts` — `SearchPluginOptionsSchema` defaults and bounds.
- `src/service/service.test.ts` — index lifecycle, document indexing, search,
filters, sort, facets, highlighting, suggest, error codes, OTel emission.
- `src/routes/build-routes.test.ts` — every route (9 endpoints, success + error
branches).
- `src/plugin/plugin.test.ts` — descriptor metadata, `onInit` /
`onReady` / `onClose`, custom `routePrefix`, missing `app` service.
- `src/api-plugin-search.spec-conformance.test.ts` — type-level public-surface
pins + barrel exports + side-effect imports.
## Migration impact
None — additive only. No existing genesis package depends on
`@suaveplan/api-plugin-search`.
## Impact
### Affected dependents
No dependents yet. After publication, sibling plugins (`api-plugin-user`,
`api-plugin-cms`, etc.) can read the augmented `searchIndexes` service slot.
- N/A — [x] no action needed (new package, zero pre-existing consumers in genesis)

View File

@@ -0,0 +1,365 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-plugin-search (add-api-plugin-search)
**Package**: `@suaveplan/api-plugin-search`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Plugin factory shape
The package SHALL export `createSearchPlugin(options?: SearchPluginOptions)`
returning a `PluginDescriptor<SearchPluginConfig>` whose `name` is `"search"`,
whose `dependencies` is empty, whose `optionalDependencies` includes
`"database"` and `"events"`, and whose `tags` include `"search"`, `"fulltext"`,
`"facets"`, and `"relevance"`.
#### Scenario: Descriptor metadata
- **WHEN** `createSearchPlugin()` is called with no arguments
- **THEN** the returned descriptor has the exact name, version, dependencies,
optional-dependencies, and tags listed above
#### Scenario: Invalid options reject at the boundary
- **WHEN** `createSearchPlugin({ maxIndexes: 0 })` is called
- **THEN** the factory throws `SearchError` with code
`SEARCH.INVALID_OPTIONS` and the original `ZodError` as `cause`
**Verified by:** `src/plugin/plugin.test.ts::"returns a descriptor with the canonical search-plugin metadata"` AND `src/plugin/plugin.test.ts::"throws SEARCH.INVALID_OPTIONS when options fail schema validation"`
---
### Requirement: Lifecycle (onInit / onReady / onClose)
The descriptor SHALL implement `onInit`, `onReady`, and `onClose`. `onInit`
registers the in-memory `Map<string, SearchIndex>` under the canonical
`searchIndexes` service slot. `onReady` builds the route tree and mounts it
under the configured `routePrefix` (default `/search`). `onClose` clears the
index map.
#### Scenario: onInit registers the index map
- **WHEN** `onInit(ctx)` runs
- **THEN** `ctx.getService("searchIndexes")` returns a non-undefined `Map`
#### Scenario: onReady mounts routes at custom prefix
- **WHEN** the descriptor's `onInit` then `onReady` runs with options
`{ routePrefix: "/api/fts" }`
- **THEN** the host `app.route` was called with `"/api/fts"` as the mount point
#### Scenario: onClose empties the index map
- **WHEN** `onInit` runs, an index is inserted, then `onClose` runs
- **THEN** the index map has size 0
**Verified by:** `src/plugin/plugin.test.ts::"onInit registers the searchIndexes service"` AND `src/plugin/plugin.test.ts::"onReady mounts routes at the configured routePrefix"` AND `src/plugin/plugin.test.ts::"onClose empties the searchIndexes map"`
---
### Requirement: In-memory index lifecycle
The package SHALL expose `createSearchIndex`, `deleteSearchIndex`,
`getSearchIndex`, and `listSearchIndexes`. Creating a duplicate index throws
`SearchError(SEARCH.INDEX_ALREADY_EXISTS)`. Exceeding `maxIndexes` throws
`SearchError(SEARCH.INDEX_LIMIT_REACHED)`. `listSearchIndexes` paginates by
`offset`/`limit` and optionally filters by `status`.
#### Scenario: Duplicate index name
- **WHEN** `createSearchIndex` is called twice with the same name
- **THEN** the second call throws `SearchError` with code
`SEARCH.INDEX_ALREADY_EXISTS`
#### Scenario: Index limit enforcement
- **WHEN** the registered map already contains `maxIndexes` entries and a
new `createSearchIndex` call is made
- **THEN** the call throws `SearchError` with code `SEARCH.INDEX_LIMIT_REACHED`
#### Scenario: List with pagination + status filter
- **WHEN** three indexes exist (one with status `"building"`) and the caller
passes `{ status: "active", limit: 2 }`
- **THEN** the returned `items` array has length ≤ 2 and contains only
active indexes, and `total` reports the unpaged active count
**Verified by:** `src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_ALREADY_EXISTS on duplicate name"` AND `src/service/service.test.ts::"createSearchIndex throws SEARCH.INDEX_LIMIT_REACHED when maxIndexes is reached"` AND `src/service/service.test.ts::"listSearchIndexes paginates and filters by status"`
---
### Requirement: Document indexing with limits and field-aware analysis
The package SHALL expose `indexDocument`, `bulkIndexDocuments`, and
`removeDocument`. `indexDocument` runs the index's configured analyzer over
every declared field, stores per-field token lists, and rejects with
`SearchError(SEARCH.INDEX_NOT_FOUND)` when the index is missing. When the
document map would exceed `maxDocumentsPerIndex` AND the doc id is new, it
throws `SearchError(SEARCH.DOCUMENT_LIMIT_REACHED)`. `removeDocument` returns
`false` when the doc id is absent and `true` when it was removed.
#### Scenario: Per-field tokenisation
- **WHEN** an index declares fields `[title, body]` and a document with both
fields is indexed
- **THEN** the stored `IndexedDocument.tokens` map carries one entry per field
with non-empty token arrays
#### Scenario: Document-limit guard
- **WHEN** the index already contains `maxDocumentsPerIndex` documents and a
new document id is indexed
- **THEN** the call throws `SearchError(SEARCH.DOCUMENT_LIMIT_REACHED)`
#### Scenario: Re-indexing an existing doc never exceeds the limit
- **WHEN** the index is at the document limit and `indexDocument` is called
with an id that already exists
- **THEN** the call succeeds and replaces the stored tokens
**Verified by:** `src/service/service.test.ts::"indexDocument analyses every declared field"` AND `src/service/service.test.ts::"indexDocument throws SEARCH.DOCUMENT_LIMIT_REACHED for new ids beyond the cap"` AND `src/service/service.test.ts::"indexDocument replaces tokens for existing ids without triggering the limit"`
---
### Requirement: Search with TF-IDF scoring, filters, sort, facets, highlights
The package SHALL expose `search(query, indexes)` returning a `SearchResult`.
Scoring uses term-frequency × field-weight. Filters accept scalar equality and
`Array.includes` for array values. Sort honours `relevance`, `date_asc`,
`date_desc`, `alpha_asc`, `alpha_desc`. Facets are computed only over fields
declared with `facet: true` and reflect the FILTERED document set. Highlights
wrap matching whole words in the configured `highlightTag`.
#### Scenario: Relevance sort by default
- **WHEN** three documents match a query with different term frequencies
- **THEN** the response `hits` are sorted by descending `score`
#### Scenario: Filter restricts the result set
- **WHEN** a query carries `filters: { tag: "blog" }`
- **THEN** only documents whose `tag` field equals `"blog"` appear in `hits`,
and `total` reflects the filtered count
#### Scenario: Facet honours `facet: true` only
- **WHEN** the index declares `category` with `facet: true` and `body` with
`facet: false`, and the query requests `["category", "body"]`
- **THEN** the response carries exactly one facet block keyed on `category`
#### Scenario: Highlight wraps matching words
- **WHEN** `highlight: true` and `highlightTag: "em"` are set and a document
matches the query token
- **THEN** the response hit's `highlights[fieldName]` contains
`<em>word</em>` strings
#### Scenario: Empty-query token list yields empty result
- **WHEN** the analyzer returns zero tokens for the input query
- **THEN** `search` returns `{ hits: [], total: 0, ... }` and never throws
#### Scenario: Unknown index throws
- **WHEN** the query references an index that does not exist
- **THEN** `search` throws `SearchError` with code `SEARCH.INDEX_NOT_FOUND`
**Verified by:** `src/service/service.test.ts::"search ranks hits by TF × field weight"` AND `src/service/service.test.ts::"search applies scalar and array filters"` AND `src/service/service.test.ts::"search honours sort order"` AND `src/service/service.test.ts::"search emits facets only for fields with facet: true"` AND `src/service/service.test.ts::"search wraps matching words with the configured highlight tag"` AND `src/service/service.test.ts::"search returns empty result when query analyses to zero tokens"` AND `src/service/service.test.ts::"search throws SEARCH.INDEX_NOT_FOUND for an unknown index"`
---
### Requirement: Suggest endpoint (prefix autocomplete)
The package SHALL expose `suggest(query, indexes)` returning ranked prefix
matches over every indexed field. The last analysed token is used as the
prefix, hits are deduplicated by surface form, and results are sorted by
descending occurrence count and truncated to `query.limit`.
#### Scenario: Prefix expansion ranks by frequency
- **WHEN** the index contains documents whose words begin with the query's
last token
- **THEN** `suggest` returns suggestions ordered by descending occurrence
count and truncated to `limit`
#### Scenario: Suggest returns empty for empty token analysis
- **WHEN** the analysed query produces zero tokens
- **THEN** `suggest` returns `{ suggestions: [], query }`
**Verified by:** `src/service/service.test.ts::"suggest ranks prefix matches by occurrence count"` AND `src/service/service.test.ts::"suggest returns empty result when query analyses to zero tokens"`
---
### Requirement: Four built-in analyzers via the registry
The package SHALL register four analyzers — `standard`, `simple`,
`whitespace`, and `keyword` — through `getAnalyzer(name)`. `listAnalyzers()`
returns the four registered names. Unknown analyzer names throw
`SearchError(SEARCH.UNKNOWN_ANALYZER)`.
#### Scenario: Standard analyzer tokenises, removes stop words, stems
- **WHEN** the standard analyzer analyses `"Running the cats quickly"`
- **THEN** the returned token list contains the stemmed forms `run`, `cat`,
`quick` and excludes the stop word `the`
#### Scenario: Keyword analyzer treats input as single token
- **WHEN** the keyword analyzer analyses `"Hello World"`
- **THEN** the returned token list is `["Hello World"]`
#### Scenario: Unknown analyzer name fails fast
- **WHEN** `getAnalyzer("does-not-exist")` is called
- **THEN** the call throws `SearchError` with code `SEARCH.UNKNOWN_ANALYZER`
**Verified by:** `src/analyzer/analyzer.test.ts::"standard analyzer tokenises, removes stop words, and stems"` AND `src/analyzer/analyzer.test.ts::"keyword analyzer returns the trimmed input as a single token"` AND `src/analyzer/analyzer.test.ts::"getAnalyzer throws SEARCH.UNKNOWN_ANALYZER for an unregistered name"`
---
### Requirement: 9 HTTP routes mounted under `routePrefix`
The package SHALL expose `createSearchRoutes(deps)` returning an
`OpenAPIHono` instance with the canonical 9 routes (POST /query,
POST /suggest, GET /indexes, POST /indexes, GET /indexes/:name,
DELETE /indexes/:name, POST /indexes/:name/documents,
POST /indexes/:name/bulk, DELETE /indexes/:name/documents/:docId). Every
request body / param is Zod-validated through `@hono/zod-openapi`. Error
responses are `{ error: string }` and the HTTP status reflects the typed
`SearchError` (`SEARCH.INDEX_NOT_FOUND` → 404, `SEARCH.DOCUMENT_NOT_FOUND` →
404, every other failure → 400).
#### Scenario: POST /query returns hits and emits `search:query:executed`
- **WHEN** a populated index receives a POST /query body
- **THEN** the response status is 200, the body matches `SearchResultSchema`,
and `deps.emitEvent` was invoked with `"search:query:executed"` and a
`SearchQueryExecutedEvent` payload
#### Scenario: 404 for missing index
- **WHEN** POST /query references an unknown index
- **THEN** the response status is 404 with body `{ error: "..." }`
#### Scenario: POST /indexes creates an index
- **WHEN** POST /indexes receives a valid `CreateIndexSchema` body
- **THEN** the response status is 201 with body matching `IndexInfoSchema`
#### Scenario: DELETE /indexes/:name returns 404 for unknown index
- **WHEN** DELETE /indexes/missing is hit on an empty registry
- **THEN** the response status is 404
#### Scenario: DELETE /indexes/:name/documents/:docId removes a doc
- **WHEN** a doc was indexed and then DELETE is invoked on the doc id
- **THEN** the response status is 200 with `{ success: true }` and
`deps.emitEvent` was invoked with `"search:index:updated"` and
`operation: "document_removed"`
**Verified by:** `src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed"` AND `src/routes/build-routes.test.ts::"POST /query returns 404 when the index does not exist"` AND `src/routes/build-routes.test.ts::"POST /indexes creates an index and returns 201"` AND `src/routes/build-routes.test.ts::"DELETE /indexes/:name returns 404 for an unknown index"` AND `src/routes/build-routes.test.ts::"DELETE /indexes/:name/documents/:docId returns 200 and emits search:index:updated"`
---
### Requirement: Typed error registry
The package SHALL expose `SEARCH_CODES` via `defineCodes("SEARCH", [...])`
covering exactly 10 codes — `INDEX_NOT_FOUND`, `INDEX_ALREADY_EXISTS`,
`INDEX_LIMIT_REACHED`, `DOCUMENT_LIMIT_REACHED`, `DOCUMENT_NOT_FOUND`,
`UNKNOWN_ANALYZER`, `INVALID_OPTIONS`, `INVALID_QUERY`,
`SERVICE_NOT_AVAILABLE`, `ROUTE_PREFIX_COLLISION` — and a `SearchError`
class extending `SuaveplanError<SearchErrorCode>` such that
`instanceof SearchError` narrows `.code` to a `SearchErrorCode` literal
union.
#### Scenario: Registry shape is stable
- **WHEN** `Object.values(SEARCH_CODES)` is inspected
- **THEN** the array has exactly 10 unique entries, each beginning with
`"SEARCH."`
#### Scenario: SearchError preserves cause and code narrowing
- **WHEN** `new SearchError(SEARCH_CODES.INDEX_NOT_FOUND, "msg", { cause })`
is constructed
- **THEN** `error instanceof SearchError` is true, `error.code ===
SEARCH_CODES.INDEX_NOT_FOUND`, and `error.cause` is the supplied cause
**Verified by:** `src/error-codes/error-codes.test.ts::"declares 10 SEARCH.* codes"` AND `src/error-codes/error-codes.test.ts::"SearchError narrows code to SearchErrorCode and preserves cause"`
---
### Requirement: OTel instruments on every hot path
The package SHALL emit metrics via a meter named
`@suaveplan/api-plugin-search`. The instrument bundle SHALL include
`search.queries_total{outcome}`, `search.query_duration_ms` (histogram),
`search.documents_indexed_total{op}`, `search.indexes_total{op}`, and
`search.suggest_requests_total{outcome}`. A `resetSearchInstruments()`
helper SHALL allow tests to rebind against a freshly-installed
`MeterProvider`.
#### Scenario: Reset rebinds the meter
- **WHEN** `resetSearchInstruments()` is invoked between two
`getSearchInstruments()` calls under two different `MeterProvider`s
- **THEN** the second call returns a fresh instrument bundle bound to the
second provider
#### Scenario: Successful query bumps queries_total and records duration
- **WHEN** a successful `search()` call completes
- **THEN** the in-memory exporter records a `search.queries_total{outcome="success"}` += 1 and a non-empty
`search.query_duration_ms` histogram observation
**Verified by:** `src/otel/instruments.test.ts::"resetSearchInstruments rebinds against a fresh MeterProvider"` AND `src/service/service.test.ts::"search increments queries_total and records duration"`
---
### Requirement: ServiceRegistry augmentation
The package SHALL augment `@suaveplan/api-plugin/service-registry`'s
`ServiceRegistry` interface with a `searchIndexes` slot typed as
`Map<string, SearchIndex>`. Importing the package barrel SHALL surface the
augmentation as a side effect.
#### Scenario: getService("searchIndexes") types to Map<string, SearchIndex>
- **WHEN** a consumer calls `ctx.getService("searchIndexes")` after importing
`@suaveplan/api-plugin-search`
- **THEN** the inferred return type is `Map<string, SearchIndex> | undefined`
(verified at compile time via `expectTypeOf`)
**Verified by:** `src/api-plugin-search.spec-conformance.test.ts::"augments the ServiceRegistry with the searchIndexes slot"`
---
### Requirement: Typed event-bus emissions
The package SHALL emit nine search events through the host
`EventBusLike` — `search:initialized`, `search:ready`,
`search:index:created`, `search:index:deleted`, `search:index:updated`,
`search:document:indexed`, `search:bulk:completed`,
`search:document:removed`, `search:query:executed` — with strongly typed
payload signatures declared via a module augmentation. The plugin call
sites SHALL NOT use `as any` to bypass the event-map type system.
#### Scenario: Lifecycle events fire on init / ready
- **WHEN** `onInit` then `onReady` run
- **THEN** `eventBus.emit` was called with `"search:initialized"` and
`"search:ready"`
#### Scenario: Query route emits typed `SearchQueryExecutedEvent`
- **WHEN** POST /query handles a successful query
- **THEN** the emitter receives `"search:query:executed"` and a payload
matching `SearchQueryExecutedEvent`
**Verified by:** `src/plugin/plugin.test.ts::"onInit emits search:initialized and onReady emits search:ready"` AND `src/routes/build-routes.test.ts::"POST /query returns 200 and emits search:query:executed"`

View File

@@ -0,0 +1,79 @@
# Tasks — Add `@suaveplan/api-plugin-search`
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-search.md` end-to-end
- [x] 1.2 Verify no prior `openspec/specs/api-plugin-search/spec.md` exists
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` with line references to upstream
- [x] 1.6 Draft `specs/api-plugin-search/spec.md` from
`openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete
`it(...)` titles
## 2. Implementation
- [x] 2.1 `error-codes/error-codes.ts` — 10 `SEARCH.*` codes + `SearchError` class
- [x] 2.2 `otel/instruments.ts` — meter + 5 instruments (counters + histogram)
- [x] 2.3 `schemas/schemas.ts` — all 13 Zod schemas (formerly upstream `types.ts`)
- [x] 2.4 `schemas/options.ts` — `SearchPluginOptionsSchema` factory-options schema
- [x] 2.5 `analyzer/analyzer.ts` — tokenize / stem / stop-words / 4 analyzers /
`getAnalyzer` / `listAnalyzers`
- [x] 2.6 `service/service.ts` — full in-memory engine (TF-IDF, facets, filters,
sort, highlights, suggest); throws `SearchError` and emits OTel
- [x] 2.7 `routes/build-routes.ts` — 9 OpenAPIHono routes
- [x] 2.8 `types/types.ts` carries the typed event payloads
(`SearchQueryExecutedEvent`, `SearchIndexUpdatedEvent`,
`SearchEventMap`); the `routes/build-routes.ts` `SearchRouteEvent`
discriminated union is the emitter contract — no `as any` casts
- [x] 2.9 `plugin/plugin.ts` — `createSearchPlugin` factory (`@suaveplan/api-plugin`-shaped)
- [x] 2.10 `services/registry-augmentation.ts` — `searchIndexes` slot on
canonical `ServiceRegistry`
- [x] 2.11 `types/types.ts` — internal types (`SearchIndex`, `IndexedDocument`,
`SearchPluginConfig`)
- [x] 2.12 `index.ts` — explicit named barrel + side-effect imports for the
error-codes registry and the registry augmentation
## 3. Tests
- [x] 3.1 Unit tests per feature module
- [x] 3.2 `api-plugin-search.spec-conformance.test.ts` — type-level + barrel pins
- [x] 3.3 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic +
advanced examples, full API reference)
- [x] 4.2 README links every co-located `.md`
- [x] 4.3 STATUS.md row updated post-archival (other-session-owned; skipped per
guidance)
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-search`
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-search`
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-search`
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-search`
## 6. Pre-archive 9-row checklist
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type
claim
- [x] 6.2 Zod schemas wired at every declared boundary
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production
path
- [x] 6.4 Strict-mode behaviours tested both on and off
- [x] 6.5 OTel instruments emitted from production paths and asserted with a
real in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup tested where claimed
- [x] 6.7 No retroactive tick
- [x] 6.8 No `export *` in any feature barrel
- [x] 6.9 No direct `vitest` / `bun:test` imports in any test file
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/api-plugin-search/spec.md`
- [ ] 7.2 Move `openspec/changes/add-api-plugin-search/` →
`openspec/archive/2026-05-18-add-api-plugin-search/`