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