Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
# @suaveplan/api-plugin-search - Full-text search plugin for @suaveplan/api with TF-weighted relevance scoring, four built-in text analyzers (standard / simple / whitespace / keyword), facets, scalar+array filters, multi-key sort, highlighting, prefix autocomplete, 9 OpenAPI-typed Hono routes, 10 SEARCH.* error codes, and 5 OTel instruments. Server-tier — runs in Bun and Node.
Full-text search plugin for `@suaveplan/api`. Brings TF-weighted relevance
scoring, four built-in text analyzers (`standard`, `simple`, `whitespace`,
`keyword`), faceted aggregation, scalar/array filters, multi-key sort,
optional highlighting, prefix autocomplete suggestions, and a typed
`SearchAdapter` interface for future persistent backends. Server-tier — runs
in Bun and Node.
<!-- begin:auto-docs -->
## Documentation tree
- [analyzer](./src/analyzer/analyzer.md) — The analyzer module owns the text-analysis pipeline used by both the indexing path and the query path of `@suaveplan/ap…
- [analyzer](./src/analyzer/analyzer.md) — The analyzer module owns the text-analysis pipeline used by both the indexing path and the query path of `@suaveplan/ap…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-searc…
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-searc…
- [otel](./src/otel/instruments.md) — The OTel instruments module exposes the canonical set of meters the search plugin records on every hot path. The bundle…
- [instruments](./src/otel/instruments.md) — The OTel instruments module exposes the canonical set of meters the search plugin records on every hot path. The bundle…
- [plugin](./src/plugin/plugin.md) — The plugin module exposes `createSearchPlugin`, the `@suaveplan/api-plugin`-shaped factory that the host `PluginRegistr…
- [plugin](./src/plugin/plugin.md) — The plugin module exposes `createSearchPlugin`, the `@suaveplan/api-plugin`-shaped factory that the host `PluginRegistr…
- [routes](./src/routes/build-routes.md) — The build-routes module constructs the `OpenAPIHono` instance that the plugin mounts under `routePrefix` (`/search` by…
- [build-routes](./src/routes/build-routes.md) — The build-routes module constructs the `OpenAPIHono` instance that the plugin mounts under `routePrefix` (`/search` by…
- [schemas](./src/schemas/schemas.md) — The schemas module owns the public Zod surface of `@suaveplan/api-plugin-search`. Every HTTP route in the package valid…
- [options](./src/schemas/options.md) — The options module defines the Zod schema for the input accepted by `createSearchPlugin(options?)`. The factory validat…
- [schemas](./src/schemas/schemas.md) — The schemas module owns the public Zod surface of `@suaveplan/api-plugin-search`. Every HTTP route in the package valid…
- [service](./src/service/service.md) — The service module is the in-memory search engine that powers `@suaveplan/api-plugin-search`. It owns index creation an…
- [service](./src/service/service.md) — The service module is the in-memory search engine that powers `@suaveplan/api-plugin-search`. It owns index creation an…
- [services](./src/services/registry-augmentation.md) — This module is a TypeScript `declare module` augmentation. It adds the `searchIndexes` slot to `@suaveplan/api-plugin`'…
- [registry-augmentation](./src/services/registry-augmentation.md) — This module is a TypeScript `declare module` augmentation. It adds the `searchIndexes` slot to `@suaveplan/api-plugin`'…
- [types](./src/types/types.md) — The types module owns the internal-shape contracts of the search plugin — the materialised `SearchIndex` held in memory…
- [types](./src/types/types.md) — The types module owns the internal-shape contracts of the search plugin — the materialised `SearchIndex` held in memory…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-search
```
## Quick start
```ts
import { createApi } from "@suaveplan/api";
import { createSearchPlugin } from "@suaveplan/api-plugin-search";
const api = createApi({
plugins: [
createSearchPlugin({
routePrefix: "/search",
maxIndexes: 100,
maxDocumentsPerIndex: 100_000,
defaultAnalyzer: "standard",
}),
],
});
await api.start();
// POST /search/indexes → create index
// POST /search/indexes/:name/documents → index a document
// POST /search/query → run a search
// POST /search/suggest → prefix autocomplete
```
## Features
- **9 HTTP routes** with Zod-typed `@hono/zod-openapi` request/response
validation: `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`.
- **TF × field-weight scoring** with deterministic ordering, plus
five-way sort (`relevance`, `date_asc`, `date_desc`, `alpha_asc`,
`alpha_desc`).
- **Four built-in analyzers** — `standard` (tokenise → stop-words →
stem), `simple` (tokenise only), `whitespace` (case-preserving), and
`keyword` (single-token) — plus public primitives (`tokenize`,
`removeStopWords`, `stem`) for custom analyzers.
- **Faceted aggregation** restricted to fields declared with
`facet: true`; counts reflect the filtered document set.
- **Scalar + array filters** through a single Zod schema so callers can
send `{ tag: "blog" }` or `{ tag: ["blog", "release"] }` with the
same field.
- **Optional highlighting** wrapping matching whole words in the
configured tag (`<mark>` by default).
- **Prefix autocomplete** via `suggest()` — ranks by occurrence count
across every indexed field.
- **10 `SEARCH.*` error codes** through `defineCodes` so callers can
branch on `error.code` with full type narrowing.
- **5 OTel instruments** under the `@suaveplan/api-plugin-search`
meter — counters for queries / indexes / docs / suggests and a
histogram for query duration.
- **`ServiceRegistry` augmentation** — `ctx.getService("searchIndexes")`
returns `Map<string, SearchIndex> | undefined` without a cast.
## Modules
- [`analyzer/analyzer`](./src/analyzer/analyzer.md)
- [`error-codes/error-codes`](./src/error-codes/error-codes.md)
- [`otel/instruments`](./src/otel/instruments.md)
- [`plugin/plugin`](./src/plugin/plugin.md)
- [`routes/build-routes`](./src/routes/build-routes.md)
- [`schemas/options`](./src/schemas/options.md)
- [`schemas/schemas`](./src/schemas/schemas.md)
- [`service/service`](./src/service/service.md)
- [`services/registry-augmentation`](./src/services/registry-augmentation.md)
- [`types/types`](./src/types/types.md)
## Status
`0.1.0` — first migration wave shipping` with full upstream
parity. Persistent backends (`SearchAdapter` implementations against
SurrealDB FTS / Typesense / Meilisearch), tenant scoping, auth gates,
rate limiting, and the enterprise-audit §4 enhancements (vector search,
synonyms, query DSL, saved searches, learning-to-rank) are tracked as
follow-up changes.

View File

@@ -0,0 +1,118 @@
# analyzer/analyzer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The analyzer module owns the text-analysis pipeline used by both the
indexing path and the query path of `@suaveplan/api-plugin-search`. It
ships four built-in analyzers — `standard`, `simple`, `whitespace`,
`keyword` — covering the upstream `1.0.4` parity surface, plus the
primitive building blocks (`tokenize`, `removeStopWords`, `stem`) so
consumers can compose custom analyzers from the same parts. Every
indexing and query operation runs through this module, which means the
analyzer chosen at index-creation time determines the recall /
precision tradeoff for every subsequent query against that index.
## Features
- Four built-in analyzers with distinct trade-offs: `standard`
(tokenise → stop-words → stem, recall-tuned), `simple` (tokenise
only), `whitespace` (preserves case + punctuation, useful for code
search), `keyword` (single-token, useful for exact-match faceting).
- English baseline of 33 stop words wired into the standard analyzer
by default; callers can pass `AnalyzerOptions.stopWords` to override.
- Simplified Porter-style suffix stripping (29 rules) — fast,
deterministic, no external dependency.
- Registry lookup via `getAnalyzer(name)` with a typed
`SearchError(SEARCH.UNKNOWN_ANALYZER)` failure when the name is
unregistered.
- Public `TextAnalyzer` interface + `AnalyzerOptions` so downstream
packages can register custom analyzers using the same shape.
## Usage: Basic
```ts
import { getAnalyzer } from "@suaveplan/api-plugin-search";
const a = getAnalyzer("standard");
const tokens = a.analyze("The cats are running quickly");
// → ["cat", "run", "quick"]
```
## Usage: Advanced
Compose a custom analyzer using the primitive functions:
```ts
import {
removeStopWords,
stem,
tokenize,
type TextAnalyzer,
} from "@suaveplan/api-plugin-search";
const FRENCH_STOPS = new Set(["le", "la", "les", "de", "du", "et", "en"]);
const frenchAnalyzer: TextAnalyzer = {
name: "french",
analyze(text) {
return removeStopWords(tokenize(text), FRENCH_STOPS).map(stem);
},
};
```
## API Reference
### `tokenize(text: string): string[]`
Lowercases, strips non-letter/non-digit characters (Unicode), splits on
whitespace, drops empties.
### `removeStopWords(tokens: string[], stopWords?: Set<string>): string[]`
Filters out tokens in the supplied stop-word set (defaults to the
English baseline).
### `stem(word: string): string`
Returns the suffix-stripped stem. Words ≤ 3 chars are returned
unchanged.
### `createStandardAnalyzer(options?: AnalyzerOptions): TextAnalyzer`
Returns an analyzer chaining `tokenize → length-filter → stop-words →
stem`. Every step is opt-out via `AnalyzerOptions`.
### `createSimpleAnalyzer(): TextAnalyzer`
`tokenize`-only analyzer (no stop words, no stemming).
### `createWhitespaceAnalyzer(): TextAnalyzer`
Splits on whitespace only, preserving case and punctuation.
### `createKeywordAnalyzer(): TextAnalyzer`
Treats the trimmed input as a single token.
### `getAnalyzer(name: string): TextAnalyzer`
Resolves an analyzer from the built-in registry. Throws
`SearchError(SEARCH.UNKNOWN_ANALYZER)` for unregistered names.
### `listAnalyzers(): string[]`
Returns the four registered names in insertion order.
## Implementation Notes
The 29 suffix rules are walked in declaration order — first match
wins — and a replacement is only applied if the resulting stem is at
least 2 characters. The tokenizer uses Unicode property escapes
(`\p{L}`, `\p{N}`) so non-ASCII alphabets tokenise correctly. The
built-in registry is sealed (no public mutation) — custom analyzers
plug in by supplying a `TextAnalyzer` directly at the call site rather
than registering globally.

View File

@@ -0,0 +1,96 @@
# error-codes/error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The error-codes module defines the stable registry of programmatic
failure codes raised by `@suaveplan/api-plugin-search`. Every failure
path inside the search engine, the analyzer registry, the route
handlers, and the plugin lifecycle throws a `SearchError` extending
`SuaveplanError`, so callers can `instanceof SearchError` to narrow
`error.code` to the 10 `SEARCH.*` codes without touching free-form
strings. The registry is the public contract: adding, removing, or
renaming a code is a breaking change for consumers that switch on the
value.
## Features
- Ten stable codes covering every plugin failure: index lookup misses,
index-name collisions, hard cap on indexes / documents-per-index,
document removal misses, unknown analyzer names, factory-option
validation, query-time validation failures, missing host services,
and route-prefix collisions.
- `defineCodes("SEARCH", [...])` registers every code globally so the
unified error registry can resolve any `SEARCH.*` string back to a
human-friendly description.
- `SearchError` carries `searchCode` alongside the canonical `code`
field so downstream code that does not import the base class can
still branch on the discriminant.
- Each thrown instance carries `context.statusCode` so the host's
error-handler middleware maps the failure to the right HTTP status
without per-route boilerplate.
## Usage: Basic
```ts
import {
SEARCH_CODES,
SearchError,
} from "@suaveplan/api-plugin-search";
if (!indexes.has(name)) {
throw new SearchError(
SEARCH_CODES.INDEX_NOT_FOUND,
`Index "${name}" not found`,
{ context: { statusCode: 404, indexName: name } }
);
}
```
## Usage: Advanced
Compose the registry with the `@suaveplan/error` `cause` chain so the
original Zod failure survives translation to HTTP without losing its
stack:
```ts
const parsed = SearchPluginOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new SearchError(
SEARCH_CODES.INVALID_OPTIONS,
"createSearchPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
```
## API Reference
### `SEARCH_CODES`
`Readonly<Record<"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", string>>`.
Each value is the fully-qualified `"SEARCH.<NAME>"` string.
### `SearchError`
`class SearchError extends SuaveplanError<SearchErrorCode>` — public
constructor `(code, message, options?)` so callers can construct the
error directly. `searchCode` mirrors `code` for ergonomic narrowing.
### `SearchErrorCode`
Union of all 10 literal `"SEARCH.*"` code values.
## Implementation Notes
The codes are declared with a `readonly` tuple so the type system can
prove the array length and value union at compile time. Every code is
wired into at least one production code path in
`src/service/service.ts`, `src/analyzer/analyzer.ts`,
`src/plugin/plugin.ts`, or `src/routes/build-routes.ts`. The
`spec-conformance` test pins the union shape to prevent silent drift,
and the error-codes test pins the count + name set so removing a code
fails CI.

View File

@@ -0,0 +1,105 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The OTel instruments module exposes the canonical set of meters the
search plugin records on every hot path. The bundle is created once
against the global `MeterProvider` and cached, so all production call
sites — the in-memory engine, the route handlers, and the plugin
lifecycle — observe through the same `Counter` / `Histogram` instances.
Tests install a real in-memory `MeterProvider`, reset the cache, and
assert the recorded data points without mocking the OTel API.
## Features
- Five instruments covering every consumer-visible operation:
`queriesTotal`, `queryDurationMs`, `documentsIndexedTotal`,
`indexesTotal`, `suggestRequestsTotal`.
- Counters carry an `outcome` or `op` attribute so dashboards can
partition by success/failure or by index/bulk/remove without
juggling separate metric names.
- `resetSearchInstruments()` clears the cache so test suites can
install a fresh `MeterProvider` between cases and rebind the
instruments deterministically.
- Single meter name `@suaveplan/api-plugin-search` so downstream
collectors can scope alerting and SLOs to this plugin.
## Usage: Basic
```ts
import { getSearchInstruments } from "@suaveplan/api-plugin-search";
const start = performance.now();
try {
const result = search(query, indexes);
const inst = getSearchInstruments();
inst.queriesTotal.add(1, { outcome: "success" });
inst.queryDurationMs.record(performance.now() - start, {
index: query.index,
});
return result;
} catch (err) {
getSearchInstruments().queriesTotal.add(1, { outcome: "error" });
throw err;
}
```
## Usage: Advanced
Pair the reset helper with an in-memory exporter in test setup so
every test observes its own fresh metric stream without leaking state
across cases:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { resetSearchInstruments } from "@suaveplan/api-plugin-search";
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetSearchInstruments();
```
## API Reference
### `SearchInstruments`
Interface bundling the five OTel instruments. Each field is a
`Counter` from `@opentelemetry/api` except `queryDurationMs` which is a
`Histogram`.
### `getSearchInstruments(): SearchInstruments`
Returns the cached bundle, lazily creating it against the meter named
`@suaveplan/api-plugin-search` on first call. Subsequent calls return
the same object reference.
### `resetSearchInstruments(): void`
Drops the cache so the next `getSearchInstruments()` call rebinds
against the current global `MeterProvider`. Test-only helper.
## Implementation Notes
The module retains the bundle in a module-scope `let cached`. This is
intentional and matches the pattern in `@suaveplan/api-plugin-user` —
the cache is per-process, every consumer reads through the global
`MeterProvider`, and the reset helper is the explicit escape hatch for
deterministic test isolation. The histogram unit is fixed to `"ms"`
because every hot-path measurement uses `performance.now()`.

View File

@@ -0,0 +1,94 @@
# plugin/plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The plugin module exposes `createSearchPlugin`, the
`@suaveplan/api-plugin`-shaped factory that the host
`PluginRegistry` consumes to bring the search engine online. It
defines the three lifecycle hooks (`onInit`, `onReady`, `onClose`),
validates factory options through `SearchPluginOptionsSchema`,
registers the in-memory index map on the `ServiceRegistry`, mounts
the 9-route OpenAPIHono surface under the configured `routePrefix`,
and emits four lifecycle events through the host event bus. The
factory is the single entry point consumers use to add search to an
API host built on `@suaveplan/api`.
## Features
- Zod-validated factory options with typed `SearchError` failure on
invalid input (`SEARCH.INVALID_OPTIONS`).
- Three-phase lifecycle: `onInit` registers the index map, `onReady`
builds + mounts routes, `onClose` clears the map.
- Typed emission through `ctx.eventBus` — no `as any` anywhere; the
emitter pattern-matches on `SearchRouteEvent.type` and forwards the
payload directly.
- `optionalDependencies: ["database", "events"]` so the host can
topo-sort the search plugin after the database and events plugins
when they exist, without forcing them.
- `SERVICE_NOT_AVAILABLE` failure when `app` is absent from the
registry — surfaces misconfiguration during `onReady` rather than
silently no-oping.
## Usage: Basic
```ts
import { createApi } from "@suaveplan/api";
import { createSearchPlugin } from "@suaveplan/api-plugin-search";
const api = createApi({
plugins: [createSearchPlugin()],
});
```
## Usage: Advanced
Bind the plugin to a custom mount path, narrow the per-index document
ceiling, and switch the default analyzer to `whitespace`:
```ts
const api = createApi({
plugins: [
createSearchPlugin({
routePrefix: "/api/fts",
maxIndexes: 50,
maxDocumentsPerIndex: 50_000,
defaultAnalyzer: "whitespace",
}),
],
});
```
## API Reference
### `createSearchPlugin(options?: Partial<SearchPluginConfig>): PluginDescriptor<SearchPluginConfig>`
Validates `options` through `SearchPluginOptionsSchema`. Returns a
descriptor with `name: "search"`, `version: "0.1.0"`,
`optionalDependencies: ["database", "events"]`,
`tags: ["search", "fulltext", "facets", "relevance"]`. Throws
`SearchError(SEARCH.INVALID_OPTIONS)` on validation failure.
The descriptor implements:
- `onInit(ctx)` — registers `searchIndexes` service, emits
`search:initialized`.
- `onReady(ctx)` — resolves `app`, builds routes via
`createSearchRoutes`, mounts under `routePrefix`, emits
`search:ready`. Throws `SearchError(SEARCH.SERVICE_NOT_AVAILABLE)`
if `app` is missing.
- `onClose(ctx)` — clears the in-memory index map.
## Implementation Notes
`runOnReady` extracts the body of `onReady` into a top-level function
so the descriptor's hook arrow stays a one-liner — this keeps the
lambda's source-map exact and keeps test coverage attributable to
`runOnReady` directly rather than to an anonymous closure. The
`indexes` map is captured in the factory closure so the three hooks
share a single reference; this is intentional and matches the
upstream design (a fresh `createSearchPlugin()` call creates a fresh
index map, so concurrent plugin instances never share state).

View File

@@ -0,0 +1,97 @@
# routes/build-routes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The build-routes module constructs the `OpenAPIHono` instance that the
plugin mounts under `routePrefix` (`/search` by default). It declares
the 9 canonical search endpoints — query, suggest, list indexes,
create index, get index, delete index, index document, bulk index, and
remove document — wiring every request through Zod validation via
`@hono/zod-openapi`, dispatching to the in-memory engine in
`service/service.ts`, and propagating typed events through the
supplied `emitEvent` callback. Error responses are normalised to the
canonical `{ error: string }` shape and the HTTP status is driven by
the `context.statusCode` carried on each `SearchError`.
## Features
- 9 endpoints with OpenAPI 3.1 metadata baked in: every request body /
path param / query string is Zod-validated, every response shape
declared.
- Typed `SearchRouteEvent` discriminated union so emissions never need
`as any` and consumers can pattern-match on `event.type`.
- Typed errors mapped to HTTP via `errorPayload`: `SearchError` with
`context.statusCode === 404` maps to 404, every other failure to
400; non-`SearchError` throwables stringify their message.
- Side-effect-free factory — `createSearchRoutes` builds a fresh
router on every call, so concurrent plugin instantiations never
share router state.
- Pure dispatch layer — the routes carry zero business logic; they
exist purely to translate HTTP into service calls and back.
## Usage: Basic
```ts
import { createSearchRoutes } from "@suaveplan/api-plugin-search";
const router = createSearchRoutes({
indexes,
serviceConfig: {
maxIndexes: 100,
maxDocumentsPerIndex: 100_000,
defaultAnalyzer: "standard",
},
});
app.route("/search", router);
```
## Usage: Advanced
Wire the emitter to the host event bus so domain code can react to
search lifecycle:
```ts
const router = createSearchRoutes({
indexes,
serviceConfig,
emitEvent: (event) => {
eventBus.emit(event.type, event.payload);
logger.info("search-event", { type: event.type });
},
});
```
## API Reference
### `createSearchRoutes(deps): OpenAPIHono`
Returns the configured router with 9 endpoints registered.
### `SearchRoutesDeps`
`{ indexes: Map<string, SearchIndex>; serviceConfig: SearchServiceConfig; emitEvent?: (event: SearchRouteEvent) => void }`.
### `SearchRouteEvent`
Discriminated union — `search:query:executed` /
`search:index:updated` / `search:index:created` /
`search:index:deleted`. Every variant carries `type` + `payload` so
emitters can pattern-match without unsafe casts.
## Implementation Notes
The error-handling helper `errorPayload` exists once rather than per
route so the mapping rule (`statusCode 404 → 404`, anything else →
400) lives in one place. The five lookup routes that throw 404 by
contract clamp the payload status to 404 explicitly; the index/bulk
write routes that can throw `INDEX_NOT_FOUND` (404) or
`DOCUMENT_LIMIT_REACHED` (400) consult the underlying status. The
`getSearchIndex` post-create check guards against the impossible case
where the service inserted a name that immediately fails lookup —
defensive but unreachable, kept to satisfy the `info!` exclamation
that upstream relied on.

View File

@@ -0,0 +1,82 @@
# schemas/options
## Purpose
The options module defines the Zod schema for the input accepted by
`createSearchPlugin(options?)`. The factory validates user-supplied
options at the boundary and rejects invalid input with a typed
`SearchError(SEARCH.INVALID_OPTIONS)` whose `cause` is the original
`ZodError`. Keeping the schema in a dedicated module lets downstream
consumers (orchestrators, OpenSpec-style runtime config layers) share
the same validator without importing the plugin factory.
## Features
- Four configurable options with sensible defaults: `routePrefix`
(`/search`), `maxIndexes` (`100`), `maxDocumentsPerIndex` (`100_000`),
`defaultAnalyzer` (`"standard"`).
- Hard upper bounds (`maxIndexes ≤ 10_000`, `maxDocumentsPerIndex ≤
10_000_000`) so misconfiguration cannot accidentally exhaust process
memory.
- `routePrefix` enforces a leading `/` and a 128-char ceiling so route
registration cannot silently fail.
- Two inferred types — `SearchPluginOptionsInput` (raw input, every
field optional) and `SearchPluginOptionsResolved` (post-defaults,
every field required) — so callers can type-check both
pre-validation and post-validation shapes.
## Usage: Basic
```ts
import { SearchPluginOptionsSchema } from "@suaveplan/api-plugin-search";
const parsed = SearchPluginOptionsSchema.safeParse({});
if (parsed.success) {
// parsed.data.routePrefix === "/search"
}
```
## Usage: Advanced
Wire the schema into a top-level config loader so the entire stack's
plugin options are validated in one pass:
```ts
import z from "zod";
import { SearchPluginOptionsSchema } from "@suaveplan/api-plugin-search";
const AppConfigSchema = z.object({
search: SearchPluginOptionsSchema.optional(),
// ... other plugin schemas
});
const config = AppConfigSchema.parse(rawConfig);
const plugin = createSearchPlugin(config.search);
```
## API Reference
### `SearchPluginOptionsSchema`
`z.ZodObject` validating the four fields described above. Apply with
`safeParse` to receive a discriminated success/failure result; the
factory `createSearchPlugin` always uses `safeParse` and never throws
the raw `ZodError`.
### `SearchPluginOptionsInput`
Type alias for `z.input<typeof SearchPluginOptionsSchema>` — every
field optional.
### `SearchPluginOptionsResolved`
Type alias for `z.output<typeof SearchPluginOptionsSchema>` — every
field required, defaults applied.
## Implementation Notes
The schema deliberately omits an `enabled` flag — orchestrators
decide whether to call `createSearchPlugin` at all rather than
exposing a runtime disable switch. The `defaultAnalyzer` field accepts
any non-empty string so consumers can register additional analyzers
through the public analyzer-registry API and reference them by name.

View File

@@ -0,0 +1,112 @@
# schemas/schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The schemas module owns the public Zod surface of
`@suaveplan/api-plugin-search`. Every HTTP route in the package validates
its request body, path params, or query string against one of these
schemas before invoking the service layer, and every response shape is
declared here so `@hono/zod-openapi` can publish a typed `/openapi.json`
document without per-route duplication. Inferred types are exported in
parallel so direct programmatic consumers (e.g. another plugin building
on top of the search service) can construct payloads with full type
safety.
## Features
- Thirteen schemas covering the full upstream search surface — queries,
hits, facets, sort orders, index status enums, index field
declarations, document indexing, bulk indexing, suggest queries, and
every response shape.
- Two const-object enums (`SearchSortOrder`, `IndexStatus`) so consumers
can branch on string literals without re-typing them.
- Sensible defaults at the boundary (`sort: "relevance"`, `limit: 20`,
`offset: 0`, `highlight: false`, `highlightTag: "mark"`,
`analyzer: "standard"`, `weight: 1`, `facet: false`) so consumers only
pass the fields they care about.
- Hard upper bounds on every numeric and string field — `query` capped
at 1000 chars, index names at 128 chars, `limit` ≤ 200, bulk docs ≤
1000 — keeping the HTTP surface within sane resource budgets.
- Inferred TypeScript types exported alongside each schema so call
sites can type-check payloads at compile time without `z.infer`
boilerplate.
## Usage: Basic
```ts
import {
SearchQuerySchema,
type SearchQuery,
} from "@suaveplan/api-plugin-search";
const parsed = SearchQuerySchema.safeParse({
query: "hello world",
index: "posts",
});
if (parsed.success) {
const q: SearchQuery = parsed.data;
// q.sort === "relevance" by default, q.limit === 20, ...
}
```
## Usage: Advanced
Compose the schemas with `@hono/zod-openapi` to publish typed routes:
```ts
import { createRoute } from "@hono/zod-openapi";
import {
SearchQuerySchema,
SearchResultSchema,
} from "@suaveplan/api-plugin-search";
export const searchRoute = createRoute({
method: "post",
path: "/query",
request: {
body: {
content: { "application/json": { schema: SearchQuerySchema } },
},
},
responses: {
200: {
description: "Search results",
content: { "application/json": { schema: SearchResultSchema } },
},
},
});
```
## API Reference
| Symbol | Shape |
|---|---|
| `SearchSortOrder` | const object + type union — `"relevance"`, `"date_asc"`, `"date_desc"`, `"alpha_asc"`, `"alpha_desc"` |
| `IndexStatus` | const object + type union — `"active"`, `"building"`, `"error"` |
| `SearchQuerySchema` / `SearchQuery` | request body for `POST /query` |
| `SearchHitSchema` / `SearchHit` | one hit returned by `search()` |
| `FacetValueSchema` / `FacetValue` | `{ value, count }` |
| `FacetSchema` / `Facet` | `{ field, values: FacetValue[] }` |
| `SearchResultSchema` / `SearchResult` | response shape of `POST /query` |
| `IndexFieldSchema` / `IndexField` | per-field config in `CreateIndex` |
| `CreateIndexSchema` / `CreateIndex` | request body for `POST /indexes` |
| `IndexInfoSchema` / `IndexInfo` | response shape of `GET /indexes/:name` |
| `IndexListQuerySchema` / `IndexListQuery` | query string for `GET /indexes` |
| `IndexDocumentSchema` / `IndexDocument` | request body for indexing |
| `BulkIndexSchema` / `BulkIndex` | request body for bulk indexing |
| `SuggestQuerySchema` / `SuggestQuery` | request body for `POST /suggest` |
| `SuggestionSchema` / `Suggestion` | one entry in `SuggestResult.suggestions` |
| `SuggestResultSchema` / `SuggestResult` | response shape of `POST /suggest` |
## Implementation Notes
All numeric fields use `z.coerce.number()` so query-string values
(`"20"`) parse correctly. `z.record(z.string(), z.unknown())` is used
for free-form document fields and filter maps. The `createdAt` field
on `IndexInfoSchema` accepts both ISO-string and `Date` so the schema
can validate both the service-internal shape and the
serialized-over-HTTP shape.

View File

@@ -0,0 +1,137 @@
# service/service
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The service module is the in-memory search engine that powers
`@suaveplan/api-plugin-search`. It owns index creation and deletion,
per-document analysis and storage, TF-weighted scoring, scalar/array
filtering, multi-key sorting, facet aggregation, optional highlighting,
and the prefix-based suggest pass. Every route handler in the package
delegates to the functions here, every code path that fails throws a
typed `SearchError(SEARCH.*)`, and every consumer-visible operation
emits to the OTel meter bundle so dashboards reflect actual hot-path
behaviour.
## Features
- Synchronous, dependency-free engine — `Map<string, SearchIndex>` is
the entire backing store.
- TF × field-weight scoring loop with deterministic ordering across
identical scores (relies on `Map` insertion order).
- Filter pass supporting scalar equality (`actual === expected`) and
array membership (`expected.includes(actual)`) so a single Zod
schema accepts both shapes.
- Five-way sort (`relevance`, `date_asc`, `date_desc`, `alpha_asc`,
`alpha_desc`) using `localeCompare` for stable cross-locale ordering.
- Facet aggregation restricted to fields with `facet: true`; counts are
built over the filtered (not raw) document set.
- Optional highlighting wraps matching whole words in the
caller-supplied tag (`<mark>` by default).
- Prefix-based suggest derives candidates from the last analysed token
and ranks by occurrence count.
- Three hard caps (`maxIndexes`, `maxDocumentsPerIndex`) enforced with
typed errors that map to HTTP 400 via `context.statusCode`.
- Five OTel instruments wired into every call: `queriesTotal`,
`queryDurationMs`, `documentsIndexedTotal`, `indexesTotal`,
`suggestRequestsTotal`.
## Usage: Basic
```ts
import {
createSearchIndex,
indexDocument,
search,
} from "@suaveplan/api-plugin-search";
const indexes = new Map();
createSearchIndex(
{ name: "posts", fields: [{ name: "title", weight: 2, facet: false }] },
indexes
);
indexDocument(
"posts",
{ id: "1", fields: { title: "Hello world" } },
indexes
);
const result = search(
{
query: "hello",
index: "posts",
sort: "relevance",
limit: 20,
offset: 0,
highlight: false,
highlightTag: "mark",
},
indexes
);
```
## Usage: Advanced
Bulk-index 500 documents, search with a facet + filter, and observe
the emitted OTel metrics:
```ts
import {
bulkIndexDocuments,
search,
} from "@suaveplan/api-plugin-search";
bulkIndexDocuments(
"posts",
docs,
indexes,
{ maxIndexes: 100, maxDocumentsPerIndex: 1_000, defaultAnalyzer: "standard" }
);
const result = search(
{
query: "search",
index: "posts",
filters: { tag: ["blog", "release"] },
facets: ["tag"],
sort: "relevance",
limit: 20,
offset: 0,
highlight: true,
highlightTag: "em",
},
indexes
);
// result.facets[0].field === "tag", result.hits[0].highlights.title contains <em>…</em>
```
## API Reference
| Function | Throws |
|---|---|
| `createSearchIndex(config, indexes, serviceConfig?)` | `INDEX_ALREADY_EXISTS`, `INDEX_LIMIT_REACHED` |
| `deleteSearchIndex(name, indexes)` | — |
| `getSearchIndex(name, indexes)` | — |
| `listSearchIndexes(indexes, query?)` | — |
| `indexDocument(name, doc, indexes, serviceConfig?)` | `INDEX_NOT_FOUND`, `DOCUMENT_LIMIT_REACHED` |
| `bulkIndexDocuments(name, docs, indexes, serviceConfig?)` | propagates from `indexDocument` |
| `removeDocument(name, docId, indexes)` | `INDEX_NOT_FOUND` |
| `search(query, indexes)` | `INDEX_NOT_FOUND`, `UNKNOWN_ANALYZER` |
| `suggest(query, indexes)` | `INDEX_NOT_FOUND`, `UNKNOWN_ANALYZER` |
`SearchServiceConfig` carries the three hard caps; `DEFAULT_SEARCH_CONFIG` provides the defaults
(`maxIndexes: 100`, `maxDocumentsPerIndex: 100_000`, `defaultAnalyzer: "standard"`).
## Implementation Notes
`scoreDocuments` filters out documents with `score === 0` so they
never appear in result sets — this matches upstream behaviour. The
`sortResults` switch is closed by the `SearchSortOrder` Zod enum so
the `default` arm is provably unreachable; it is preserved as a
forward-defensive carve-out. `buildFacets` walks `facetFields` in
caller order so consumers can rely on response shape. `suggest` reads
`tokens.at(-1)` rather than indexing `[len - 1]` so the empty-array
case is statically impossible — even though the caller already short-
circuits on zero tokens, the `at` call is the documented narrow form.

View File

@@ -0,0 +1,83 @@
# services/registry-augmentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is a TypeScript `declare module` augmentation. It adds the
`searchIndexes` slot to `@suaveplan/api-plugin`'s canonical
`ServiceRegistry` interface so any consumer importing
`@suaveplan/api-plugin-search` automatically sees
`ctx.getService("searchIndexes")` resolve to
`Map<string, SearchIndex> | undefined`. Without this augmentation the
`getService` call would fail TypeScript compilation because the
default `ServiceRegistry` is empty by design.
## Features
- Adds `searchIndexes: Map<string, SearchIndex>` to the augmented
registry.
- Re-declares the host-side `app: OpenAPIHono` slot owned by
`@suaveplan/api` so the search plugin's `onInit` can resolve `app`
with full typing even in isolated unit tests that do not import
`@suaveplan/api`. TypeScript merges duplicate `declare module`
declarations transparently.
- Side-effect-only module — importing the package barrel applies the
augmentation automatically; there is nothing to call.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-search"; // side-effect augmentation
import type { PluginContext } from "@suaveplan/api-plugin";
function consume(ctx: PluginContext): void {
const indexes = ctx.getService("searchIndexes");
// indexes: Map<string, SearchIndex> | undefined
}
```
## Usage: Advanced
Sibling plugins that operate on the search index map can mutate it
safely once the search plugin has run — the map is a live reference,
not a snapshot:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
analyticsCollector: { onIndexEvent(event: string): void };
}
}
async function onReady(ctx: PluginContext): Promise<void> {
const indexes = ctx.getService("searchIndexes");
if (indexes !== undefined) {
// …operate on indexes…
}
}
```
## API Reference
This module has no public runtime exports — it augments the
`ServiceRegistry` interface from
`@suaveplan/api-plugin/service-registry`. After import the augmented
interface carries:
| Slot | Type |
|---|---|
| `searchIndexes` | `Map<string, SearchIndex>` |
| `app` | `OpenAPIHono` (re-declared from `@suaveplan/api`) |
## Implementation Notes
The module pattern (`declare module "@suaveplan/api-plugin/service-registry" {…}`)
relies on TypeScript's declaration merging. Multiple packages
augmenting the same interface union their fields automatically; no
package owns the registry exclusively. The empty `export {}` at the
bottom forces this file to be a module rather than a script so the
`declare module` is a proper augmentation rather than ambient
declaration.

View File

@@ -0,0 +1,88 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The types module owns the internal-shape contracts of the search
plugin — the materialised `SearchIndex` held in memory, the per-document
`IndexedDocument` payload that caches analysed tokens, the resolved
`SearchPluginConfig`, the typed event-bus payloads, and the
`SearchAdapter` interface that future persistent backends will
implement. Keeping these in one module separates the "engine
internals" surface from the public Zod schemas (`schemas/schemas.ts`)
without losing strong typing at the call sites.
## Features
- `SearchIndex` — the in-memory map entry holding name, declared
fields, analyzer, status, document map, and creation timestamp.
- `IndexedDocument` — caches per-field token arrays so the scoring
loop never re-analyses on read.
- `SearchAdapter` — the contract any alternate backend
(SurrealDB FTS, Typesense, Meilisearch) must implement. Ships
upfront so external implementors can compile against a stable shape.
- `SearchPluginConfig` — the resolved-after-defaults shape carried by
the descriptor's generic parameter.
- `SearchQueryExecutedEvent` / `SearchIndexUpdatedEvent` /
`SearchEventMap` — strongly-typed event payloads so the route
handlers never cast through `any`.
## Usage: Basic
```ts
import type { SearchIndex } from "@suaveplan/api-plugin-search";
function describe(index: SearchIndex): string {
return `${index.name} (${index.documents.size} docs)`;
}
```
## Usage: Advanced
Implement a custom backend against the `SearchAdapter` contract:
```ts
import type {
SearchAdapter,
SearchQuery,
SearchResult,
} from "@suaveplan/api-plugin-search";
class TypesenseAdapter implements SearchAdapter {
async search(query: SearchQuery): Promise<SearchResult> {
// delegate to typesense client...
return {
hits: [],
total: 0,
query: query.query,
took: 0,
};
}
// ... other methods
}
```
## API Reference
| Type | Shape |
|---|---|
| `IndexedDocument` | `{ id; fields; tokens: Map<string, string[]> }` |
| `SearchIndex` | `{ name; fields; analyzer; status; documents: Map<string, IndexedDocument>; createdAt }` |
| `SearchAdapter` | 9-method async interface mirroring the engine API |
| `SearchPluginConfig` | resolved options shape |
| `SearchQueryExecutedEvent` | `{ indexName; query; hitCount; durationMs; timestamp }` |
| `SearchIndexUpdatedEvent` | `{ indexName; operation; documentCount; timestamp }` |
| `SearchEventMap` | mapping from event name to payload |
## Implementation Notes
`IndexedDocument.tokens` is a `Map<string, string[]>` not a plain
record so iteration order is insertion order — the scoring loop walks
fields in declaration order which is observable in deterministic
score tests. The `SearchAdapter` interface is async even though the
in-memory engine is synchronous; this is intentional — making the
contract async upfront avoids a breaking change when the persistent
backend ships.