Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,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);
}
});
});

View File

@@ -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());
}

View File

@@ -0,0 +1,15 @@
export type {
AnalyzerOptions,
TextAnalyzer,
} from "./analyzer.js";
export {
createKeywordAnalyzer,
createSimpleAnalyzer,
createStandardAnalyzer,
createWhitespaceAnalyzer,
getAnalyzer,
listAnalyzers,
removeStopWords,
stem,
tokenize,
} from "./analyzer.js";

View File

@@ -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[]>;
}>();
});
});

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export type { SearchErrorCode } from "./error-codes.js";
export {
SEARCH_CODES,
SearchError,
} from "./error-codes.js";

View 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";

View File

@@ -0,0 +1,5 @@
export {
getSearchInstruments,
resetSearchInstruments,
type SearchInstruments,
} from "./instruments.js";

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { createSearchPlugin } from "./plugin.js";

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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);
}

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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);
}
});
}

View File

@@ -0,0 +1,5 @@
export {
createSearchRoutes,
type SearchRouteEvent,
type SearchRoutesDeps,
} from "./build-routes.js";

View File

@@ -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";

View File

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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
>;

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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>;

View File

@@ -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";

View File

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

View File

@@ -0,0 +1,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");
});
});

View 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 };
});
}

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

@@ -0,0 +1,9 @@
export type {
IndexedDocument,
SearchAdapter,
SearchEventMap,
SearchIndex,
SearchIndexUpdatedEvent,
SearchPluginConfig,
SearchQueryExecutedEvent,
} from "./types.js";

View File

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

View 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;
}