Files
2026-05-27 23:29:53 +01:00

6.3 KiB
Raw Permalink Blame History

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) 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.