Initial Commit
This commit is contained in:
120
openspec/archive/2026-05-18-add-api-plugin-search/design.md
Normal file
120
openspec/archive/2026-05-18-add-api-plugin-search/design.md
Normal 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.
|
||||
154
openspec/archive/2026-05-18-add-api-plugin-search/proposal.md
Normal file
154
openspec/archive/2026-05-18-add-api-plugin-search/proposal.md
Normal 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)
|
||||
@@ -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"`
|
||||
79
openspec/archive/2026-05-18-add-api-plugin-search/tasks.md
Normal file
79
openspec/archive/2026-05-18-add-api-plugin-search/tasks.md
Normal 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/`
|
||||
Reference in New Issue
Block a user