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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user