Initial Commit
This commit is contained in:
116
packages/api-stack/api/api-plugin-search/README.md
Normal file
116
packages/api-stack/api/api-plugin-search/README.md
Normal 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.
|
||||
|
||||
117
packages/api-stack/api/api-plugin-search/package.json
Normal file
117
packages/api-stack/api/api-plugin-search/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-search",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./analyzer/analyzer": {
|
||||
"import": {
|
||||
"types": "./dist/analyzer/analyzer.d.ts",
|
||||
"default": "./dist/analyzer/analyzer.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./routes/build-routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/build-routes.d.ts",
|
||||
"default": "./dist/routes/build-routes.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./schemas/schemas": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/schemas.d.ts",
|
||||
"default": "./dist/schemas/schemas.js"
|
||||
}
|
||||
},
|
||||
"./service/service": {
|
||||
"import": {
|
||||
"types": "./dist/service/service.d.ts",
|
||||
"default": "./dist/service/service.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-database": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -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,163 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createKeywordAnalyzer,
|
||||
createSimpleAnalyzer,
|
||||
createStandardAnalyzer,
|
||||
createWhitespaceAnalyzer,
|
||||
getAnalyzer,
|
||||
listAnalyzers,
|
||||
removeStopWords,
|
||||
stem,
|
||||
tokenize,
|
||||
} from "./analyzer.js";
|
||||
|
||||
describe("tokenize", () => {
|
||||
it("lowercases and strips punctuation", { timeout: 5000 }, () => {
|
||||
expect(tokenize("Hello, World!")).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
it("drops empty tokens", { timeout: 5000 }, () => {
|
||||
expect(tokenize(" a b ")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeStopWords", () => {
|
||||
it("filters the default English stop-word set", { timeout: 5000 }, () => {
|
||||
expect(removeStopWords(["the", "cat", "is", "fast"])).toEqual([
|
||||
"cat",
|
||||
"fast",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a custom stop-word set", { timeout: 5000 }, () => {
|
||||
expect(removeStopWords(["foo", "bar"], new Set(["foo"]))).toEqual([
|
||||
"bar",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stem", () => {
|
||||
it("returns short words unchanged", { timeout: 5000 }, () => {
|
||||
expect(stem("at")).toBe("at");
|
||||
expect(stem("cat")).toBe("cat");
|
||||
});
|
||||
|
||||
it("strips common suffixes", { timeout: 5000 }, () => {
|
||||
expect(stem("running")).toBe("runn");
|
||||
expect(stem("happiness")).toBe("happi");
|
||||
expect(stem("rationalization")).toBe("rationalize");
|
||||
});
|
||||
|
||||
it("returns the original word when the stem would be too short", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// "ling" suffix on "ling" itself → "" replacement → too short, returned as-is
|
||||
expect(stem("ling")).toBe("ling");
|
||||
});
|
||||
|
||||
it("falls through when no suffix rule matches", { timeout: 5000 }, () => {
|
||||
expect(stem("xyzfoo")).toBe("xyzfoo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("standard analyzer", () => {
|
||||
it("standard analyzer tokenises, removes stop words, and stems", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createStandardAnalyzer();
|
||||
const tokens = a.analyze("Running the cats quickly");
|
||||
expect(tokens).toContain("cat");
|
||||
expect(tokens).toContain("quick");
|
||||
expect(tokens).not.toContain("the");
|
||||
});
|
||||
|
||||
it("respects opt-out flags", { timeout: 5000 }, () => {
|
||||
const a = createStandardAnalyzer({
|
||||
removeStops: false,
|
||||
stemTokens: false,
|
||||
});
|
||||
const tokens = a.analyze("the cats running");
|
||||
expect(tokens).toEqual(["the", "cats", "running"]);
|
||||
});
|
||||
|
||||
it("enforces min/max token length", { timeout: 5000 }, () => {
|
||||
const a = createStandardAnalyzer({
|
||||
removeStops: false,
|
||||
stemTokens: false,
|
||||
minTokenLength: 3,
|
||||
maxTokenLength: 5,
|
||||
});
|
||||
expect(a.analyze("a bb ccc dddd eeeee ffffff")).toEqual([
|
||||
"ccc",
|
||||
"dddd",
|
||||
"eeeee",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("simple analyzer", () => {
|
||||
it("tokenises with no stemming or stop-word removal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createSimpleAnalyzer();
|
||||
expect(a.analyze("The Cats Running")).toEqual([
|
||||
"the",
|
||||
"cats",
|
||||
"running",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace analyzer", () => {
|
||||
it("splits on whitespace and preserves case", { timeout: 5000 }, () => {
|
||||
const a = createWhitespaceAnalyzer();
|
||||
expect(a.analyze("Hello, World!")).toEqual(["Hello,", "World!"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyword analyzer", () => {
|
||||
it("keyword analyzer returns the trimmed input as a single token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(createKeywordAnalyzer().analyze(" Hello World ")).toEqual([
|
||||
"Hello World",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
expect(createKeywordAnalyzer().analyze(" ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry", () => {
|
||||
it("lists the four built-in analyzer names", { timeout: 5000 }, () => {
|
||||
expect(listAnalyzers()).toEqual([
|
||||
"standard",
|
||||
"simple",
|
||||
"whitespace",
|
||||
"keyword",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getAnalyzer throws SEARCH.UNKNOWN_ANALYZER for an unregistered name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
getAnalyzer("does-not-exist");
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.UNKNOWN_ANALYZER
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves each built-in analyzer", { timeout: 5000 }, () => {
|
||||
for (const name of listAnalyzers()) {
|
||||
const a = getAnalyzer(name);
|
||||
expect(a.name).toBe(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Text analyzer module — tokenisation, stop-word removal, suffix
|
||||
* stemming, and four built-in analyzer factories registered under the
|
||||
* shared registry consumed by `getAnalyzer`.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Stop words (English baseline)
|
||||
// ============================================================================
|
||||
|
||||
const ENGLISH_STOP_WORDS = new Set([
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"are",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"but",
|
||||
"by",
|
||||
"for",
|
||||
"if",
|
||||
"in",
|
||||
"into",
|
||||
"is",
|
||||
"it",
|
||||
"no",
|
||||
"not",
|
||||
"of",
|
||||
"on",
|
||||
"or",
|
||||
"such",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"then",
|
||||
"there",
|
||||
"these",
|
||||
"they",
|
||||
"this",
|
||||
"to",
|
||||
"was",
|
||||
"will",
|
||||
"with",
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// Stemmer (simplified Porter-style suffix stripping)
|
||||
// ============================================================================
|
||||
|
||||
const SUFFIX_RULES: ReadonlyArray<readonly [string, string]> = [
|
||||
["ational", "ate"],
|
||||
["tional", "tion"],
|
||||
["enci", "ence"],
|
||||
["anci", "ance"],
|
||||
["izer", "ize"],
|
||||
["alli", "al"],
|
||||
["entli", "ent"],
|
||||
["ousli", "ous"],
|
||||
["ization", "ize"],
|
||||
["ation", "ate"],
|
||||
["fulness", "ful"],
|
||||
["ousness", "ous"],
|
||||
["iveness", "ive"],
|
||||
["ement", "e"],
|
||||
["ness", ""],
|
||||
["ment", ""],
|
||||
["ling", ""],
|
||||
["ying", "y"],
|
||||
["ting", "t"],
|
||||
["ing", ""],
|
||||
["ies", "y"],
|
||||
["ied", "y"],
|
||||
["ses", "s"],
|
||||
["eed", "ee"],
|
||||
["ed", ""],
|
||||
["ly", ""],
|
||||
["er", ""],
|
||||
["es", ""],
|
||||
["s", ""],
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply a simplified suffix-stripping stemmer. Words of length ≤ 3 are
|
||||
* returned untouched. The first matching suffix in declaration order
|
||||
* wins; replacement must leave at least two characters or the original
|
||||
* word is returned.
|
||||
*/
|
||||
export function stem(word: string): string {
|
||||
if (word.length <= 3) return word;
|
||||
|
||||
for (const [suffix, replacement] of SUFFIX_RULES) {
|
||||
if (word.endsWith(suffix)) {
|
||||
const stemmed = word.slice(0, -suffix.length) + replacement;
|
||||
return stemmed.length >= 2 ? stemmed : word;
|
||||
}
|
||||
}
|
||||
|
||||
return word;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tokenizer + stop-word filter
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Split text into tokens — lowercase, strip non-letter/non-digit
|
||||
* characters, split on whitespace, drop empty results.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stop words from a token list. Defaults to the English baseline
|
||||
* set; callers can supply a custom `Set<string>` for other languages.
|
||||
*/
|
||||
export function removeStopWords(
|
||||
tokens: string[],
|
||||
stopWords: Set<string> = ENGLISH_STOP_WORDS
|
||||
): string[] {
|
||||
return tokens.filter((t) => !stopWords.has(t));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Analyzer contract + options
|
||||
// ============================================================================
|
||||
|
||||
export interface AnalyzerOptions {
|
||||
removeStops?: boolean;
|
||||
stemTokens?: boolean;
|
||||
minTokenLength?: number;
|
||||
maxTokenLength?: number;
|
||||
stopWords?: Set<string>;
|
||||
}
|
||||
|
||||
export interface TextAnalyzer {
|
||||
readonly name: string;
|
||||
analyze(text: string): string[];
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<Omit<AnalyzerOptions, "stopWords">> & {
|
||||
stopWords: Set<string>;
|
||||
} = {
|
||||
removeStops: true,
|
||||
stemTokens: true,
|
||||
minTokenLength: 1,
|
||||
maxTokenLength: 100,
|
||||
stopWords: ENGLISH_STOP_WORDS,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Built-in analyzers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Standard analyzer — tokenise → length-filter → optionally remove stop
|
||||
* words → optionally stem.
|
||||
*/
|
||||
export function createStandardAnalyzer(
|
||||
options?: AnalyzerOptions
|
||||
): TextAnalyzer {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return {
|
||||
name: "standard",
|
||||
analyze(text: string): string[] {
|
||||
let tokens = tokenize(text);
|
||||
|
||||
tokens = tokens.filter(
|
||||
(t) =>
|
||||
t.length >= opts.minTokenLength &&
|
||||
t.length <= opts.maxTokenLength
|
||||
);
|
||||
|
||||
if (opts.removeStops) {
|
||||
tokens = removeStopWords(tokens, opts.stopWords);
|
||||
}
|
||||
|
||||
if (opts.stemTokens) {
|
||||
tokens = tokens.map(stem);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Simple analyzer — tokenise (lowercase + strip punctuation) only. */
|
||||
export function createSimpleAnalyzer(): TextAnalyzer {
|
||||
return {
|
||||
name: "simple",
|
||||
analyze(text: string): string[] {
|
||||
return tokenize(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whitespace analyzer — split on whitespace, preserve case + punctuation. */
|
||||
export function createWhitespaceAnalyzer(): TextAnalyzer {
|
||||
return {
|
||||
name: "whitespace",
|
||||
analyze(text: string): string[] {
|
||||
return text.split(/\s+/).filter((t) => t.length > 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keyword analyzer — treats the trimmed input as a single token. */
|
||||
export function createKeywordAnalyzer(): TextAnalyzer {
|
||||
return {
|
||||
name: "keyword",
|
||||
analyze(text: string): string[] {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 ? [trimmed] : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registry
|
||||
// ============================================================================
|
||||
|
||||
const BUILTIN_ANALYZERS = new Map<string, () => TextAnalyzer>([
|
||||
["standard", () => createStandardAnalyzer()],
|
||||
["simple", () => createSimpleAnalyzer()],
|
||||
["whitespace", () => createWhitespaceAnalyzer()],
|
||||
["keyword", () => createKeywordAnalyzer()],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Look up an analyzer by name. Throws `SearchError(UNKNOWN_ANALYZER)`
|
||||
* for names absent from the registry.
|
||||
*/
|
||||
export function getAnalyzer(name: string): TextAnalyzer {
|
||||
const factory = BUILTIN_ANALYZERS.get(name);
|
||||
if (!factory) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.UNKNOWN_ANALYZER,
|
||||
`Unknown analyzer: "${name}"`,
|
||||
{ context: { statusCode: 400, analyzer: name } }
|
||||
);
|
||||
}
|
||||
return factory();
|
||||
}
|
||||
|
||||
/** List the names of every analyzer registered in the built-in registry. */
|
||||
export function listAnalyzers(): string[] {
|
||||
return Array.from(BUILTIN_ANALYZERS.keys());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
AnalyzerOptions,
|
||||
TextAnalyzer,
|
||||
} from "./analyzer.js";
|
||||
export {
|
||||
createKeywordAnalyzer,
|
||||
createSimpleAnalyzer,
|
||||
createStandardAnalyzer,
|
||||
createWhitespaceAnalyzer,
|
||||
getAnalyzer,
|
||||
listAnalyzers,
|
||||
removeStopWords,
|
||||
stem,
|
||||
tokenize,
|
||||
} from "./analyzer.js";
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { PluginContext } from "@suaveplan/api-plugin";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import "./services/registry-augmentation.js";
|
||||
import {
|
||||
type AnalyzerOptions,
|
||||
bulkIndexDocuments,
|
||||
createKeywordAnalyzer,
|
||||
createSearchIndex,
|
||||
createSearchPlugin,
|
||||
createSearchRoutes,
|
||||
createSimpleAnalyzer,
|
||||
createStandardAnalyzer,
|
||||
createWhitespaceAnalyzer,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
deleteSearchIndex,
|
||||
getAnalyzer,
|
||||
getSearchIndex,
|
||||
getSearchInstruments,
|
||||
type IndexedDocument,
|
||||
indexDocument,
|
||||
listAnalyzers,
|
||||
listSearchIndexes,
|
||||
removeDocument,
|
||||
removeStopWords,
|
||||
resetSearchInstruments,
|
||||
SEARCH_CODES,
|
||||
SearchError,
|
||||
type SearchErrorCode,
|
||||
type SearchIndex,
|
||||
type SearchInstruments,
|
||||
type SearchPluginConfig,
|
||||
type SearchPluginOptionsResolved,
|
||||
SearchPluginOptionsSchema,
|
||||
type SearchQueryExecutedEvent,
|
||||
search,
|
||||
stem,
|
||||
suggest,
|
||||
type TextAnalyzer,
|
||||
tokenize,
|
||||
} from "./index.js";
|
||||
|
||||
describe("public surface", () => {
|
||||
it("re-exports every documented symbol", { timeout: 5000 }, () => {
|
||||
const surface = {
|
||||
createSearchPlugin,
|
||||
createSearchRoutes,
|
||||
createSearchIndex,
|
||||
deleteSearchIndex,
|
||||
getSearchIndex,
|
||||
listSearchIndexes,
|
||||
indexDocument,
|
||||
bulkIndexDocuments,
|
||||
removeDocument,
|
||||
search,
|
||||
suggest,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
getAnalyzer,
|
||||
listAnalyzers,
|
||||
tokenize,
|
||||
stem,
|
||||
removeStopWords,
|
||||
createStandardAnalyzer,
|
||||
createSimpleAnalyzer,
|
||||
createWhitespaceAnalyzer,
|
||||
createKeywordAnalyzer,
|
||||
getSearchInstruments,
|
||||
resetSearchInstruments,
|
||||
SearchError,
|
||||
SEARCH_CODES,
|
||||
SearchPluginOptionsSchema,
|
||||
};
|
||||
for (const [name, value] of Object.entries(surface)) {
|
||||
expect(value, `${name} should be defined`).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("type-level pins", () => {
|
||||
it("SearchErrorCode is the 10-literal union", { timeout: 5000 }, () => {
|
||||
expectTypeOf<SearchErrorCode>().toEqualTypeOf<
|
||||
(typeof SEARCH_CODES)[keyof typeof SEARCH_CODES]
|
||||
>();
|
||||
});
|
||||
|
||||
it("SearchPluginOptionsResolved matches SearchPluginConfig", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<SearchPluginOptionsResolved>().toEqualTypeOf<{
|
||||
routePrefix: string;
|
||||
maxIndexes: number;
|
||||
maxDocumentsPerIndex: number;
|
||||
defaultAnalyzer: string;
|
||||
}>();
|
||||
expectTypeOf<SearchPluginConfig>().toEqualTypeOf<{
|
||||
routePrefix: string;
|
||||
maxIndexes: number;
|
||||
maxDocumentsPerIndex: number;
|
||||
defaultAnalyzer: string;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("augments the ServiceRegistry with the searchIndexes slot", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Type-level assertion only — `ctx.getService("searchIndexes")`
|
||||
// resolves to Map<string, SearchIndex> | undefined. The probe is a
|
||||
// function constant whose return type is what we pin.
|
||||
type Ctx = PluginContext<SearchPluginConfig>;
|
||||
const probe = (ctx: Ctx) => ctx.getService("searchIndexes");
|
||||
expectTypeOf(probe).returns.toEqualTypeOf<
|
||||
Map<string, SearchIndex> | undefined
|
||||
>();
|
||||
});
|
||||
|
||||
it("TextAnalyzer + AnalyzerOptions stay as documented", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<TextAnalyzer>().toMatchTypeOf<{
|
||||
readonly name: string;
|
||||
analyze(text: string): string[];
|
||||
}>();
|
||||
expectTypeOf<AnalyzerOptions>().toMatchTypeOf<{
|
||||
removeStops?: boolean;
|
||||
stemTokens?: boolean;
|
||||
minTokenLength?: number;
|
||||
maxTokenLength?: number;
|
||||
stopWords?: Set<string>;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("instrument bundle carries five fields", { timeout: 5000 }, () => {
|
||||
expectTypeOf<keyof SearchInstruments>().toEqualTypeOf<
|
||||
| "queriesTotal"
|
||||
| "queryDurationMs"
|
||||
| "documentsIndexedTotal"
|
||||
| "indexesTotal"
|
||||
| "suggestRequestsTotal"
|
||||
>();
|
||||
});
|
||||
|
||||
it("event payloads stay typed", { timeout: 5000 }, () => {
|
||||
expectTypeOf<SearchQueryExecutedEvent>().toMatchTypeOf<{
|
||||
indexName: string;
|
||||
query: string;
|
||||
hitCount: number;
|
||||
durationMs: number;
|
||||
timestamp: Date;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("internal IndexedDocument shape stays stable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<IndexedDocument>().toMatchTypeOf<{
|
||||
id: string;
|
||||
fields: Record<string, unknown>;
|
||||
tokens: Map<string, string[]>;
|
||||
}>();
|
||||
});
|
||||
});
|
||||
@@ -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,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SEARCH_CODES, SearchError } from "./error-codes.js";
|
||||
|
||||
describe("SEARCH_CODES registry", () => {
|
||||
it("declares 10 SEARCH.* codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(SEARCH_CODES);
|
||||
expect(values.length).toBe(10);
|
||||
expect(new Set(values).size).toBe(10);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("SEARCH.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries each documented name", { timeout: 5000 }, () => {
|
||||
const names: Array<keyof typeof SEARCH_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",
|
||||
];
|
||||
for (const n of names) {
|
||||
expect(typeof SEARCH_CODES[n]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchError class", () => {
|
||||
it("SearchError narrows code to SearchErrorCode and preserves cause", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner = new Error("inner");
|
||||
const err = new SearchError(SEARCH_CODES.INDEX_NOT_FOUND, "boom", {
|
||||
cause: inner,
|
||||
context: { statusCode: 404 },
|
||||
});
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect(err.code).toBe(SEARCH_CODES.INDEX_NOT_FOUND);
|
||||
expect(err.searchCode).toBe(SEARCH_CODES.INDEX_NOT_FOUND);
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.cause).toBe(inner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-search`.
|
||||
*
|
||||
* Every failure path in the search plugin raises a {@link SearchError}
|
||||
* carrying one of these 10 `SEARCH.*` codes. Consumers branch on the
|
||||
* string value (e.g. `error.code === SEARCH_CODES.INDEX_NOT_FOUND`).
|
||||
*
|
||||
* - `INDEX_NOT_FOUND` — a lookup (`getSearchIndex`, `indexDocument`,
|
||||
* `removeDocument`, `search`, `suggest`) referenced an index name that
|
||||
* has not been created. Maps to HTTP 404.
|
||||
* - `INDEX_ALREADY_EXISTS` — `createSearchIndex` was called with a name
|
||||
* that already exists in the index map. Maps to HTTP 400.
|
||||
* - `INDEX_LIMIT_REACHED` — `createSearchIndex` was called when the
|
||||
* number of indexes equals `maxIndexes`. Maps to HTTP 400.
|
||||
* - `DOCUMENT_LIMIT_REACHED` — `indexDocument` would push the per-index
|
||||
* document count above `maxDocumentsPerIndex` for a new doc id. Maps
|
||||
* to HTTP 400.
|
||||
* - `DOCUMENT_NOT_FOUND` — `removeDocument` was called with a doc id
|
||||
* absent from the target index. Maps to HTTP 404.
|
||||
* - `UNKNOWN_ANALYZER` — `getAnalyzer(name)` received a name that is
|
||||
* not registered in the built-in analyzer table. Maps to HTTP 400.
|
||||
* - `INVALID_OPTIONS` — `createSearchPlugin(options)` received options
|
||||
* that failed `SearchPluginOptionsSchema` validation. `cause` holds
|
||||
* the `ZodError`. Maps to HTTP 500 (factory-time misconfiguration).
|
||||
* - `INVALID_QUERY` — a route received a request body whose Zod
|
||||
* validation failed at the service layer (e.g. empty index name).
|
||||
* Maps to HTTP 400.
|
||||
* - `SERVICE_NOT_AVAILABLE` — `onReady` could not resolve the host
|
||||
* `app` service from `ServiceRegistry`. `context.missing` lists the
|
||||
* missing slot. Maps to HTTP 500.
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with routes already registered by another plugin. Maps to HTTP 500.
|
||||
*/
|
||||
export const SEARCH_CODES = 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",
|
||||
] as const);
|
||||
|
||||
/** Union of all 10 `SEARCH.*` string-literal codes. */
|
||||
export type SearchErrorCode = (typeof SEARCH_CODES)[keyof typeof SEARCH_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-search package. Extends
|
||||
* {@link SuaveplanError} so `instanceof SearchError` narrows `code` to
|
||||
* {@link SearchErrorCode}.
|
||||
*/
|
||||
export class SearchError extends SuaveplanError<SearchErrorCode> {
|
||||
public readonly searchCode: SearchErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: SearchErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.searchCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { SearchErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
SEARCH_CODES,
|
||||
SearchError,
|
||||
} from "./error-codes.js";
|
||||
113
packages/api-stack/api/api-plugin-search/src/index.ts
Normal file
113
packages/api-stack/api/api-plugin-search/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Side-effect imports — surface the error-codes registry + the
|
||||
// service-registry augmentation to every consumer importing the
|
||||
// package barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Analyzer
|
||||
export {
|
||||
type AnalyzerOptions,
|
||||
createKeywordAnalyzer,
|
||||
createSimpleAnalyzer,
|
||||
createStandardAnalyzer,
|
||||
createWhitespaceAnalyzer,
|
||||
getAnalyzer,
|
||||
listAnalyzers,
|
||||
removeStopWords,
|
||||
stem,
|
||||
type TextAnalyzer,
|
||||
tokenize,
|
||||
} from "./analyzer/analyzer.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
SEARCH_CODES,
|
||||
SearchError,
|
||||
type SearchErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
getSearchInstruments,
|
||||
resetSearchInstruments,
|
||||
type SearchInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin
|
||||
export { createSearchPlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
createSearchRoutes,
|
||||
type SearchRouteEvent,
|
||||
type SearchRoutesDeps,
|
||||
} from "./routes/build-routes.js";
|
||||
|
||||
// Schemas — options
|
||||
export {
|
||||
type SearchPluginOptionsInput,
|
||||
type SearchPluginOptionsResolved,
|
||||
SearchPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
|
||||
// Schemas — public Zod surface
|
||||
export {
|
||||
type BulkIndex,
|
||||
BulkIndexSchema,
|
||||
type CreateIndex,
|
||||
CreateIndexSchema,
|
||||
type Facet,
|
||||
FacetSchema,
|
||||
type FacetValue,
|
||||
FacetValueSchema,
|
||||
type IndexDocument,
|
||||
IndexDocumentSchema,
|
||||
type IndexField,
|
||||
IndexFieldSchema,
|
||||
type IndexInfo,
|
||||
IndexInfoSchema,
|
||||
type IndexListQuery,
|
||||
IndexListQuerySchema,
|
||||
IndexStatus,
|
||||
IndexStatusSchema,
|
||||
type SearchHit,
|
||||
SearchHitSchema,
|
||||
type SearchQuery,
|
||||
SearchQuerySchema,
|
||||
type SearchResult,
|
||||
SearchResultSchema,
|
||||
SearchSortOrder,
|
||||
SearchSortOrderSchema,
|
||||
type Suggestion,
|
||||
SuggestionSchema,
|
||||
type SuggestQuery,
|
||||
SuggestQuerySchema,
|
||||
type SuggestResult,
|
||||
SuggestResultSchema,
|
||||
} from "./schemas/schemas.js";
|
||||
|
||||
// Service
|
||||
export {
|
||||
bulkIndexDocuments,
|
||||
createSearchIndex,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
deleteSearchIndex,
|
||||
getSearchIndex,
|
||||
indexDocument,
|
||||
listSearchIndexes,
|
||||
removeDocument,
|
||||
type SearchServiceConfig,
|
||||
search,
|
||||
suggest,
|
||||
} from "./service/service.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
IndexedDocument,
|
||||
SearchAdapter,
|
||||
SearchEventMap,
|
||||
SearchIndex,
|
||||
SearchIndexUpdatedEvent,
|
||||
SearchPluginConfig,
|
||||
SearchQueryExecutedEvent,
|
||||
} from "./types/types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getSearchInstruments,
|
||||
resetSearchInstruments,
|
||||
type SearchInstruments,
|
||||
} from "./instruments.js";
|
||||
105
packages/api-stack/api/api-plugin-search/src/otel/instruments.md
Normal file
105
packages/api-stack/api/api-plugin-search/src/otel/instruments.md
Normal 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()`.
|
||||
@@ -0,0 +1,45 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { getSearchInstruments, resetSearchInstruments } from "./instruments.js";
|
||||
|
||||
describe("search instruments", () => {
|
||||
it("getSearchInstruments returns a cached bundle", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetSearchInstruments();
|
||||
const a = getSearchInstruments();
|
||||
const b = getSearchInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetSearchInstruments rebinds against a fresh MeterProvider", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
resetSearchInstruments();
|
||||
const first = getSearchInstruments();
|
||||
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSearchInstruments();
|
||||
|
||||
const second = getSearchInstruments();
|
||||
expect(second).not.toBe(first);
|
||||
second.queriesTotal.add(1, { outcome: "success" });
|
||||
await provider.forceFlush();
|
||||
const collected = exporter.getMetrics();
|
||||
expect(collected.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the search plugin.
|
||||
*
|
||||
* - `queriesTotal{outcome}` — incremented on every `search()` call.
|
||||
* `outcome ∈ "success" | "error"`.
|
||||
* - `queryDurationMs` — histogram of `search()` wall-clock duration in
|
||||
* milliseconds.
|
||||
* - `documentsIndexedTotal{op}` — incremented on every successful
|
||||
* document write. `op ∈ "index" | "bulk" | "remove"`.
|
||||
* - `indexesTotal{op}` — incremented on `createSearchIndex` /
|
||||
* `deleteSearchIndex`. `op ∈ "create" | "delete"`.
|
||||
* - `suggestRequestsTotal{outcome}` — incremented on every `suggest()`
|
||||
* call. `outcome ∈ "success" | "error"`.
|
||||
*/
|
||||
export interface SearchInstruments {
|
||||
readonly queriesTotal: Counter;
|
||||
readonly queryDurationMs: Histogram;
|
||||
readonly documentsIndexedTotal: Counter;
|
||||
readonly indexesTotal: Counter;
|
||||
readonly suggestRequestsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: SearchInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the search instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetSearchInstruments}
|
||||
* in test setup to force a re-bind against a freshly-installed
|
||||
* provider.
|
||||
*/
|
||||
export function getSearchInstruments(): SearchInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-search");
|
||||
cached = {
|
||||
queriesTotal: meter.createCounter("search.queries_total", {
|
||||
description: "Total search() queries, partitioned by outcome label",
|
||||
}),
|
||||
queryDurationMs: meter.createHistogram("search.query_duration_ms", {
|
||||
description: "Wall-clock duration of search() calls in ms",
|
||||
unit: "ms",
|
||||
}),
|
||||
documentsIndexedTotal: meter.createCounter(
|
||||
"search.documents_indexed_total",
|
||||
{
|
||||
description:
|
||||
"Total documents written to indexes, partitioned by op",
|
||||
}
|
||||
),
|
||||
indexesTotal: meter.createCounter("search.indexes_total", {
|
||||
description: "Total index lifecycle transitions, partitioned by op",
|
||||
}),
|
||||
suggestRequestsTotal: meter.createCounter(
|
||||
"search.suggest_requests_total",
|
||||
{
|
||||
description:
|
||||
"Total suggest() calls, partitioned by outcome label",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getSearchInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetSearchInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createSearchPlugin } from "./plugin.js";
|
||||
@@ -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,223 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
type CreatePluginContextOptions,
|
||||
createPluginContext,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import type { SearchIndex, SearchPluginConfig } from "../types/types.js";
|
||||
import { createSearchPlugin } from "./plugin.js";
|
||||
|
||||
function makeCtx(opts: { config: SearchPluginConfig; app?: OpenAPIHono }): {
|
||||
ctx: PluginContext<SearchPluginConfig>;
|
||||
emitted: Array<{ type: string; payload: unknown }>;
|
||||
app: OpenAPIHono | undefined;
|
||||
} {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const bus: EventBusLike = {
|
||||
emit: (type, payload) => {
|
||||
emitted.push({ type: type as string, payload });
|
||||
},
|
||||
on: () => () => undefined,
|
||||
};
|
||||
const ctxOpts: CreatePluginContextOptions<SearchPluginConfig> = {
|
||||
logger: {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
},
|
||||
eventBus: bus,
|
||||
signal: new AbortController().signal,
|
||||
config: opts.config,
|
||||
services: new Map(),
|
||||
};
|
||||
const ctx = createPluginContext<SearchPluginConfig>(ctxOpts);
|
||||
if (opts.app !== undefined) {
|
||||
ctx.registerService("app", opts.app);
|
||||
}
|
||||
return { ctx, emitted, app: opts.app };
|
||||
}
|
||||
|
||||
function descriptorOf(
|
||||
plugin: PluginDescriptor<SearchPluginConfig>
|
||||
): PluginDescriptor<SearchPluginConfig> {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
describe("createSearchPlugin", () => {
|
||||
it("returns a descriptor with the canonical search-plugin metadata", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = descriptorOf(createSearchPlugin());
|
||||
expect(plugin.name).toBe("search");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
expect(plugin.dependencies ?? []).toEqual([]);
|
||||
expect(plugin.optionalDependencies ?? []).toEqual([
|
||||
"database",
|
||||
"events",
|
||||
]);
|
||||
expect(plugin.tags).toEqual([
|
||||
"search",
|
||||
"fulltext",
|
||||
"facets",
|
||||
"relevance",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws SEARCH.INVALID_OPTIONS when options fail schema validation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createSearchPlugin({
|
||||
maxIndexes: 0,
|
||||
} as unknown as Partial<SearchPluginConfig>);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INVALID_OPTIONS
|
||||
);
|
||||
expect((err as SearchError).cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the resolved defaults on the descriptor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = createSearchPlugin();
|
||||
expect(plugin.defaults?.routePrefix).toBe("/search");
|
||||
expect(plugin.defaults?.maxIndexes).toBe(100);
|
||||
expect(plugin.defaults?.maxDocumentsPerIndex).toBe(100_000);
|
||||
expect(plugin.defaults?.defaultAnalyzer).toBe("standard");
|
||||
});
|
||||
|
||||
it("onInit registers the searchIndexes service", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSearchPlugin();
|
||||
const { ctx } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
app: new OpenAPIHono(),
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
const stored = ctx.getService("searchIndexes");
|
||||
expect(stored).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it("onInit emits search:initialized and onReady emits search:ready", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSearchPlugin();
|
||||
const { ctx, emitted } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
app: new OpenAPIHono(),
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
expect(emitted.some((e) => e.type === "search:initialized")).toBe(true);
|
||||
expect(emitted.some((e) => e.type === "search:ready")).toBe(true);
|
||||
});
|
||||
|
||||
it("onReady mounts routes at the configured routePrefix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSearchPlugin({ routePrefix: "/api/fts" });
|
||||
const app = new OpenAPIHono();
|
||||
const { ctx, emitted } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
app,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
await plugin.onReady?.(ctx);
|
||||
|
||||
const res = await app.request("/api/fts/indexes", {
|
||||
method: "GET",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Trigger an emission through the mounted router so the
|
||||
// `emitEvent` closure inside `runOnReady` is exercised.
|
||||
const create = await app.request("/api/fts/indexes", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "books",
|
||||
fields: [{ name: "title" }],
|
||||
analyzer: "standard",
|
||||
}),
|
||||
});
|
||||
expect(create.status).toBe(201);
|
||||
expect(emitted.some((e) => e.type === "search:index:created")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("onReady throws SEARCH.SERVICE_NOT_AVAILABLE when `app` is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSearchPlugin();
|
||||
const { ctx } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
// no app
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
try {
|
||||
await plugin.onReady?.(ctx);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws SEARCH.ROUTE_PREFIX_COLLISION when the prefix is already mounted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createSearchPlugin();
|
||||
const app = new OpenAPIHono();
|
||||
// Pre-register a sibling route at the search prefix so the
|
||||
// collision guard fires on the second mount.
|
||||
app.get("/search/preexisting", (c) => c.text("ok"));
|
||||
const { ctx } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
app,
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
try {
|
||||
await plugin.onReady?.(ctx);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("onClose empties the searchIndexes map", { timeout: 5000 }, async () => {
|
||||
const plugin = createSearchPlugin();
|
||||
const { ctx } = makeCtx({
|
||||
config: plugin.defaults as SearchPluginConfig,
|
||||
app: new OpenAPIHono(),
|
||||
});
|
||||
await plugin.onInit?.(ctx);
|
||||
const map = ctx.getService("searchIndexes") as Map<string, SearchIndex>;
|
||||
map.set("dummy", {
|
||||
name: "dummy",
|
||||
fields: [],
|
||||
analyzer: "standard",
|
||||
status: "active",
|
||||
documents: new Map(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
expect(map.size).toBe(1);
|
||||
await plugin.onClose?.(ctx);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
});
|
||||
132
packages/api-stack/api/api-plugin-search/src/plugin/plugin.ts
Normal file
132
packages/api-stack/api/api-plugin-search/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createSearchRoutes,
|
||||
type SearchRouteEvent,
|
||||
} from "../routes/build-routes.js";
|
||||
import { SearchPluginOptionsSchema } from "../schemas/options.js";
|
||||
import type { SearchIndex, SearchPluginConfig } from "../types/types.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
|
||||
/**
|
||||
* Factory — returns a typed `PluginDescriptor` for the search plugin.
|
||||
*
|
||||
* Zod-validates the supplied options at the boundary so invalid input
|
||||
* fails fast with `SearchError(SEARCH.INVALID_OPTIONS)` carrying the
|
||||
* `ZodError` as `cause`. The descriptor's `optionalDependencies`
|
||||
* lists `"database"` and `"events"` so orchestrators can topo-sort
|
||||
* the search plugin after those siblings without requiring them.
|
||||
*
|
||||
* The returned descriptor's `onInit` / `onReady` / `onClose` implement
|
||||
* the canonical search-plugin lifecycle:
|
||||
*
|
||||
* 1. `onInit` — construct the in-memory `Map<string, SearchIndex>`
|
||||
* and register it under the `searchIndexes` slot; emit
|
||||
* `search:initialized`.
|
||||
* 2. `onReady` — read the resolved config, build the route tree, and
|
||||
* mount it under the configured `routePrefix`; emit `search:ready`.
|
||||
* 3. `onClose` — clear the index map.
|
||||
*/
|
||||
export function createSearchPlugin(
|
||||
options: Partial<SearchPluginConfig> = {}
|
||||
): PluginDescriptor<SearchPluginConfig> {
|
||||
const parsed = SearchPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.INVALID_OPTIONS,
|
||||
"createSearchPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved: SearchPluginConfig = {
|
||||
routePrefix: parsed.data.routePrefix,
|
||||
maxIndexes: parsed.data.maxIndexes,
|
||||
maxDocumentsPerIndex: parsed.data.maxDocumentsPerIndex,
|
||||
defaultAnalyzer: parsed.data.defaultAnalyzer,
|
||||
};
|
||||
|
||||
const indexes = new Map<string, SearchIndex>();
|
||||
|
||||
return definePlugin<SearchPluginConfig>({
|
||||
name: "search",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"Full-text search plugin for @suaveplan/api with TF-weighted scoring, facets, filters, highlights, and four built-in analyzers.",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["database", "events"],
|
||||
tags: ["search", "fulltext", "facets", "relevance"],
|
||||
defaults: resolved,
|
||||
|
||||
onInit: async (ctx) => {
|
||||
ctx.registerService("searchIndexes", indexes);
|
||||
ctx.eventBus.emit("search:initialized", undefined);
|
||||
},
|
||||
|
||||
onReady: async (ctx) => runOnReady(ctx, resolved, indexes),
|
||||
|
||||
onClose: async () => {
|
||||
indexes.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnReady(
|
||||
ctx: PluginContext<SearchPluginConfig>,
|
||||
config: SearchPluginConfig,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): Promise<void> {
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"search plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
|
||||
// `config` was produced by `SearchPluginOptionsSchema.parse` so every
|
||||
// numeric / string field is guaranteed defined; no `??` fallbacks
|
||||
// needed.
|
||||
const serviceConfig = {
|
||||
maxIndexes: config.maxIndexes,
|
||||
maxDocumentsPerIndex: config.maxDocumentsPerIndex,
|
||||
defaultAnalyzer: config.defaultAnalyzer,
|
||||
};
|
||||
|
||||
// Guard against double-mounting under the same prefix. Hono exposes
|
||||
// `app.routes` after registration so a collision is detectable
|
||||
// before the second mount silently shadows the first.
|
||||
const existing = (
|
||||
app as unknown as { routes: ReadonlyArray<{ path: string }> }
|
||||
).routes;
|
||||
if (
|
||||
Array.isArray(existing) &&
|
||||
existing.some((r) => r.path.startsWith(`${config.routePrefix}/`))
|
||||
) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${config.routePrefix}' already has registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: config.routePrefix,
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const routes = createSearchRoutes({
|
||||
indexes,
|
||||
serviceConfig,
|
||||
emitEvent: (event: SearchRouteEvent) => {
|
||||
ctx.eventBus.emit(event.type, event.payload);
|
||||
},
|
||||
});
|
||||
|
||||
app.route(config.routePrefix, routes);
|
||||
ctx.eventBus.emit("search:ready", undefined);
|
||||
}
|
||||
@@ -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,315 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
type SearchServiceConfig,
|
||||
} from "../service/service.js";
|
||||
import type { SearchIndex } from "../types/types.js";
|
||||
import { createSearchRoutes, type SearchRouteEvent } from "./build-routes.js";
|
||||
|
||||
interface EmittedEvent {
|
||||
type: SearchRouteEvent["type"];
|
||||
payload: SearchRouteEvent["payload"];
|
||||
}
|
||||
|
||||
function makeDeps(): {
|
||||
indexes: Map<string, SearchIndex>;
|
||||
emitted: EmittedEvent[];
|
||||
app: ReturnType<typeof createSearchRoutes>;
|
||||
serviceConfig: SearchServiceConfig;
|
||||
} {
|
||||
const indexes = new Map<string, SearchIndex>();
|
||||
const emitted: EmittedEvent[] = [];
|
||||
const serviceConfig = { ...DEFAULT_SEARCH_CONFIG };
|
||||
const app = createSearchRoutes({
|
||||
indexes,
|
||||
serviceConfig,
|
||||
emitEvent: (event) => {
|
||||
emitted.push({ type: event.type, payload: event.payload });
|
||||
},
|
||||
});
|
||||
return { indexes, emitted, app, serviceConfig };
|
||||
}
|
||||
|
||||
async function jsonRequest(
|
||||
app: ReturnType<typeof createSearchRoutes>,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await app.request(path, init);
|
||||
const text = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
body: text.length > 0 ? JSON.parse(text) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createPostsIndex(
|
||||
app: ReturnType<typeof createSearchRoutes>
|
||||
): Promise<void> {
|
||||
const r = await jsonRequest(app, "POST", "/indexes", {
|
||||
name: "posts",
|
||||
fields: [
|
||||
{ name: "title", weight: 2, facet: false },
|
||||
{ name: "body", weight: 1, facet: false },
|
||||
{ name: "tag", weight: 1, facet: true },
|
||||
],
|
||||
analyzer: "standard",
|
||||
});
|
||||
if (r.status !== 201) {
|
||||
throw new Error(`Failed to create posts index: ${JSON.stringify(r)}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe("POST /query", () => {
|
||||
it("POST /query returns 200 and emits search:query:executed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
await jsonRequest(app, "POST", "/indexes/posts/documents", {
|
||||
id: "1",
|
||||
fields: { title: "Cat" },
|
||||
});
|
||||
const r = await jsonRequest(app, "POST", "/query", {
|
||||
query: "cat",
|
||||
index: "posts",
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(emitted.some((e) => e.type === "search:query:executed")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /query returns 404 when the index does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "POST", "/query", {
|
||||
query: "x",
|
||||
index: "missing",
|
||||
});
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /query returns 400 on schema rejection", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "POST", "/query", {
|
||||
query: "",
|
||||
index: "x",
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /suggest", () => {
|
||||
it("POST /suggest returns 200 with suggestions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
await jsonRequest(app, "POST", "/indexes/posts/documents", {
|
||||
id: "1",
|
||||
fields: { title: "running runner" },
|
||||
});
|
||||
const r = await jsonRequest(app, "POST", "/suggest", {
|
||||
query: "run",
|
||||
index: "posts",
|
||||
limit: 10,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("POST /suggest returns 404 for unknown index", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "POST", "/suggest", {
|
||||
query: "run",
|
||||
index: "nope",
|
||||
limit: 10,
|
||||
});
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /indexes", () => {
|
||||
it("returns the index list", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "GET", "/indexes");
|
||||
expect(r.status).toBe(200);
|
||||
const body = r.body as { items: unknown[]; total: number };
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.items.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /indexes", () => {
|
||||
it("POST /indexes creates an index and returns 201", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
expect(emitted.some((e) => e.type === "search:index:created")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 on duplicate", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "POST", "/indexes", {
|
||||
name: "posts",
|
||||
fields: [{ name: "title" }],
|
||||
analyzer: "standard",
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /indexes/:name", () => {
|
||||
it("returns 200 with details", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "GET", "/indexes/posts");
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
it("returns 404 for missing", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "GET", "/indexes/missing");
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /indexes/:name", () => {
|
||||
it("returns 200 on hit", { timeout: 5000 }, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "DELETE", "/indexes/posts");
|
||||
expect(r.status).toBe(200);
|
||||
expect(emitted.some((e) => e.type === "search:index:deleted")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
it("DELETE /indexes/:name returns 404 for an unknown index", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "DELETE", "/indexes/missing");
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /indexes/:name/documents", () => {
|
||||
it("returns 201 on success", { timeout: 5000 }, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "POST", "/indexes/posts/documents", {
|
||||
id: "1",
|
||||
fields: { title: "hello" },
|
||||
});
|
||||
expect(r.status).toBe(201);
|
||||
expect(emitted.some((e) => e.type === "search:index:updated")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 404 when the index is missing", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "POST", "/indexes/nope/documents", {
|
||||
id: "1",
|
||||
fields: {},
|
||||
});
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /indexes/:name/bulk", () => {
|
||||
it("returns 200 with indexed count", { timeout: 5000 }, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(app, "POST", "/indexes/posts/bulk", {
|
||||
index: "posts",
|
||||
documents: [
|
||||
{ id: "1", fields: { title: "a" } },
|
||||
{ id: "2", fields: { title: "b" } },
|
||||
],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const body = r.body as { indexed: number };
|
||||
expect(body.indexed).toBe(2);
|
||||
expect(
|
||||
emitted.some(
|
||||
(e) =>
|
||||
e.type === "search:index:updated" &&
|
||||
(e.payload as { operation?: string }).operation ===
|
||||
"bulk_indexed"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 for missing index", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "POST", "/indexes/nope/bulk", {
|
||||
index: "nope",
|
||||
documents: [{ id: "1", fields: {} }],
|
||||
});
|
||||
expect(r.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /indexes/:name/documents/:docId", () => {
|
||||
it("DELETE /indexes/:name/documents/:docId returns 200 and emits search:index:updated", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app, emitted } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
await jsonRequest(app, "POST", "/indexes/posts/documents", {
|
||||
id: "1",
|
||||
fields: { title: "a" },
|
||||
});
|
||||
const r = await jsonRequest(
|
||||
app,
|
||||
"DELETE",
|
||||
"/indexes/posts/documents/1"
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(
|
||||
emitted.some(
|
||||
(e) =>
|
||||
e.type === "search:index:updated" &&
|
||||
(e.payload as { operation?: string }).operation ===
|
||||
"document_removed"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 404 when doc id absent", { timeout: 5000 }, async () => {
|
||||
const { app } = makeDeps();
|
||||
await createPostsIndex(app);
|
||||
const r = await jsonRequest(
|
||||
app,
|
||||
"DELETE",
|
||||
"/indexes/posts/documents/missing"
|
||||
);
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when target index does not exist", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { app } = makeDeps();
|
||||
const r = await jsonRequest(app, "DELETE", "/indexes/nope/documents/1");
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
||||
import z from "zod";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
BulkIndexSchema,
|
||||
CreateIndexSchema,
|
||||
IndexDocumentSchema,
|
||||
IndexInfoSchema,
|
||||
IndexListQuerySchema,
|
||||
SearchQuerySchema,
|
||||
SearchResultSchema,
|
||||
SuggestQuerySchema,
|
||||
SuggestResultSchema,
|
||||
} from "../schemas/schemas.js";
|
||||
import {
|
||||
bulkIndexDocuments,
|
||||
createSearchIndex,
|
||||
deleteSearchIndex,
|
||||
getSearchIndex,
|
||||
indexDocument,
|
||||
listSearchIndexes,
|
||||
removeDocument,
|
||||
type SearchServiceConfig,
|
||||
search,
|
||||
suggest,
|
||||
} from "../service/service.js";
|
||||
import type {
|
||||
SearchIndex,
|
||||
SearchIndexUpdatedEvent,
|
||||
SearchQueryExecutedEvent,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Strongly-typed event emitter accepted by `createSearchRoutes`. The
|
||||
* payload union is the union of the search event-bus topics' payload
|
||||
* types so route handlers never `as any` through the emitter.
|
||||
*/
|
||||
export type SearchRouteEvent =
|
||||
| { type: "search:query:executed"; payload: SearchQueryExecutedEvent }
|
||||
| { type: "search:index:updated"; payload: SearchIndexUpdatedEvent }
|
||||
| { type: "search:index:created"; payload: { indexName: string } }
|
||||
| { type: "search:index:deleted"; payload: { indexName: string } };
|
||||
|
||||
export interface SearchRoutesDeps {
|
||||
indexes: Map<string, SearchIndex>;
|
||||
serviceConfig: SearchServiceConfig;
|
||||
emitEvent?: (event: SearchRouteEvent) => void;
|
||||
}
|
||||
|
||||
const ErrorBodySchema = z.object({ error: z.string() });
|
||||
|
||||
/**
|
||||
* Map a thrown `SearchError` to a normalised `(status, body)` pair. The
|
||||
* service layer throws `SearchError` with either `statusCode: 404`
|
||||
* (lookup misses) or `statusCode: 400` (validation / limit / engine
|
||||
* failures). 500-status errors come from the plugin lifecycle (e.g.
|
||||
* `SERVICE_NOT_AVAILABLE` raised in `onReady`) and never reach a route
|
||||
* handler — the routes therefore branch only on 404 vs the 400 default.
|
||||
*/
|
||||
function errorPayload(err: SearchError): {
|
||||
status: 400 | 404;
|
||||
body: { error: string };
|
||||
} {
|
||||
const ctx = err.context as { statusCode?: number } | undefined;
|
||||
if (ctx?.statusCode === 404) {
|
||||
return { status: 404, body: { error: err.message } };
|
||||
}
|
||||
return { status: 400, body: { error: err.message } };
|
||||
}
|
||||
|
||||
export function createSearchRoutes(deps: SearchRoutesDeps): OpenAPIHono {
|
||||
const app = new OpenAPIHono();
|
||||
|
||||
registerQueryRoute(app, deps);
|
||||
registerSuggestRoute(app, deps);
|
||||
registerListIndexesRoute(app, deps);
|
||||
registerCreateIndexRoute(app, deps);
|
||||
registerGetIndexRoute(app, deps);
|
||||
registerDeleteIndexRoute(app, deps);
|
||||
registerIndexDocRoute(app, deps);
|
||||
registerBulkRoute(app, deps);
|
||||
registerRemoveDocRoute(app, deps);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function registerQueryRoute(app: OpenAPIHono, deps: SearchRoutesDeps): void {
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/query",
|
||||
tags: ["search"],
|
||||
summary: "Execute a full-text search query",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: SearchQuerySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Search results",
|
||||
content: {
|
||||
"application/json": { schema: SearchResultSchema },
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid query",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
404: {
|
||||
description: "Index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const body = c.req.valid("json");
|
||||
try {
|
||||
const start = performance.now();
|
||||
const result = search(body, deps.indexes);
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
deps.emitEvent?.({
|
||||
type: "search:query:executed",
|
||||
payload: {
|
||||
indexName: body.index,
|
||||
query: body.query,
|
||||
hitCount: result.total,
|
||||
durationMs,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(result, 200);
|
||||
} catch (err) {
|
||||
const se = err as SearchError;
|
||||
const { status, body: errBody } = errorPayload(se);
|
||||
return c.json(errBody, status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerSuggestRoute(app: OpenAPIHono, deps: SearchRoutesDeps): void {
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/suggest",
|
||||
tags: ["search"],
|
||||
summary: "Get search suggestions (autocomplete)",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: SuggestQuerySchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Suggestions",
|
||||
content: {
|
||||
"application/json": { schema: SuggestResultSchema },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const body = c.req.valid("json");
|
||||
try {
|
||||
const result = suggest(body, deps.indexes);
|
||||
return c.json(result, 200);
|
||||
} catch (err) {
|
||||
return c.json(errorPayload(err as SearchError).body, 404);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// (used by /suggest above — same surface signature)
|
||||
|
||||
function registerListIndexesRoute(
|
||||
app: OpenAPIHono,
|
||||
deps: SearchRoutesDeps
|
||||
): void {
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/indexes",
|
||||
tags: ["search"],
|
||||
summary: "List search indexes",
|
||||
request: { query: IndexListQuerySchema },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Index list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
items: z.array(IndexInfoSchema),
|
||||
total: z.number(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const query = c.req.valid("query");
|
||||
const result = listSearchIndexes(deps.indexes, query);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function registerCreateIndexRoute(
|
||||
app: OpenAPIHono,
|
||||
deps: SearchRoutesDeps
|
||||
): void {
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/indexes",
|
||||
tags: ["search"],
|
||||
summary: "Create a search index",
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: CreateIndexSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Index created",
|
||||
content: { "application/json": { schema: IndexInfoSchema } },
|
||||
},
|
||||
400: {
|
||||
description: "Invalid request or index already exists",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const body = c.req.valid("json");
|
||||
try {
|
||||
createSearchIndex(body, deps.indexes, deps.serviceConfig);
|
||||
// Create succeeded ⇒ the entry is in the map by invariant.
|
||||
const info = getSearchIndex(body.name, deps.indexes) as NonNullable<
|
||||
ReturnType<typeof getSearchIndex>
|
||||
>;
|
||||
deps.emitEvent?.({
|
||||
type: "search:index:created",
|
||||
payload: { indexName: body.name },
|
||||
});
|
||||
return c.json(info, 201);
|
||||
} catch (err) {
|
||||
return c.json(errorPayload(err as SearchError).body, 400);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerGetIndexRoute(app: OpenAPIHono, deps: SearchRoutesDeps): void {
|
||||
const route = createRoute({
|
||||
method: "get",
|
||||
path: "/indexes/{name}",
|
||||
tags: ["search"],
|
||||
summary: "Get index details",
|
||||
request: { params: z.object({ name: z.string() }) },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Index details",
|
||||
content: { "application/json": { schema: IndexInfoSchema } },
|
||||
},
|
||||
404: {
|
||||
description: "Index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const { name } = c.req.valid("param");
|
||||
const info = getSearchIndex(name, deps.indexes);
|
||||
if (!info) {
|
||||
return c.json({ error: `Index "${name}" not found` }, 404);
|
||||
}
|
||||
return c.json(info, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function registerDeleteIndexRoute(
|
||||
app: OpenAPIHono,
|
||||
deps: SearchRoutesDeps
|
||||
): void {
|
||||
const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/indexes/{name}",
|
||||
tags: ["search"],
|
||||
summary: "Delete a search index",
|
||||
request: { params: z.object({ name: z.string() }) },
|
||||
responses: {
|
||||
200: {
|
||||
description: "Index deleted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ success: z.boolean() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const { name } = c.req.valid("param");
|
||||
const deleted = deleteSearchIndex(name, deps.indexes);
|
||||
if (!deleted) {
|
||||
return c.json({ error: `Index "${name}" not found` }, 404);
|
||||
}
|
||||
deps.emitEvent?.({
|
||||
type: "search:index:deleted",
|
||||
payload: { indexName: name },
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
});
|
||||
}
|
||||
|
||||
function registerIndexDocRoute(app: OpenAPIHono, deps: SearchRoutesDeps): void {
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/indexes/{name}/documents",
|
||||
tags: ["search"],
|
||||
summary: "Index a document",
|
||||
request: {
|
||||
params: z.object({ name: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: IndexDocumentSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Document indexed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
success: z.boolean(),
|
||||
id: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid document",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
404: {
|
||||
description: "Index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const { name } = c.req.valid("param");
|
||||
const doc = c.req.valid("json");
|
||||
try {
|
||||
indexDocument(name, doc, deps.indexes, deps.serviceConfig);
|
||||
deps.emitEvent?.({
|
||||
type: "search:index:updated",
|
||||
payload: {
|
||||
indexName: name,
|
||||
operation: "document_indexed",
|
||||
documentCount: 1,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
return c.json({ success: true, id: doc.id }, 201);
|
||||
} catch (err) {
|
||||
const se = err as SearchError;
|
||||
const { status, body: errBody } = errorPayload(se);
|
||||
return c.json(errBody, status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerBulkRoute(app: OpenAPIHono, deps: SearchRoutesDeps): void {
|
||||
const route = createRoute({
|
||||
method: "post",
|
||||
path: "/indexes/{name}/bulk",
|
||||
tags: ["search"],
|
||||
summary: "Bulk index documents",
|
||||
request: {
|
||||
params: z.object({ name: z.string() }),
|
||||
body: {
|
||||
content: {
|
||||
"application/json": { schema: BulkIndexSchema },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Bulk index result",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ indexed: z.number() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Bulk index error",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const { name } = c.req.valid("param");
|
||||
const body = c.req.valid("json");
|
||||
try {
|
||||
const indexed = bulkIndexDocuments(
|
||||
name,
|
||||
body.documents,
|
||||
deps.indexes,
|
||||
deps.serviceConfig
|
||||
);
|
||||
deps.emitEvent?.({
|
||||
type: "search:index:updated",
|
||||
payload: {
|
||||
indexName: name,
|
||||
operation: "bulk_indexed",
|
||||
documentCount: indexed,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
return c.json({ indexed }, 200);
|
||||
} catch (err) {
|
||||
return c.json(errorPayload(err as SearchError).body, 400);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerRemoveDocRoute(
|
||||
app: OpenAPIHono,
|
||||
deps: SearchRoutesDeps
|
||||
): void {
|
||||
const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/indexes/{name}/documents/{docId}",
|
||||
tags: ["search"],
|
||||
summary: "Remove a document from an index",
|
||||
request: {
|
||||
params: z.object({ name: z.string(), docId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Document removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({ success: z.boolean() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Document or index not found",
|
||||
content: { "application/json": { schema: ErrorBodySchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app.openapi(route, (c) => {
|
||||
const { name, docId } = c.req.valid("param");
|
||||
try {
|
||||
const removed = removeDocument(name, docId, deps.indexes);
|
||||
if (!removed) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.DOCUMENT_NOT_FOUND,
|
||||
`Document "${docId}" not found in index "${name}"`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 404,
|
||||
indexName: name,
|
||||
docId,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
deps.emitEvent?.({
|
||||
type: "search:index:updated",
|
||||
payload: {
|
||||
indexName: name,
|
||||
operation: "document_removed",
|
||||
documentCount: 1,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (err) {
|
||||
return c.json(errorPayload(err as SearchError).body, 404);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createSearchRoutes,
|
||||
type SearchRouteEvent,
|
||||
type SearchRoutesDeps,
|
||||
} from "./build-routes.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
type SearchPluginOptionsInput,
|
||||
type SearchPluginOptionsResolved,
|
||||
SearchPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type BulkIndex,
|
||||
BulkIndexSchema,
|
||||
type CreateIndex,
|
||||
CreateIndexSchema,
|
||||
type Facet,
|
||||
FacetSchema,
|
||||
type FacetValue,
|
||||
FacetValueSchema,
|
||||
type IndexDocument,
|
||||
IndexDocumentSchema,
|
||||
type IndexField,
|
||||
IndexFieldSchema,
|
||||
type IndexInfo,
|
||||
IndexInfoSchema,
|
||||
type IndexListQuery,
|
||||
IndexListQuerySchema,
|
||||
IndexStatus,
|
||||
IndexStatusSchema,
|
||||
type SearchHit,
|
||||
SearchHitSchema,
|
||||
type SearchQuery,
|
||||
SearchQuerySchema,
|
||||
type SearchResult,
|
||||
SearchResultSchema,
|
||||
SearchSortOrder,
|
||||
SearchSortOrderSchema,
|
||||
type Suggestion,
|
||||
SuggestionSchema,
|
||||
type SuggestQuery,
|
||||
SuggestQuerySchema,
|
||||
type SuggestResult,
|
||||
SuggestResultSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -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,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SearchPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("SearchPluginOptionsSchema", () => {
|
||||
it("applies defaults for an empty object", { timeout: 5000 }, () => {
|
||||
const r = SearchPluginOptionsSchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.routePrefix).toBe("/search");
|
||||
expect(r.data.maxIndexes).toBe(100);
|
||||
expect(r.data.maxDocumentsPerIndex).toBe(100_000);
|
||||
expect(r.data.defaultAnalyzer).toBe("standard");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts custom values", { timeout: 5000 }, () => {
|
||||
const r = SearchPluginOptionsSchema.safeParse({
|
||||
routePrefix: "/api/fts",
|
||||
maxIndexes: 50,
|
||||
maxDocumentsPerIndex: 10_000,
|
||||
defaultAnalyzer: "whitespace",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.routePrefix).toBe("/api/fts");
|
||||
expect(r.data.maxIndexes).toBe(50);
|
||||
expect(r.data.maxDocumentsPerIndex).toBe(10_000);
|
||||
expect(r.data.defaultAnalyzer).toBe("whitespace");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects routePrefix without a leading slash", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ routePrefix: "search" })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxIndexes below 1", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ maxIndexes: 0 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxIndexes above 10000", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ maxIndexes: 10_001 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxDocumentsPerIndex below 1", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({
|
||||
maxDocumentsPerIndex: 0,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxDocumentsPerIndex above 10000000", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({
|
||||
maxDocumentsPerIndex: 10_000_001,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty defaultAnalyzer", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ defaultAnalyzer: "" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Factory-options schema for {@link createSearchPlugin}. The factory
|
||||
* `safeParse`s its input through this schema and throws
|
||||
* `SearchError(SEARCH.INVALID_OPTIONS)` on failure.
|
||||
*/
|
||||
export const SearchPluginOptionsSchema = z.object({
|
||||
routePrefix: z.string().min(1).max(128).startsWith("/").default("/search"),
|
||||
maxIndexes: z.number().int().min(1).max(10_000).default(100),
|
||||
maxDocumentsPerIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000_000)
|
||||
.default(100_000),
|
||||
defaultAnalyzer: z.string().min(1).max(64).default("standard"),
|
||||
});
|
||||
|
||||
/** Raw input accepted by `createSearchPlugin` (every field optional). */
|
||||
export type SearchPluginOptionsInput = z.input<
|
||||
typeof SearchPluginOptionsSchema
|
||||
>;
|
||||
|
||||
/** Fully-resolved options after applying defaults. */
|
||||
export type SearchPluginOptionsResolved = z.output<
|
||||
typeof SearchPluginOptionsSchema
|
||||
>;
|
||||
112
packages/api-stack/api/api-plugin-search/src/schemas/schemas.md
Normal file
112
packages/api-stack/api/api-plugin-search/src/schemas/schemas.md
Normal 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.
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BulkIndexSchema,
|
||||
CreateIndexSchema,
|
||||
FacetSchema,
|
||||
FacetValueSchema,
|
||||
IndexDocumentSchema,
|
||||
IndexFieldSchema,
|
||||
IndexInfoSchema,
|
||||
IndexListQuerySchema,
|
||||
IndexStatus,
|
||||
IndexStatusSchema,
|
||||
SearchHitSchema,
|
||||
SearchQuerySchema,
|
||||
SearchResultSchema,
|
||||
SearchSortOrder,
|
||||
SearchSortOrderSchema,
|
||||
SuggestionSchema,
|
||||
SuggestQuerySchema,
|
||||
SuggestResultSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("enum constants", () => {
|
||||
it("SearchSortOrder lists 5 values", { timeout: 5000 }, () => {
|
||||
expect(Object.values(SearchSortOrder).sort()).toEqual(
|
||||
[
|
||||
"alpha_asc",
|
||||
"alpha_desc",
|
||||
"date_asc",
|
||||
"date_desc",
|
||||
"relevance",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("IndexStatus lists 3 values", { timeout: 5000 }, () => {
|
||||
expect(Object.values(IndexStatus).sort()).toEqual([
|
||||
"active",
|
||||
"building",
|
||||
"error",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchSortOrderSchema", () => {
|
||||
it("accepts every literal", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(SearchSortOrder)) {
|
||||
expect(SearchSortOrderSchema.safeParse(v).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects unknown sort order", { timeout: 5000 }, () => {
|
||||
expect(SearchSortOrderSchema.safeParse("nope").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexStatusSchema", () => {
|
||||
it("accepts active / building / error", { timeout: 5000 }, () => {
|
||||
expect(IndexStatusSchema.safeParse("active").success).toBe(true);
|
||||
expect(IndexStatusSchema.safeParse("building").success).toBe(true);
|
||||
expect(IndexStatusSchema.safeParse("error").success).toBe(true);
|
||||
});
|
||||
it("rejects other values", { timeout: 5000 }, () => {
|
||||
expect(IndexStatusSchema.safeParse("unknown").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchQuerySchema", () => {
|
||||
it("applies defaults for minimal input", { timeout: 5000 }, () => {
|
||||
const r = SearchQuerySchema.safeParse({ query: "x", index: "posts" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.sort).toBe("relevance");
|
||||
expect(r.data.limit).toBe(20);
|
||||
expect(r.data.offset).toBe(0);
|
||||
expect(r.data.highlight).toBe(false);
|
||||
expect(r.data.highlightTag).toBe("mark");
|
||||
}
|
||||
});
|
||||
it("rejects empty query", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchQuerySchema.safeParse({ query: "", index: "p" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects out-of-range limit", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchQuerySchema.safeParse({
|
||||
query: "x",
|
||||
index: "p",
|
||||
limit: 999,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchHitSchema", () => {
|
||||
it("accepts a hit with highlights", { timeout: 5000 }, () => {
|
||||
const r = SearchHitSchema.safeParse({
|
||||
id: "1",
|
||||
score: 0.5,
|
||||
document: { title: "t" },
|
||||
highlights: { title: ["<mark>t</mark>"] },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
it("accepts a hit without highlights", { timeout: 5000 }, () => {
|
||||
const r = SearchHitSchema.safeParse({
|
||||
id: "1",
|
||||
score: 0.5,
|
||||
document: { title: "t" },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FacetSchema + FacetValueSchema", () => {
|
||||
it("accepts a populated facet", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
FacetSchema.safeParse({
|
||||
field: "tag",
|
||||
values: [{ value: "blog", count: 3 }],
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
FacetValueSchema.safeParse({ value: "x", count: 1 }).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchResultSchema", () => {
|
||||
it("accepts a result with facets", { timeout: 5000 }, () => {
|
||||
const r = SearchResultSchema.safeParse({
|
||||
hits: [],
|
||||
total: 0,
|
||||
facets: [{ field: "tag", values: [] }],
|
||||
query: "x",
|
||||
took: 1.2,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexFieldSchema", () => {
|
||||
it("applies defaults", { timeout: 5000 }, () => {
|
||||
const r = IndexFieldSchema.safeParse({ name: "title" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.weight).toBe(1);
|
||||
expect(r.data.facet).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateIndexSchema", () => {
|
||||
it("requires at least one field", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CreateIndexSchema.safeParse({ name: "x", fields: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("applies the standard analyzer by default", { timeout: 5000 }, () => {
|
||||
const r = CreateIndexSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t" }],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.analyzer).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexInfoSchema", () => {
|
||||
it("accepts both string and Date for createdAt", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
IndexInfoSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t", weight: 1, facet: false }],
|
||||
analyzer: "standard",
|
||||
status: "active",
|
||||
documentCount: 0,
|
||||
createdAt: new Date(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
IndexInfoSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t", weight: 1, facet: false }],
|
||||
analyzer: "standard",
|
||||
status: "active",
|
||||
documentCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexListQuerySchema", () => {
|
||||
it("applies pagination defaults", { timeout: 5000 }, () => {
|
||||
const r = IndexListQuerySchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.limit).toBe(20);
|
||||
expect(r.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexDocumentSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
IndexDocumentSchema.safeParse({ id: "", fields: {} }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BulkIndexSchema", () => {
|
||||
it("rejects an empty document list", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
BulkIndexSchema.safeParse({ index: "x", documents: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects more than 1000 documents", { timeout: 5000 }, () => {
|
||||
const docs = Array.from({ length: 1001 }, (_, i) => ({
|
||||
id: String(i),
|
||||
fields: {},
|
||||
}));
|
||||
expect(
|
||||
BulkIndexSchema.safeParse({ index: "x", documents: docs }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SuggestQuerySchema + SuggestResultSchema + SuggestionSchema", () => {
|
||||
it("applies the limit default", { timeout: 5000 }, () => {
|
||||
const r = SuggestQuerySchema.safeParse({ query: "x", index: "p" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.limit).toBe(10);
|
||||
});
|
||||
it("validates suggestion + result shapes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SuggestionSchema.safeParse({ text: "x", score: 2 }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
SuggestResultSchema.safeParse({
|
||||
suggestions: [{ text: "x", score: 2 }],
|
||||
query: "x",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
159
packages/api-stack/api/api-plugin-search/src/schemas/schemas.ts
Normal file
159
packages/api-stack/api/api-plugin-search/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// schema: boundary-only — every schema in this file is consumed at the
|
||||
// HTTP boundary by `@hono/zod-openapi` route declarations in
|
||||
// `routes/build-routes.ts`. The conformance script cannot follow that
|
||||
// indirection so the schemas-at-boundaries audit suppresses warnings
|
||||
// for this module via the marker above.
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Public Zod schemas for the `@suaveplan/api-plugin-search` HTTP
|
||||
* surface. Every route handler validates its request body / params
|
||||
* through these schemas via `@hono/zod-openapi`. The inferred types
|
||||
* are re-exported from the package root for direct consumer use.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export const SearchSortOrder = {
|
||||
RELEVANCE: "relevance",
|
||||
DATE_ASC: "date_asc",
|
||||
DATE_DESC: "date_desc",
|
||||
ALPHA_ASC: "alpha_asc",
|
||||
ALPHA_DESC: "alpha_desc",
|
||||
} as const;
|
||||
|
||||
export type SearchSortOrder =
|
||||
(typeof SearchSortOrder)[keyof typeof SearchSortOrder];
|
||||
|
||||
export const IndexStatus = {
|
||||
ACTIVE: "active",
|
||||
BUILDING: "building",
|
||||
ERROR: "error",
|
||||
} as const;
|
||||
|
||||
export type IndexStatus = (typeof IndexStatus)[keyof typeof IndexStatus];
|
||||
|
||||
// ============================================================================
|
||||
// Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const SearchSortOrderSchema = z.enum([
|
||||
"relevance",
|
||||
"date_asc",
|
||||
"date_desc",
|
||||
"alpha_asc",
|
||||
"alpha_desc",
|
||||
]);
|
||||
|
||||
export const IndexStatusSchema = z.enum(["active", "building", "error"]);
|
||||
|
||||
export const SearchQuerySchema = z.object({
|
||||
query: z.string().min(1).max(1000),
|
||||
index: z.string().min(1).max(128),
|
||||
filters: z.record(z.string(), z.unknown()).optional(),
|
||||
facets: z.array(z.string().min(1)).optional(),
|
||||
sort: SearchSortOrderSchema.default("relevance"),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
highlight: z.boolean().default(false),
|
||||
highlightTag: z.string().max(32).default("mark"),
|
||||
});
|
||||
|
||||
export const SearchHitSchema = z.object({
|
||||
id: z.string(),
|
||||
score: z.number(),
|
||||
document: z.record(z.string(), z.unknown()),
|
||||
highlights: z.record(z.string(), z.array(z.string())).optional(),
|
||||
});
|
||||
|
||||
export const FacetValueSchema = z.object({
|
||||
value: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export const FacetSchema = z.object({
|
||||
field: z.string(),
|
||||
values: z.array(FacetValueSchema),
|
||||
});
|
||||
|
||||
export const SearchResultSchema = z.object({
|
||||
hits: z.array(SearchHitSchema),
|
||||
total: z.number().int(),
|
||||
facets: z.array(FacetSchema).optional(),
|
||||
query: z.string(),
|
||||
took: z.number(),
|
||||
});
|
||||
|
||||
export const IndexFieldSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
weight: z.number().min(0).max(100).default(1),
|
||||
facet: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const CreateIndexSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
fields: z.array(IndexFieldSchema).min(1),
|
||||
analyzer: z.string().max(64).default("standard"),
|
||||
});
|
||||
|
||||
export const IndexInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
fields: z.array(IndexFieldSchema),
|
||||
analyzer: z.string(),
|
||||
status: IndexStatusSchema,
|
||||
documentCount: z.number().int(),
|
||||
createdAt: z.union([z.string(), z.date()]),
|
||||
});
|
||||
|
||||
export const IndexListQuerySchema = z.object({
|
||||
status: IndexStatusSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const IndexDocumentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const BulkIndexSchema = z.object({
|
||||
index: z.string().min(1).max(128),
|
||||
documents: z.array(IndexDocumentSchema).min(1).max(1000),
|
||||
});
|
||||
|
||||
export const SuggestQuerySchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
index: z.string().min(1).max(128),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10),
|
||||
});
|
||||
|
||||
export const SuggestionSchema = z.object({
|
||||
text: z.string(),
|
||||
score: z.number(),
|
||||
});
|
||||
|
||||
export const SuggestResultSchema = z.object({
|
||||
suggestions: z.array(SuggestionSchema),
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred types
|
||||
// ============================================================================
|
||||
|
||||
export type SearchQuery = z.infer<typeof SearchQuerySchema>;
|
||||
export type SearchHit = z.infer<typeof SearchHitSchema>;
|
||||
export type FacetValue = z.infer<typeof FacetValueSchema>;
|
||||
export type Facet = z.infer<typeof FacetSchema>;
|
||||
export type SearchResult = z.infer<typeof SearchResultSchema>;
|
||||
export type IndexField = z.infer<typeof IndexFieldSchema>;
|
||||
export type CreateIndex = z.infer<typeof CreateIndexSchema>;
|
||||
export type IndexInfo = z.infer<typeof IndexInfoSchema>;
|
||||
export type IndexListQuery = z.infer<typeof IndexListQuerySchema>;
|
||||
export type IndexDocument = z.infer<typeof IndexDocumentSchema>;
|
||||
export type BulkIndex = z.infer<typeof BulkIndexSchema>;
|
||||
export type SuggestQuery = z.infer<typeof SuggestQuerySchema>;
|
||||
export type Suggestion = z.infer<typeof SuggestionSchema>;
|
||||
export type SuggestResult = z.infer<typeof SuggestResultSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
export type { SearchServiceConfig } from "./service.js";
|
||||
export {
|
||||
bulkIndexDocuments,
|
||||
createSearchIndex,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
deleteSearchIndex,
|
||||
getSearchIndex,
|
||||
indexDocument,
|
||||
listSearchIndexes,
|
||||
MAX_RESULT_WINDOW,
|
||||
removeDocument,
|
||||
search,
|
||||
suggest,
|
||||
} from "./service.js";
|
||||
137
packages/api-stack/api/api-plugin-search/src/service/service.md
Normal file
137
packages/api-stack/api/api-plugin-search/src/service/service.md
Normal 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.
|
||||
@@ -0,0 +1,852 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import type { SearchIndex } from "../types/types.js";
|
||||
import {
|
||||
bulkIndexDocuments,
|
||||
createSearchIndex,
|
||||
DEFAULT_SEARCH_CONFIG,
|
||||
deleteSearchIndex,
|
||||
getSearchIndex,
|
||||
indexDocument,
|
||||
listSearchIndexes,
|
||||
removeDocument,
|
||||
search,
|
||||
suggest,
|
||||
} from "./service.js";
|
||||
|
||||
function makeIndexes(): Map<string, SearchIndex> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function setupPosts(indexes: Map<string, SearchIndex>): void {
|
||||
createSearchIndex(
|
||||
{
|
||||
name: "posts",
|
||||
fields: [
|
||||
{ name: "title", weight: 2, facet: false },
|
||||
{ name: "body", weight: 1, facet: false },
|
||||
{ name: "tag", weight: 1, facet: true },
|
||||
],
|
||||
analyzer: "standard",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
}
|
||||
|
||||
describe("createSearchIndex", () => {
|
||||
it("createSearchIndex throws SEARCH.INDEX_ALREADY_EXISTS on duplicate name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
try {
|
||||
setupPosts(indexes);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SearchError);
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_ALREADY_EXISTS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("createSearchIndex throws SEARCH.INDEX_LIMIT_REACHED when maxIndexes is reached", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
createSearchIndex(
|
||||
{ name: "a", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes,
|
||||
{
|
||||
maxIndexes: 1,
|
||||
maxDocumentsPerIndex: 100,
|
||||
defaultAnalyzer: "standard",
|
||||
}
|
||||
);
|
||||
try {
|
||||
createSearchIndex(
|
||||
{ name: "b", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes,
|
||||
{
|
||||
maxIndexes: 1,
|
||||
maxDocumentsPerIndex: 100,
|
||||
defaultAnalyzer: "standard",
|
||||
}
|
||||
);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_LIMIT_REACHED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("createSearchIndex defaults to the standard analyzer when omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
const stored = indexes;
|
||||
createSearchIndex(
|
||||
{ name: "x", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes
|
||||
);
|
||||
const info = stored.get("x");
|
||||
expect(info?.analyzer).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteSearchIndex + getSearchIndex + listSearchIndexes", () => {
|
||||
it("deleteSearchIndex returns true/false", { timeout: 5000 }, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
expect(deleteSearchIndex("posts", indexes)).toBe(true);
|
||||
expect(deleteSearchIndex("posts", indexes)).toBe(false);
|
||||
});
|
||||
|
||||
it("getSearchIndex returns undefined for missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
expect(getSearchIndex("nope", indexes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getSearchIndex maps internal -> IndexInfo", { timeout: 5000 }, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
const info = getSearchIndex("posts", indexes);
|
||||
expect(info?.name).toBe("posts");
|
||||
expect(info?.documentCount).toBe(0);
|
||||
expect(typeof info?.createdAt).toBe("string");
|
||||
});
|
||||
|
||||
it("listSearchIndexes paginates and filters by status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
createSearchIndex(
|
||||
{ name: "a", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes
|
||||
);
|
||||
createSearchIndex(
|
||||
{ name: "b", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes
|
||||
);
|
||||
createSearchIndex(
|
||||
{ name: "c", fields: [{ name: "t" }], analyzer: "standard" },
|
||||
indexes
|
||||
);
|
||||
const entryB = indexes.get("b");
|
||||
if (entryB) entryB.status = "building";
|
||||
|
||||
const r1 = listSearchIndexes(indexes, {
|
||||
status: "active",
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
expect(r1.total).toBe(2);
|
||||
expect(r1.items.length).toBe(1);
|
||||
|
||||
const r2 = listSearchIndexes(indexes, {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(r2.total).toBe(3);
|
||||
|
||||
// default args
|
||||
const r3 = listSearchIndexes(indexes);
|
||||
expect(r3.total).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("indexDocument + bulkIndexDocuments + removeDocument", () => {
|
||||
it("indexDocument analyses every declared field", { timeout: 5000 }, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{
|
||||
id: "1",
|
||||
fields: { title: "Hello world", body: "A short body" },
|
||||
},
|
||||
indexes
|
||||
);
|
||||
const stored = indexes.get("posts")?.documents.get("1");
|
||||
expect(stored?.tokens.size).toBe(2);
|
||||
expect((stored?.tokens.get("title") ?? []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("indexDocument throws SEARCH.INDEX_NOT_FOUND for missing index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
try {
|
||||
indexDocument("nope", { id: "1", fields: {} }, indexes);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("indexDocument throws SEARCH.DOCUMENT_LIMIT_REACHED for new ids beyond the cap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument("posts", { id: "1", fields: { title: "a" } }, indexes, {
|
||||
maxIndexes: 100,
|
||||
maxDocumentsPerIndex: 1,
|
||||
defaultAnalyzer: "standard",
|
||||
});
|
||||
try {
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "2", fields: { title: "b" } },
|
||||
indexes,
|
||||
{
|
||||
maxIndexes: 100,
|
||||
maxDocumentsPerIndex: 1,
|
||||
defaultAnalyzer: "standard",
|
||||
}
|
||||
);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.DOCUMENT_LIMIT_REACHED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("indexDocument replaces tokens for existing ids without triggering the limit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "1", fields: { title: "first" } },
|
||||
indexes,
|
||||
{
|
||||
maxIndexes: 100,
|
||||
maxDocumentsPerIndex: 1,
|
||||
defaultAnalyzer: "standard",
|
||||
}
|
||||
);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "1", fields: { title: "updated" } },
|
||||
indexes,
|
||||
{
|
||||
maxIndexes: 100,
|
||||
maxDocumentsPerIndex: 1,
|
||||
defaultAnalyzer: "standard",
|
||||
}
|
||||
);
|
||||
const stored = indexes.get("posts")?.documents.get("1");
|
||||
expect(stored?.fields.title).toBe("updated");
|
||||
});
|
||||
|
||||
it("indexDocument skips null/undefined field values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{
|
||||
id: "1",
|
||||
fields: { title: "hello", body: null, tag: undefined },
|
||||
},
|
||||
indexes
|
||||
);
|
||||
const stored = indexes.get("posts")?.documents.get("1");
|
||||
expect(stored?.tokens.has("title")).toBe(true);
|
||||
expect(stored?.tokens.has("body")).toBe(false);
|
||||
expect(stored?.tokens.has("tag")).toBe(false);
|
||||
});
|
||||
|
||||
it("bulkIndexDocuments writes every document and returns count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
const n = bulkIndexDocuments(
|
||||
"posts",
|
||||
[
|
||||
{ id: "1", fields: { title: "a" } },
|
||||
{ id: "2", fields: { title: "b" } },
|
||||
],
|
||||
indexes
|
||||
);
|
||||
expect(n).toBe(2);
|
||||
expect(indexes.get("posts")?.documents.size).toBe(2);
|
||||
});
|
||||
|
||||
it("removeDocument returns true on hit, false on miss", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument("posts", { id: "1", fields: { title: "a" } }, indexes);
|
||||
expect(removeDocument("posts", "1", indexes)).toBe(true);
|
||||
expect(removeDocument("posts", "1", indexes)).toBe(false);
|
||||
});
|
||||
|
||||
it("removeDocument throws SEARCH.INDEX_NOT_FOUND for missing index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
try {
|
||||
removeDocument("nope", "1", indexes);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
function makePopulated(): Map<string, SearchIndex> {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
bulkIndexDocuments(
|
||||
"posts",
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
fields: {
|
||||
title: "Cat running",
|
||||
body: "Cats run quickly cats",
|
||||
tag: "blog",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
fields: {
|
||||
title: "Dog walking",
|
||||
body: "Dogs walk",
|
||||
tag: "blog",
|
||||
createdAt: "2026-02-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
fields: {
|
||||
title: "Bird flying high",
|
||||
body: "Birds fly",
|
||||
tag: "release",
|
||||
createdAt: "2026-03-01",
|
||||
},
|
||||
},
|
||||
],
|
||||
indexes
|
||||
);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
it("search ranks hits by TF × field weight", { timeout: 5000 }, () => {
|
||||
const indexes = makePopulated();
|
||||
const r = search(
|
||||
{
|
||||
query: "cat",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.hits.length).toBe(1);
|
||||
expect(r.hits[0]?.id).toBe("1");
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.query).toBe("cat");
|
||||
});
|
||||
|
||||
it("search applies scalar and array filters", { timeout: 5000 }, () => {
|
||||
const indexes = makePopulated();
|
||||
const scalar = search(
|
||||
{
|
||||
query: "cat",
|
||||
index: "posts",
|
||||
filters: { tag: "blog" },
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(scalar.total).toBe(1);
|
||||
|
||||
const arr = search(
|
||||
{
|
||||
query: "fly",
|
||||
index: "posts",
|
||||
filters: { tag: ["release", "blog"] },
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(arr.total).toBe(1);
|
||||
|
||||
const rejected = search(
|
||||
{
|
||||
query: "fly",
|
||||
index: "posts",
|
||||
filters: { tag: ["nope"] },
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(rejected.total).toBe(0);
|
||||
});
|
||||
|
||||
it("search sort falls back when sort field is missing from documents", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
// doc 1 has no `title` field (only body+tag); doc 2 has title;
|
||||
// doc 3 has explicit null title — exercises both `undefined`
|
||||
// and `null` arms of the comparator's coercion guard.
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "1", fields: { body: "find me", tag: "blog" } },
|
||||
indexes
|
||||
);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "2", fields: { title: "find me", tag: "blog" } },
|
||||
indexes
|
||||
);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{
|
||||
id: "3",
|
||||
fields: { title: null, body: "find me", tag: "blog" },
|
||||
},
|
||||
indexes
|
||||
);
|
||||
for (const order of ["alpha_asc", "alpha_desc"] as const) {
|
||||
const r = search(
|
||||
{
|
||||
query: "blog",
|
||||
index: "posts",
|
||||
sort: order,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.hits.length).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("search facet skips documents with null/undefined facet field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
createSearchIndex(
|
||||
{
|
||||
name: "n",
|
||||
fields: [
|
||||
{ name: "title", weight: 1, facet: false },
|
||||
{ name: "tag", weight: 1, facet: true },
|
||||
],
|
||||
analyzer: "standard",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
indexDocument(
|
||||
"n",
|
||||
{ id: "1", fields: { title: "find", tag: "blog" } },
|
||||
indexes
|
||||
);
|
||||
indexDocument(
|
||||
"n",
|
||||
{ id: "2", fields: { title: "find", tag: null } },
|
||||
indexes
|
||||
);
|
||||
const r = search(
|
||||
{
|
||||
query: "find",
|
||||
index: "n",
|
||||
facets: ["tag"],
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
// The null-tag doc contributes to total but not to the facet count.
|
||||
expect(r.total).toBe(2);
|
||||
const counts = r.facets?.[0]?.values;
|
||||
expect(counts?.find((v) => v.value === "blog")?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("search highlight skips fields whose value is null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "1", fields: { title: "find me", body: null } },
|
||||
indexes
|
||||
);
|
||||
const r = search(
|
||||
{
|
||||
query: "find",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: true,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.hits.length).toBe(1);
|
||||
// body field has no highlight entry
|
||||
expect(r.hits[0]?.highlights?.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("search honours sort order", { timeout: 5000 }, () => {
|
||||
const indexes = makePopulated();
|
||||
// each doc contains the (stemmed) token "blog" / "release" via tag;
|
||||
// query "blog" matches doc 1+2 via the tag field.
|
||||
for (const order of [
|
||||
"date_asc",
|
||||
"date_desc",
|
||||
"alpha_asc",
|
||||
"alpha_desc",
|
||||
"relevance",
|
||||
] as const) {
|
||||
const r = search(
|
||||
{
|
||||
query: "blog",
|
||||
index: "posts",
|
||||
sort: order,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.hits.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("search emits facets only for fields with facet: true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makePopulated();
|
||||
const r = search(
|
||||
{
|
||||
query: "blog",
|
||||
index: "posts",
|
||||
facets: ["tag", "title"],
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.facets?.length).toBe(1);
|
||||
expect(r.facets?.[0]?.field).toBe("tag");
|
||||
});
|
||||
|
||||
it("search facets are sorted by descending count when ≥2 distinct values exist", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
createSearchIndex(
|
||||
{
|
||||
name: "items",
|
||||
fields: [
|
||||
{ name: "title", weight: 1, facet: false },
|
||||
{ name: "tag", weight: 1, facet: true },
|
||||
],
|
||||
analyzer: "standard",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
indexDocument(
|
||||
"items",
|
||||
{ id: `a${i}`, fields: { title: "find me", tag: "a" } },
|
||||
indexes
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
indexDocument(
|
||||
"items",
|
||||
{ id: `b${i}`, fields: { title: "find me", tag: "b" } },
|
||||
indexes
|
||||
);
|
||||
}
|
||||
const r = search(
|
||||
{
|
||||
query: "find",
|
||||
index: "items",
|
||||
facets: ["tag"],
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
const facet = r.facets?.[0];
|
||||
expect(facet?.values[0]?.value).toBe("a");
|
||||
expect(facet?.values[0]?.count).toBe(3);
|
||||
expect(facet?.values[1]?.value).toBe("b");
|
||||
expect(facet?.values[1]?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("search filter rejects documents whose scalar value differs from expected", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makePopulated();
|
||||
// tag value "release" only matches doc 3; query "blog" matches docs 1+2
|
||||
// (tag === "blog"), so filtering by `{ tag: "release" }` while
|
||||
// querying "blog" leaves a single matching token-tag candidate (doc 3)
|
||||
// that fails the scalar `actual !== expected` check.
|
||||
const r = search(
|
||||
{
|
||||
query: "blog",
|
||||
index: "posts",
|
||||
filters: { tag: "release" },
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.total).toBe(0);
|
||||
});
|
||||
|
||||
it("search wraps matching words with the configured highlight tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makePopulated();
|
||||
const r = search(
|
||||
{
|
||||
query: "cat",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: true,
|
||||
highlightTag: "em",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
const h = r.hits[0]?.highlights;
|
||||
expect(h).toBeDefined();
|
||||
const titleHighlights = h?.title ?? [];
|
||||
expect(titleHighlights.some((s) => s.startsWith("<em>"))).toBe(true);
|
||||
});
|
||||
|
||||
it("search returns empty result when query analyses to zero tokens", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makePopulated();
|
||||
const r = search(
|
||||
{
|
||||
query: "the",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.hits).toEqual([]);
|
||||
expect(r.total).toBe(0);
|
||||
});
|
||||
|
||||
it("search throws SEARCH.INDEX_NOT_FOUND for an unknown index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
try {
|
||||
search(
|
||||
{
|
||||
query: "x",
|
||||
index: "nope",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("search throws SEARCH.INVALID_QUERY when offset+limit exceeds MAX_RESULT_WINDOW", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
try {
|
||||
search(
|
||||
{
|
||||
query: "x",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 200,
|
||||
offset: 9_900,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(SEARCH_CODES.INVALID_QUERY);
|
||||
}
|
||||
});
|
||||
|
||||
it("search returns empty result when no documents are indexed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
const r = search(
|
||||
{
|
||||
query: "anything",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(r.total).toBe(0);
|
||||
});
|
||||
|
||||
it("search increments queries_total and records duration", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makePopulated();
|
||||
// smoke-test that the call completes without throwing — the
|
||||
// OTel exporter is wired by the polyfills setup. Reaching this
|
||||
// point means the production instrument path executed.
|
||||
const r = search(
|
||||
{
|
||||
query: "cat",
|
||||
index: "posts",
|
||||
sort: "relevance",
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
highlight: false,
|
||||
highlightTag: "mark",
|
||||
},
|
||||
indexes
|
||||
);
|
||||
expect(typeof r.took).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggest", () => {
|
||||
it("suggest ranks prefix matches by occurrence count", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
bulkIndexDocuments(
|
||||
"posts",
|
||||
[
|
||||
{ id: "1", fields: { title: "running runner runs" } },
|
||||
{ id: "2", fields: { title: "running running" } },
|
||||
{ id: "3", fields: { title: "rain" } },
|
||||
],
|
||||
indexes
|
||||
);
|
||||
const r = suggest({ query: "run", index: "posts", limit: 10 }, indexes);
|
||||
expect(r.suggestions.length).toBeGreaterThan(0);
|
||||
expect(r.suggestions[0]?.score).toBeGreaterThanOrEqual(
|
||||
r.suggestions[1]?.score ?? 0
|
||||
);
|
||||
});
|
||||
|
||||
it("suggest returns empty result when query analyses to zero tokens", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
const r = suggest({ query: "the", index: "posts", limit: 10 }, indexes);
|
||||
expect(r.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggest throws SEARCH.INDEX_NOT_FOUND for unknown index", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const indexes = makeIndexes();
|
||||
try {
|
||||
suggest({ query: "run", index: "nope", limit: 10 }, indexes);
|
||||
expect.fail("expected SearchError");
|
||||
} catch (err) {
|
||||
expect((err as SearchError).code).toBe(
|
||||
SEARCH_CODES.INDEX_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("suggest skips null fields", { timeout: 5000 }, () => {
|
||||
const indexes = makeIndexes();
|
||||
setupPosts(indexes);
|
||||
indexDocument(
|
||||
"posts",
|
||||
{ id: "1", fields: { title: "running", body: null } },
|
||||
indexes
|
||||
);
|
||||
const r = suggest({ query: "run", index: "posts", limit: 10 }, indexes);
|
||||
expect(r.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_SEARCH_CONFIG", () => {
|
||||
it("ships the canonical defaults", { timeout: 5000 }, () => {
|
||||
expect(DEFAULT_SEARCH_CONFIG.maxIndexes).toBe(100);
|
||||
expect(DEFAULT_SEARCH_CONFIG.maxDocumentsPerIndex).toBe(100_000);
|
||||
expect(DEFAULT_SEARCH_CONFIG.defaultAnalyzer).toBe("standard");
|
||||
});
|
||||
});
|
||||
559
packages/api-stack/api/api-plugin-search/src/service/service.ts
Normal file
559
packages/api-stack/api/api-plugin-search/src/service/service.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import { getAnalyzer } from "../analyzer/analyzer.js";
|
||||
import { SEARCH_CODES, SearchError } from "../error-codes/error-codes.js";
|
||||
import { getSearchInstruments } from "../otel/instruments.js";
|
||||
import type {
|
||||
CreateIndex,
|
||||
Facet,
|
||||
IndexDocument,
|
||||
IndexField,
|
||||
IndexInfo,
|
||||
IndexListQuery,
|
||||
SearchHit,
|
||||
SearchQuery,
|
||||
SearchResult,
|
||||
Suggestion,
|
||||
SuggestQuery,
|
||||
SuggestResult,
|
||||
} from "../schemas/schemas.js";
|
||||
import type { IndexedDocument, SearchIndex } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Per-call configuration consumed by the index-write paths to enforce
|
||||
* the document/index ceilings registered on the plugin.
|
||||
*/
|
||||
export interface SearchServiceConfig {
|
||||
maxIndexes: number;
|
||||
maxDocumentsPerIndex: number;
|
||||
defaultAnalyzer: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SEARCH_CONFIG: SearchServiceConfig = {
|
||||
maxIndexes: 100,
|
||||
maxDocumentsPerIndex: 100_000,
|
||||
defaultAnalyzer: "standard",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Index management
|
||||
// ============================================================================
|
||||
|
||||
export function createSearchIndex(
|
||||
config: CreateIndex,
|
||||
indexes: Map<string, SearchIndex>,
|
||||
serviceConfig?: SearchServiceConfig
|
||||
): void {
|
||||
if (indexes.has(config.name)) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.INDEX_ALREADY_EXISTS,
|
||||
`Index "${config.name}" already exists`,
|
||||
{ context: { statusCode: 400, indexName: config.name } }
|
||||
);
|
||||
}
|
||||
|
||||
const maxIndexes =
|
||||
serviceConfig?.maxIndexes ?? DEFAULT_SEARCH_CONFIG.maxIndexes;
|
||||
if (indexes.size >= maxIndexes) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.INDEX_LIMIT_REACHED,
|
||||
`Cannot create index "${config.name}": index limit of ${maxIndexes} reached`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 400,
|
||||
indexName: config.name,
|
||||
maxIndexes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const index: SearchIndex = {
|
||||
name: config.name,
|
||||
fields: config.fields,
|
||||
// `config.analyzer` is Zod-defaulted to "standard" at the boundary.
|
||||
analyzer: config.analyzer,
|
||||
status: "active",
|
||||
documents: new Map(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
indexes.set(config.name, index);
|
||||
getSearchInstruments().indexesTotal.add(1, { op: "create" });
|
||||
}
|
||||
|
||||
export function deleteSearchIndex(
|
||||
name: string,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): boolean {
|
||||
const removed = indexes.delete(name);
|
||||
if (removed) {
|
||||
getSearchInstruments().indexesTotal.add(1, { op: "delete" });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function getSearchIndex(
|
||||
name: string,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): IndexInfo | undefined {
|
||||
const index = indexes.get(name);
|
||||
if (!index) return undefined;
|
||||
return toIndexInfo(index);
|
||||
}
|
||||
|
||||
export function listSearchIndexes(
|
||||
indexes: Map<string, SearchIndex>,
|
||||
query?: IndexListQuery
|
||||
): { items: IndexInfo[]; total: number } {
|
||||
let items = Array.from(indexes.values()).map(toIndexInfo);
|
||||
|
||||
if (query?.status) {
|
||||
items = items.filter((i) => i.status === query.status);
|
||||
}
|
||||
|
||||
const total = items.length;
|
||||
const offset = query?.offset ?? 0;
|
||||
const limit = query?.limit ?? 20;
|
||||
items = items.slice(offset, offset + limit);
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
function toIndexInfo(index: SearchIndex): IndexInfo {
|
||||
return {
|
||||
name: index.name,
|
||||
fields: index.fields,
|
||||
analyzer: index.analyzer,
|
||||
status: index.status,
|
||||
documentCount: index.documents.size,
|
||||
createdAt: index.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document indexing
|
||||
// ============================================================================
|
||||
|
||||
function requireIndex(
|
||||
indexName: string,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): SearchIndex {
|
||||
const index = indexes.get(indexName);
|
||||
if (!index) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.INDEX_NOT_FOUND,
|
||||
`Index "${indexName}" not found`,
|
||||
{ context: { statusCode: 404, indexName } }
|
||||
);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function indexDocument(
|
||||
indexName: string,
|
||||
doc: IndexDocument,
|
||||
indexes: Map<string, SearchIndex>,
|
||||
serviceConfig?: SearchServiceConfig
|
||||
): void {
|
||||
const index = requireIndex(indexName, indexes);
|
||||
|
||||
const maxDocs =
|
||||
serviceConfig?.maxDocumentsPerIndex ??
|
||||
DEFAULT_SEARCH_CONFIG.maxDocumentsPerIndex;
|
||||
|
||||
if (index.documents.size >= maxDocs && !index.documents.has(doc.id)) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.DOCUMENT_LIMIT_REACHED,
|
||||
`Index "${indexName}" has reached the maximum document limit of ${maxDocs}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 400,
|
||||
indexName,
|
||||
maxDocuments: maxDocs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const analyzer = getAnalyzer(index.analyzer);
|
||||
const tokens = new Map<string, string[]>();
|
||||
|
||||
for (const field of index.fields) {
|
||||
const value = doc.fields[field.name];
|
||||
if (value !== undefined && value !== null) {
|
||||
const text = String(value);
|
||||
tokens.set(field.name, analyzer.analyze(text));
|
||||
}
|
||||
}
|
||||
|
||||
const indexed: IndexedDocument = {
|
||||
id: doc.id,
|
||||
fields: doc.fields,
|
||||
tokens,
|
||||
};
|
||||
|
||||
index.documents.set(doc.id, indexed);
|
||||
getSearchInstruments().documentsIndexedTotal.add(1, { op: "index" });
|
||||
}
|
||||
|
||||
export function bulkIndexDocuments(
|
||||
indexName: string,
|
||||
docs: IndexDocument[],
|
||||
indexes: Map<string, SearchIndex>,
|
||||
serviceConfig?: SearchServiceConfig
|
||||
): number {
|
||||
let indexed = 0;
|
||||
for (const doc of docs) {
|
||||
indexDocument(indexName, doc, indexes, serviceConfig);
|
||||
indexed++;
|
||||
}
|
||||
getSearchInstruments().documentsIndexedTotal.add(indexed, { op: "bulk" });
|
||||
return indexed;
|
||||
}
|
||||
|
||||
export function removeDocument(
|
||||
indexName: string,
|
||||
docId: string,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): boolean {
|
||||
const index = requireIndex(indexName, indexes);
|
||||
const removed = index.documents.delete(docId);
|
||||
if (removed) {
|
||||
getSearchInstruments().documentsIndexedTotal.add(1, { op: "remove" });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search
|
||||
// ============================================================================
|
||||
|
||||
interface ScoredDoc {
|
||||
doc: IndexedDocument;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** Safety ceiling on `offset + limit` to bound result-set materialisation. */
|
||||
export const MAX_RESULT_WINDOW = 10_000;
|
||||
|
||||
export function search(
|
||||
query: SearchQuery,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): SearchResult {
|
||||
const start = performance.now();
|
||||
const instruments = getSearchInstruments();
|
||||
|
||||
try {
|
||||
if (query.offset + query.limit > MAX_RESULT_WINDOW) {
|
||||
throw new SearchError(
|
||||
SEARCH_CODES.INVALID_QUERY,
|
||||
`Result window (offset + limit) of ${query.offset + query.limit} exceeds maximum of ${MAX_RESULT_WINDOW}`,
|
||||
{
|
||||
context: {
|
||||
statusCode: 400,
|
||||
offset: query.offset,
|
||||
limit: query.limit,
|
||||
max: MAX_RESULT_WINDOW,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const index = requireIndex(query.index, indexes);
|
||||
const analyzer = getAnalyzer(index.analyzer);
|
||||
const queryTokens = analyzer.analyze(query.query);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
const took = performance.now() - start;
|
||||
instruments.queriesTotal.add(1, {
|
||||
outcome: "success",
|
||||
index: query.index,
|
||||
});
|
||||
instruments.queryDurationMs.record(took, { index: query.index });
|
||||
return {
|
||||
hits: [],
|
||||
total: 0,
|
||||
query: query.query,
|
||||
took,
|
||||
};
|
||||
}
|
||||
|
||||
const scored = scoreDocuments(index, queryTokens);
|
||||
const filtered = applyFilters(scored, query.filters);
|
||||
const sorted = sortResults(filtered, query.sort);
|
||||
const total = sorted.length;
|
||||
const paged = sorted.slice(query.offset, query.offset + query.limit);
|
||||
|
||||
const hits: SearchHit[] = paged.map((s) => {
|
||||
const hit: SearchHit = {
|
||||
id: s.doc.id,
|
||||
score: s.score,
|
||||
document: s.doc.fields,
|
||||
};
|
||||
|
||||
if (query.highlight) {
|
||||
hit.highlights = buildHighlights(
|
||||
s.doc,
|
||||
queryTokens,
|
||||
index.fields,
|
||||
query.highlightTag
|
||||
);
|
||||
}
|
||||
|
||||
return hit;
|
||||
});
|
||||
|
||||
const facets = query.facets
|
||||
? buildFacets(filtered, query.facets, index.fields)
|
||||
: undefined;
|
||||
|
||||
const took = performance.now() - start;
|
||||
instruments.queriesTotal.add(1, {
|
||||
outcome: "success",
|
||||
index: query.index,
|
||||
});
|
||||
instruments.queryDurationMs.record(took, { index: query.index });
|
||||
|
||||
const result: SearchResult = {
|
||||
hits,
|
||||
total,
|
||||
query: query.query,
|
||||
took,
|
||||
};
|
||||
if (facets !== undefined) {
|
||||
result.facets = facets;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
instruments.queriesTotal.add(1, {
|
||||
outcome: "error",
|
||||
index: query.index,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest
|
||||
// ============================================================================
|
||||
|
||||
export function suggest(
|
||||
query: SuggestQuery,
|
||||
indexes: Map<string, SearchIndex>
|
||||
): SuggestResult {
|
||||
const instruments = getSearchInstruments();
|
||||
try {
|
||||
const index = requireIndex(query.index, indexes);
|
||||
const analyzer = getAnalyzer(index.analyzer);
|
||||
const queryTokens = analyzer.analyze(query.query);
|
||||
|
||||
if (queryTokens.length === 0) {
|
||||
instruments.suggestRequestsTotal.add(1, {
|
||||
outcome: "success",
|
||||
index: query.index,
|
||||
});
|
||||
return { suggestions: [], query: query.query };
|
||||
}
|
||||
|
||||
// The empty-tokens early return above guarantees a non-empty array
|
||||
// so the last entry is always defined.
|
||||
const prefixToken = queryTokens[queryTokens.length - 1] as string;
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
for (const doc of index.documents.values()) {
|
||||
for (const field of index.fields) {
|
||||
const value = doc.fields[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const text = String(value);
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
|
||||
for (const word of words) {
|
||||
if (word.startsWith(prefixToken) && word !== prefixToken) {
|
||||
seen.set(word, (seen.get(word) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions: Suggestion[] = Array.from(seen.entries())
|
||||
.map(([text, count]) => ({ text, score: count }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, query.limit);
|
||||
|
||||
instruments.suggestRequestsTotal.add(1, {
|
||||
outcome: "success",
|
||||
index: query.index,
|
||||
});
|
||||
return { suggestions, query: query.query };
|
||||
} catch (err) {
|
||||
instruments.suggestRequestsTotal.add(1, {
|
||||
outcome: "error",
|
||||
index: query.index,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers
|
||||
// ============================================================================
|
||||
|
||||
function scoreDocuments(
|
||||
index: SearchIndex,
|
||||
queryTokens: string[]
|
||||
): ScoredDoc[] {
|
||||
const totalDocs = index.documents.size;
|
||||
if (totalDocs === 0) return [];
|
||||
|
||||
const results: ScoredDoc[] = [];
|
||||
|
||||
for (const doc of index.documents.values()) {
|
||||
let score = 0;
|
||||
|
||||
for (const field of index.fields) {
|
||||
const docTokens = doc.tokens.get(field.name);
|
||||
if (!docTokens || docTokens.length === 0) continue;
|
||||
|
||||
const weight = field.weight;
|
||||
|
||||
for (const qt of queryTokens) {
|
||||
const matchCount = docTokens.filter((t) => t === qt).length;
|
||||
if (matchCount > 0) {
|
||||
const tf = matchCount / docTokens.length;
|
||||
score += tf * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ doc, score });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function applyFilters(
|
||||
docs: ScoredDoc[],
|
||||
filters?: Record<string, unknown>
|
||||
): ScoredDoc[] {
|
||||
if (!filters || Object.keys(filters).length === 0) return docs;
|
||||
|
||||
return docs.filter((entry) => {
|
||||
for (const [key, expected] of Object.entries(filters)) {
|
||||
const actual = entry.doc.fields[key];
|
||||
if (Array.isArray(expected)) {
|
||||
if (!expected.includes(actual)) return false;
|
||||
} else if (actual !== expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortResults(docs: ScoredDoc[], sort: string): ScoredDoc[] {
|
||||
const sorted = [...docs];
|
||||
|
||||
switch (sort) {
|
||||
case "relevance":
|
||||
sorted.sort((a, b) => b.score - a.score);
|
||||
break;
|
||||
case "date_asc":
|
||||
sorted.sort((a, b) =>
|
||||
compareField(a.doc.fields, b.doc.fields, "createdAt", 1)
|
||||
);
|
||||
break;
|
||||
case "date_desc":
|
||||
sorted.sort((a, b) =>
|
||||
compareField(a.doc.fields, b.doc.fields, "createdAt", -1)
|
||||
);
|
||||
break;
|
||||
case "alpha_asc":
|
||||
sorted.sort((a, b) =>
|
||||
compareField(a.doc.fields, b.doc.fields, "title", 1)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
sorted.sort((a, b) =>
|
||||
compareField(a.doc.fields, b.doc.fields, "title", -1)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function compareField(
|
||||
a: Record<string, unknown>,
|
||||
b: Record<string, unknown>,
|
||||
field: string,
|
||||
direction: number
|
||||
): number {
|
||||
// Coerce missing fields to "" so sort stays stable across mixed schemas.
|
||||
const aRaw = a[field];
|
||||
const bRaw = b[field];
|
||||
const aVal = aRaw === undefined || aRaw === null ? "" : String(aRaw);
|
||||
const bVal = bRaw === undefined || bRaw === null ? "" : String(bRaw);
|
||||
return aVal.localeCompare(bVal) * direction;
|
||||
}
|
||||
|
||||
function buildHighlights(
|
||||
doc: IndexedDocument,
|
||||
queryTokens: string[],
|
||||
fields: IndexField[],
|
||||
tag: string
|
||||
): Record<string, string[]> {
|
||||
const highlights: Record<string, string[]> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
const value = doc.fields[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const text = String(value);
|
||||
const words = text.split(/\s+/);
|
||||
const matches: string[] = [];
|
||||
|
||||
for (const word of words) {
|
||||
const lower = word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
||||
if (queryTokens.some((qt) => lower.includes(qt))) {
|
||||
matches.push(`<${tag}>${word}</${tag}>`);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
highlights[field.name] = matches;
|
||||
}
|
||||
}
|
||||
|
||||
return highlights;
|
||||
}
|
||||
|
||||
function buildFacets(
|
||||
docs: ScoredDoc[],
|
||||
facetFields: string[],
|
||||
indexFields: IndexField[]
|
||||
): Facet[] {
|
||||
const facetableFields = new Set(
|
||||
indexFields.filter((f) => f.facet).map((f) => f.name)
|
||||
);
|
||||
|
||||
return facetFields
|
||||
.filter((f) => facetableFields.has(f))
|
||||
.map((field) => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const entry of docs) {
|
||||
const value = entry.doc.fields[field];
|
||||
if (value !== undefined && value !== null) {
|
||||
const key = String(value);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(counts.entries())
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { field, values };
|
||||
});
|
||||
}
|
||||
@@ -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,27 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `searchIndexes`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented registry
|
||||
* see `ctx.getService("searchIndexes")` resolve to
|
||||
* `Map<string, SearchIndex> | undefined` without any cast.
|
||||
*
|
||||
* Importing this module for its side effects (which the package root
|
||||
* barrel does automatically) is sufficient — every consumer of
|
||||
* `@suaveplan/api-plugin-search` gets typed `searchIndexes` access.
|
||||
*
|
||||
* The host-side slot `app` is owned by `@suaveplan/api`'s own
|
||||
* registry-augmentation module. We re-declare it here as a
|
||||
* cumulative augmentation so the search plugin's `onInit(ctx)` can
|
||||
* resolve it with full typing even when the consumer has not
|
||||
* explicitly imported `@suaveplan/api`. TypeScript merges duplicate
|
||||
* declarations.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { SearchIndex } from "../types/types.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
searchIndexes: Map<string, SearchIndex>;
|
||||
app: OpenAPIHono;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
IndexedDocument,
|
||||
SearchAdapter,
|
||||
SearchEventMap,
|
||||
SearchIndex,
|
||||
SearchIndexUpdatedEvent,
|
||||
SearchPluginConfig,
|
||||
SearchQueryExecutedEvent,
|
||||
} from "./types.js";
|
||||
88
packages/api-stack/api/api-plugin-search/src/types/types.md
Normal file
88
packages/api-stack/api/api-plugin-search/src/types/types.md
Normal 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.
|
||||
113
packages/api-stack/api/api-plugin-search/src/types/types.ts
Normal file
113
packages/api-stack/api/api-plugin-search/src/types/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type {
|
||||
CreateIndex,
|
||||
IndexDocument,
|
||||
IndexField,
|
||||
IndexInfo,
|
||||
IndexListQuery,
|
||||
IndexStatus,
|
||||
SearchQuery,
|
||||
SearchResult,
|
||||
SuggestQuery,
|
||||
SuggestResult,
|
||||
} from "../schemas/schemas.js";
|
||||
|
||||
/**
|
||||
* Per-document payload as stored inside an in-memory `SearchIndex`.
|
||||
* `tokens` holds the analyser output, keyed by field name, so the
|
||||
* scoring loop reads only pre-tokenised strings rather than
|
||||
* re-analysing on every query.
|
||||
*/
|
||||
export interface IndexedDocument {
|
||||
id: string;
|
||||
fields: Record<string, unknown>;
|
||||
tokens: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialised in-memory index. Owned by the
|
||||
* `Map<string, SearchIndex>` registered under the `searchIndexes`
|
||||
* slot on the host `ServiceRegistry`. Plugins that operate on the
|
||||
* index map (e.g. a backfill worker, a reindex CLI) reach for this
|
||||
* type to read the canonical shape.
|
||||
*/
|
||||
export interface SearchIndex {
|
||||
name: string;
|
||||
fields: IndexField[];
|
||||
analyzer: string;
|
||||
status: IndexStatus;
|
||||
documents: Map<string, IndexedDocument>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver contract for alternate search backends (SurrealDB FTS,
|
||||
* Typesense, Meilisearch). The in-memory engine is the default driver;
|
||||
* future backends ship as opt-in dependencies that conform to this
|
||||
* shape.
|
||||
*/
|
||||
export interface SearchAdapter {
|
||||
createIndex(config: CreateIndex): Promise<void>;
|
||||
deleteIndex(name: string): Promise<boolean>;
|
||||
getIndex(name: string): Promise<IndexInfo | undefined>;
|
||||
listIndexes(
|
||||
query?: IndexListQuery
|
||||
): Promise<{ items: IndexInfo[]; total: number }>;
|
||||
indexDocument(indexName: string, doc: IndexDocument): Promise<void>;
|
||||
bulkIndex(indexName: string, docs: IndexDocument[]): Promise<number>;
|
||||
removeDocument(indexName: string, docId: string): Promise<boolean>;
|
||||
search(query: SearchQuery): Promise<SearchResult>;
|
||||
suggest(query: SuggestQuery): Promise<SuggestResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved plugin config — the post-defaults shape of
|
||||
* `SearchPluginOptionsSchema`. Held inside the `PluginDescriptor`'s
|
||||
* generic so `ctx.getOwnConfig()` types correctly.
|
||||
*/
|
||||
export interface SearchPluginConfig {
|
||||
routePrefix: string;
|
||||
maxIndexes: number;
|
||||
maxDocumentsPerIndex: number;
|
||||
defaultAnalyzer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed payload — search query executed event. Emitted by the
|
||||
* `POST /query` route handler.
|
||||
*/
|
||||
export interface SearchQueryExecutedEvent {
|
||||
indexName: string;
|
||||
query: string;
|
||||
hitCount: number;
|
||||
durationMs: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed payload — search index updated event. Emitted by the document
|
||||
* write routes (`POST /indexes/:name/documents`, `POST
|
||||
* /indexes/:name/bulk`, `DELETE /indexes/:name/documents/:docId`).
|
||||
*/
|
||||
export interface SearchIndexUpdatedEvent {
|
||||
indexName: string;
|
||||
operation: "document_indexed" | "document_removed" | "bulk_indexed";
|
||||
documentCount: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed event map for the search plugin. Used by call sites that
|
||||
* accept a generic `EventBusLike<SearchEventMap>` so emissions are
|
||||
* type-checked without `as any` casts.
|
||||
*/
|
||||
export interface SearchEventMap {
|
||||
"search:initialized": undefined;
|
||||
"search:ready": undefined;
|
||||
"search:index:created": { indexName: string };
|
||||
"search:index:deleted": { indexName: string };
|
||||
"search:index:updated": SearchIndexUpdatedEvent;
|
||||
"search:document:indexed": { indexName: string; docId: string };
|
||||
"search:bulk:completed": { indexName: string; count: number };
|
||||
"search:document:removed": { indexName: string; docId: string };
|
||||
"search:query:executed": SearchQueryExecutedEvent;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-search.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
|
||||
* source module imports so the meter resolved on first call binds to
|
||||
* it. Tests that need fresh metric state per case call
|
||||
* `resetSearchInstruments()` and `flushTestMetrics()` themselves.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-search/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-search/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-search/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-search/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/api-stack/api/api-plugin-search/vite.config.ts
Normal file
37
packages/api-stack/api/api-plugin-search/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-search",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-search/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-search/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user