Initial Commit
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
export {
|
||||
type SearchPluginOptionsInput,
|
||||
type SearchPluginOptionsResolved,
|
||||
SearchPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type BulkIndex,
|
||||
BulkIndexSchema,
|
||||
type CreateIndex,
|
||||
CreateIndexSchema,
|
||||
type Facet,
|
||||
FacetSchema,
|
||||
type FacetValue,
|
||||
FacetValueSchema,
|
||||
type IndexDocument,
|
||||
IndexDocumentSchema,
|
||||
type IndexField,
|
||||
IndexFieldSchema,
|
||||
type IndexInfo,
|
||||
IndexInfoSchema,
|
||||
type IndexListQuery,
|
||||
IndexListQuerySchema,
|
||||
IndexStatus,
|
||||
IndexStatusSchema,
|
||||
type SearchHit,
|
||||
SearchHitSchema,
|
||||
type SearchQuery,
|
||||
SearchQuerySchema,
|
||||
type SearchResult,
|
||||
SearchResultSchema,
|
||||
SearchSortOrder,
|
||||
SearchSortOrderSchema,
|
||||
type Suggestion,
|
||||
SuggestionSchema,
|
||||
type SuggestQuery,
|
||||
SuggestQuerySchema,
|
||||
type SuggestResult,
|
||||
SuggestResultSchema,
|
||||
} from "./schemas.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# schemas/options
|
||||
|
||||
## Purpose
|
||||
|
||||
The options module defines the Zod schema for the input accepted by
|
||||
`createSearchPlugin(options?)`. The factory validates user-supplied
|
||||
options at the boundary and rejects invalid input with a typed
|
||||
`SearchError(SEARCH.INVALID_OPTIONS)` whose `cause` is the original
|
||||
`ZodError`. Keeping the schema in a dedicated module lets downstream
|
||||
consumers (orchestrators, OpenSpec-style runtime config layers) share
|
||||
the same validator without importing the plugin factory.
|
||||
|
||||
## Features
|
||||
|
||||
- Four configurable options with sensible defaults: `routePrefix`
|
||||
(`/search`), `maxIndexes` (`100`), `maxDocumentsPerIndex` (`100_000`),
|
||||
`defaultAnalyzer` (`"standard"`).
|
||||
- Hard upper bounds (`maxIndexes ≤ 10_000`, `maxDocumentsPerIndex ≤
|
||||
10_000_000`) so misconfiguration cannot accidentally exhaust process
|
||||
memory.
|
||||
- `routePrefix` enforces a leading `/` and a 128-char ceiling so route
|
||||
registration cannot silently fail.
|
||||
- Two inferred types — `SearchPluginOptionsInput` (raw input, every
|
||||
field optional) and `SearchPluginOptionsResolved` (post-defaults,
|
||||
every field required) — so callers can type-check both
|
||||
pre-validation and post-validation shapes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SearchPluginOptionsSchema } from "@suaveplan/api-plugin-search";
|
||||
|
||||
const parsed = SearchPluginOptionsSchema.safeParse({});
|
||||
if (parsed.success) {
|
||||
// parsed.data.routePrefix === "/search"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire the schema into a top-level config loader so the entire stack's
|
||||
plugin options are validated in one pass:
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
import { SearchPluginOptionsSchema } from "@suaveplan/api-plugin-search";
|
||||
|
||||
const AppConfigSchema = z.object({
|
||||
search: SearchPluginOptionsSchema.optional(),
|
||||
// ... other plugin schemas
|
||||
});
|
||||
|
||||
const config = AppConfigSchema.parse(rawConfig);
|
||||
const plugin = createSearchPlugin(config.search);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SearchPluginOptionsSchema`
|
||||
|
||||
`z.ZodObject` validating the four fields described above. Apply with
|
||||
`safeParse` to receive a discriminated success/failure result; the
|
||||
factory `createSearchPlugin` always uses `safeParse` and never throws
|
||||
the raw `ZodError`.
|
||||
|
||||
### `SearchPluginOptionsInput`
|
||||
|
||||
Type alias for `z.input<typeof SearchPluginOptionsSchema>` — every
|
||||
field optional.
|
||||
|
||||
### `SearchPluginOptionsResolved`
|
||||
|
||||
Type alias for `z.output<typeof SearchPluginOptionsSchema>` — every
|
||||
field required, defaults applied.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema deliberately omits an `enabled` flag — orchestrators
|
||||
decide whether to call `createSearchPlugin` at all rather than
|
||||
exposing a runtime disable switch. The `defaultAnalyzer` field accepts
|
||||
any non-empty string so consumers can register additional analyzers
|
||||
through the public analyzer-registry API and reference them by name.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SearchPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("SearchPluginOptionsSchema", () => {
|
||||
it("applies defaults for an empty object", { timeout: 5000 }, () => {
|
||||
const r = SearchPluginOptionsSchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.routePrefix).toBe("/search");
|
||||
expect(r.data.maxIndexes).toBe(100);
|
||||
expect(r.data.maxDocumentsPerIndex).toBe(100_000);
|
||||
expect(r.data.defaultAnalyzer).toBe("standard");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts custom values", { timeout: 5000 }, () => {
|
||||
const r = SearchPluginOptionsSchema.safeParse({
|
||||
routePrefix: "/api/fts",
|
||||
maxIndexes: 50,
|
||||
maxDocumentsPerIndex: 10_000,
|
||||
defaultAnalyzer: "whitespace",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.routePrefix).toBe("/api/fts");
|
||||
expect(r.data.maxIndexes).toBe(50);
|
||||
expect(r.data.maxDocumentsPerIndex).toBe(10_000);
|
||||
expect(r.data.defaultAnalyzer).toBe("whitespace");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects routePrefix without a leading slash", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ routePrefix: "search" })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxIndexes below 1", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ maxIndexes: 0 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxIndexes above 10000", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ maxIndexes: 10_001 }).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxDocumentsPerIndex below 1", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({
|
||||
maxDocumentsPerIndex: 0,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects maxDocumentsPerIndex above 10000000", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({
|
||||
maxDocumentsPerIndex: 10_000_001,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty defaultAnalyzer", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchPluginOptionsSchema.safeParse({ defaultAnalyzer: "" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Factory-options schema for {@link createSearchPlugin}. The factory
|
||||
* `safeParse`s its input through this schema and throws
|
||||
* `SearchError(SEARCH.INVALID_OPTIONS)` on failure.
|
||||
*/
|
||||
export const SearchPluginOptionsSchema = z.object({
|
||||
routePrefix: z.string().min(1).max(128).startsWith("/").default("/search"),
|
||||
maxIndexes: z.number().int().min(1).max(10_000).default(100),
|
||||
maxDocumentsPerIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(10_000_000)
|
||||
.default(100_000),
|
||||
defaultAnalyzer: z.string().min(1).max(64).default("standard"),
|
||||
});
|
||||
|
||||
/** Raw input accepted by `createSearchPlugin` (every field optional). */
|
||||
export type SearchPluginOptionsInput = z.input<
|
||||
typeof SearchPluginOptionsSchema
|
||||
>;
|
||||
|
||||
/** Fully-resolved options after applying defaults. */
|
||||
export type SearchPluginOptionsResolved = z.output<
|
||||
typeof SearchPluginOptionsSchema
|
||||
>;
|
||||
112
packages/api-stack/api/api-plugin-search/src/schemas/schemas.md
Normal file
112
packages/api-stack/api/api-plugin-search/src/schemas/schemas.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# schemas/schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The schemas module owns the public Zod surface of
|
||||
`@suaveplan/api-plugin-search`. Every HTTP route in the package validates
|
||||
its request body, path params, or query string against one of these
|
||||
schemas before invoking the service layer, and every response shape is
|
||||
declared here so `@hono/zod-openapi` can publish a typed `/openapi.json`
|
||||
document without per-route duplication. Inferred types are exported in
|
||||
parallel so direct programmatic consumers (e.g. another plugin building
|
||||
on top of the search service) can construct payloads with full type
|
||||
safety.
|
||||
|
||||
## Features
|
||||
|
||||
- Thirteen schemas covering the full upstream search surface — queries,
|
||||
hits, facets, sort orders, index status enums, index field
|
||||
declarations, document indexing, bulk indexing, suggest queries, and
|
||||
every response shape.
|
||||
- Two const-object enums (`SearchSortOrder`, `IndexStatus`) so consumers
|
||||
can branch on string literals without re-typing them.
|
||||
- Sensible defaults at the boundary (`sort: "relevance"`, `limit: 20`,
|
||||
`offset: 0`, `highlight: false`, `highlightTag: "mark"`,
|
||||
`analyzer: "standard"`, `weight: 1`, `facet: false`) so consumers only
|
||||
pass the fields they care about.
|
||||
- Hard upper bounds on every numeric and string field — `query` capped
|
||||
at 1000 chars, index names at 128 chars, `limit` ≤ 200, bulk docs ≤
|
||||
1000 — keeping the HTTP surface within sane resource budgets.
|
||||
- Inferred TypeScript types exported alongside each schema so call
|
||||
sites can type-check payloads at compile time without `z.infer`
|
||||
boilerplate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SearchQuerySchema,
|
||||
type SearchQuery,
|
||||
} from "@suaveplan/api-plugin-search";
|
||||
|
||||
const parsed = SearchQuerySchema.safeParse({
|
||||
query: "hello world",
|
||||
index: "posts",
|
||||
});
|
||||
if (parsed.success) {
|
||||
const q: SearchQuery = parsed.data;
|
||||
// q.sort === "relevance" by default, q.limit === 20, ...
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the schemas with `@hono/zod-openapi` to publish typed routes:
|
||||
|
||||
```ts
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import {
|
||||
SearchQuerySchema,
|
||||
SearchResultSchema,
|
||||
} from "@suaveplan/api-plugin-search";
|
||||
|
||||
export const searchRoute = createRoute({
|
||||
method: "post",
|
||||
path: "/query",
|
||||
request: {
|
||||
body: {
|
||||
content: { "application/json": { schema: SearchQuerySchema } },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Search results",
|
||||
content: { "application/json": { schema: SearchResultSchema } },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Shape |
|
||||
|---|---|
|
||||
| `SearchSortOrder` | const object + type union — `"relevance"`, `"date_asc"`, `"date_desc"`, `"alpha_asc"`, `"alpha_desc"` |
|
||||
| `IndexStatus` | const object + type union — `"active"`, `"building"`, `"error"` |
|
||||
| `SearchQuerySchema` / `SearchQuery` | request body for `POST /query` |
|
||||
| `SearchHitSchema` / `SearchHit` | one hit returned by `search()` |
|
||||
| `FacetValueSchema` / `FacetValue` | `{ value, count }` |
|
||||
| `FacetSchema` / `Facet` | `{ field, values: FacetValue[] }` |
|
||||
| `SearchResultSchema` / `SearchResult` | response shape of `POST /query` |
|
||||
| `IndexFieldSchema` / `IndexField` | per-field config in `CreateIndex` |
|
||||
| `CreateIndexSchema` / `CreateIndex` | request body for `POST /indexes` |
|
||||
| `IndexInfoSchema` / `IndexInfo` | response shape of `GET /indexes/:name` |
|
||||
| `IndexListQuerySchema` / `IndexListQuery` | query string for `GET /indexes` |
|
||||
| `IndexDocumentSchema` / `IndexDocument` | request body for indexing |
|
||||
| `BulkIndexSchema` / `BulkIndex` | request body for bulk indexing |
|
||||
| `SuggestQuerySchema` / `SuggestQuery` | request body for `POST /suggest` |
|
||||
| `SuggestionSchema` / `Suggestion` | one entry in `SuggestResult.suggestions` |
|
||||
| `SuggestResultSchema` / `SuggestResult` | response shape of `POST /suggest` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All numeric fields use `z.coerce.number()` so query-string values
|
||||
(`"20"`) parse correctly. `z.record(z.string(), z.unknown())` is used
|
||||
for free-form document fields and filter maps. The `createdAt` field
|
||||
on `IndexInfoSchema` accepts both ISO-string and `Date` so the schema
|
||||
can validate both the service-internal shape and the
|
||||
serialized-over-HTTP shape.
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
BulkIndexSchema,
|
||||
CreateIndexSchema,
|
||||
FacetSchema,
|
||||
FacetValueSchema,
|
||||
IndexDocumentSchema,
|
||||
IndexFieldSchema,
|
||||
IndexInfoSchema,
|
||||
IndexListQuerySchema,
|
||||
IndexStatus,
|
||||
IndexStatusSchema,
|
||||
SearchHitSchema,
|
||||
SearchQuerySchema,
|
||||
SearchResultSchema,
|
||||
SearchSortOrder,
|
||||
SearchSortOrderSchema,
|
||||
SuggestionSchema,
|
||||
SuggestQuerySchema,
|
||||
SuggestResultSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("enum constants", () => {
|
||||
it("SearchSortOrder lists 5 values", { timeout: 5000 }, () => {
|
||||
expect(Object.values(SearchSortOrder).sort()).toEqual(
|
||||
[
|
||||
"alpha_asc",
|
||||
"alpha_desc",
|
||||
"date_asc",
|
||||
"date_desc",
|
||||
"relevance",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("IndexStatus lists 3 values", { timeout: 5000 }, () => {
|
||||
expect(Object.values(IndexStatus).sort()).toEqual([
|
||||
"active",
|
||||
"building",
|
||||
"error",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchSortOrderSchema", () => {
|
||||
it("accepts every literal", { timeout: 5000 }, () => {
|
||||
for (const v of Object.values(SearchSortOrder)) {
|
||||
expect(SearchSortOrderSchema.safeParse(v).success).toBe(true);
|
||||
}
|
||||
});
|
||||
it("rejects unknown sort order", { timeout: 5000 }, () => {
|
||||
expect(SearchSortOrderSchema.safeParse("nope").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexStatusSchema", () => {
|
||||
it("accepts active / building / error", { timeout: 5000 }, () => {
|
||||
expect(IndexStatusSchema.safeParse("active").success).toBe(true);
|
||||
expect(IndexStatusSchema.safeParse("building").success).toBe(true);
|
||||
expect(IndexStatusSchema.safeParse("error").success).toBe(true);
|
||||
});
|
||||
it("rejects other values", { timeout: 5000 }, () => {
|
||||
expect(IndexStatusSchema.safeParse("unknown").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchQuerySchema", () => {
|
||||
it("applies defaults for minimal input", { timeout: 5000 }, () => {
|
||||
const r = SearchQuerySchema.safeParse({ query: "x", index: "posts" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.sort).toBe("relevance");
|
||||
expect(r.data.limit).toBe(20);
|
||||
expect(r.data.offset).toBe(0);
|
||||
expect(r.data.highlight).toBe(false);
|
||||
expect(r.data.highlightTag).toBe("mark");
|
||||
}
|
||||
});
|
||||
it("rejects empty query", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchQuerySchema.safeParse({ query: "", index: "p" }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects out-of-range limit", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SearchQuerySchema.safeParse({
|
||||
query: "x",
|
||||
index: "p",
|
||||
limit: 999,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchHitSchema", () => {
|
||||
it("accepts a hit with highlights", { timeout: 5000 }, () => {
|
||||
const r = SearchHitSchema.safeParse({
|
||||
id: "1",
|
||||
score: 0.5,
|
||||
document: { title: "t" },
|
||||
highlights: { title: ["<mark>t</mark>"] },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
it("accepts a hit without highlights", { timeout: 5000 }, () => {
|
||||
const r = SearchHitSchema.safeParse({
|
||||
id: "1",
|
||||
score: 0.5,
|
||||
document: { title: "t" },
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FacetSchema + FacetValueSchema", () => {
|
||||
it("accepts a populated facet", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
FacetSchema.safeParse({
|
||||
field: "tag",
|
||||
values: [{ value: "blog", count: 3 }],
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
FacetValueSchema.safeParse({ value: "x", count: 1 }).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchResultSchema", () => {
|
||||
it("accepts a result with facets", { timeout: 5000 }, () => {
|
||||
const r = SearchResultSchema.safeParse({
|
||||
hits: [],
|
||||
total: 0,
|
||||
facets: [{ field: "tag", values: [] }],
|
||||
query: "x",
|
||||
took: 1.2,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexFieldSchema", () => {
|
||||
it("applies defaults", { timeout: 5000 }, () => {
|
||||
const r = IndexFieldSchema.safeParse({ name: "title" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.weight).toBe(1);
|
||||
expect(r.data.facet).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateIndexSchema", () => {
|
||||
it("requires at least one field", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CreateIndexSchema.safeParse({ name: "x", fields: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("applies the standard analyzer by default", { timeout: 5000 }, () => {
|
||||
const r = CreateIndexSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t" }],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.analyzer).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexInfoSchema", () => {
|
||||
it("accepts both string and Date for createdAt", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
IndexInfoSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t", weight: 1, facet: false }],
|
||||
analyzer: "standard",
|
||||
status: "active",
|
||||
documentCount: 0,
|
||||
createdAt: new Date(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
IndexInfoSchema.safeParse({
|
||||
name: "x",
|
||||
fields: [{ name: "t", weight: 1, facet: false }],
|
||||
analyzer: "standard",
|
||||
status: "active",
|
||||
documentCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexListQuerySchema", () => {
|
||||
it("applies pagination defaults", { timeout: 5000 }, () => {
|
||||
const r = IndexListQuerySchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.limit).toBe(20);
|
||||
expect(r.data.offset).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("IndexDocumentSchema", () => {
|
||||
it("rejects empty id", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
IndexDocumentSchema.safeParse({ id: "", fields: {} }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BulkIndexSchema", () => {
|
||||
it("rejects an empty document list", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
BulkIndexSchema.safeParse({ index: "x", documents: [] }).success
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects more than 1000 documents", { timeout: 5000 }, () => {
|
||||
const docs = Array.from({ length: 1001 }, (_, i) => ({
|
||||
id: String(i),
|
||||
fields: {},
|
||||
}));
|
||||
expect(
|
||||
BulkIndexSchema.safeParse({ index: "x", documents: docs }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SuggestQuerySchema + SuggestResultSchema + SuggestionSchema", () => {
|
||||
it("applies the limit default", { timeout: 5000 }, () => {
|
||||
const r = SuggestQuerySchema.safeParse({ query: "x", index: "p" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.limit).toBe(10);
|
||||
});
|
||||
it("validates suggestion + result shapes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
SuggestionSchema.safeParse({ text: "x", score: 2 }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
SuggestResultSchema.safeParse({
|
||||
suggestions: [{ text: "x", score: 2 }],
|
||||
query: "x",
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
159
packages/api-stack/api/api-plugin-search/src/schemas/schemas.ts
Normal file
159
packages/api-stack/api/api-plugin-search/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
// schema: boundary-only — every schema in this file is consumed at the
|
||||
// HTTP boundary by `@hono/zod-openapi` route declarations in
|
||||
// `routes/build-routes.ts`. The conformance script cannot follow that
|
||||
// indirection so the schemas-at-boundaries audit suppresses warnings
|
||||
// for this module via the marker above.
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Public Zod schemas for the `@suaveplan/api-plugin-search` HTTP
|
||||
* surface. Every route handler validates its request body / params
|
||||
* through these schemas via `@hono/zod-openapi`. The inferred types
|
||||
* are re-exported from the package root for direct consumer use.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export const SearchSortOrder = {
|
||||
RELEVANCE: "relevance",
|
||||
DATE_ASC: "date_asc",
|
||||
DATE_DESC: "date_desc",
|
||||
ALPHA_ASC: "alpha_asc",
|
||||
ALPHA_DESC: "alpha_desc",
|
||||
} as const;
|
||||
|
||||
export type SearchSortOrder =
|
||||
(typeof SearchSortOrder)[keyof typeof SearchSortOrder];
|
||||
|
||||
export const IndexStatus = {
|
||||
ACTIVE: "active",
|
||||
BUILDING: "building",
|
||||
ERROR: "error",
|
||||
} as const;
|
||||
|
||||
export type IndexStatus = (typeof IndexStatus)[keyof typeof IndexStatus];
|
||||
|
||||
// ============================================================================
|
||||
// Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const SearchSortOrderSchema = z.enum([
|
||||
"relevance",
|
||||
"date_asc",
|
||||
"date_desc",
|
||||
"alpha_asc",
|
||||
"alpha_desc",
|
||||
]);
|
||||
|
||||
export const IndexStatusSchema = z.enum(["active", "building", "error"]);
|
||||
|
||||
export const SearchQuerySchema = z.object({
|
||||
query: z.string().min(1).max(1000),
|
||||
index: z.string().min(1).max(128),
|
||||
filters: z.record(z.string(), z.unknown()).optional(),
|
||||
facets: z.array(z.string().min(1)).optional(),
|
||||
sort: SearchSortOrderSchema.default("relevance"),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
highlight: z.boolean().default(false),
|
||||
highlightTag: z.string().max(32).default("mark"),
|
||||
});
|
||||
|
||||
export const SearchHitSchema = z.object({
|
||||
id: z.string(),
|
||||
score: z.number(),
|
||||
document: z.record(z.string(), z.unknown()),
|
||||
highlights: z.record(z.string(), z.array(z.string())).optional(),
|
||||
});
|
||||
|
||||
export const FacetValueSchema = z.object({
|
||||
value: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export const FacetSchema = z.object({
|
||||
field: z.string(),
|
||||
values: z.array(FacetValueSchema),
|
||||
});
|
||||
|
||||
export const SearchResultSchema = z.object({
|
||||
hits: z.array(SearchHitSchema),
|
||||
total: z.number().int(),
|
||||
facets: z.array(FacetSchema).optional(),
|
||||
query: z.string(),
|
||||
took: z.number(),
|
||||
});
|
||||
|
||||
export const IndexFieldSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
weight: z.number().min(0).max(100).default(1),
|
||||
facet: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const CreateIndexSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
fields: z.array(IndexFieldSchema).min(1),
|
||||
analyzer: z.string().max(64).default("standard"),
|
||||
});
|
||||
|
||||
export const IndexInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
fields: z.array(IndexFieldSchema),
|
||||
analyzer: z.string(),
|
||||
status: IndexStatusSchema,
|
||||
documentCount: z.number().int(),
|
||||
createdAt: z.union([z.string(), z.date()]),
|
||||
});
|
||||
|
||||
export const IndexListQuerySchema = z.object({
|
||||
status: IndexStatusSchema.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const IndexDocumentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const BulkIndexSchema = z.object({
|
||||
index: z.string().min(1).max(128),
|
||||
documents: z.array(IndexDocumentSchema).min(1).max(1000),
|
||||
});
|
||||
|
||||
export const SuggestQuerySchema = z.object({
|
||||
query: z.string().min(1).max(500),
|
||||
index: z.string().min(1).max(128),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10),
|
||||
});
|
||||
|
||||
export const SuggestionSchema = z.object({
|
||||
text: z.string(),
|
||||
score: z.number(),
|
||||
});
|
||||
|
||||
export const SuggestResultSchema = z.object({
|
||||
suggestions: z.array(SuggestionSchema),
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Inferred types
|
||||
// ============================================================================
|
||||
|
||||
export type SearchQuery = z.infer<typeof SearchQuerySchema>;
|
||||
export type SearchHit = z.infer<typeof SearchHitSchema>;
|
||||
export type FacetValue = z.infer<typeof FacetValueSchema>;
|
||||
export type Facet = z.infer<typeof FacetSchema>;
|
||||
export type SearchResult = z.infer<typeof SearchResultSchema>;
|
||||
export type IndexField = z.infer<typeof IndexFieldSchema>;
|
||||
export type CreateIndex = z.infer<typeof CreateIndexSchema>;
|
||||
export type IndexInfo = z.infer<typeof IndexInfoSchema>;
|
||||
export type IndexListQuery = z.infer<typeof IndexListQuerySchema>;
|
||||
export type IndexDocument = z.infer<typeof IndexDocumentSchema>;
|
||||
export type BulkIndex = z.infer<typeof BulkIndexSchema>;
|
||||
export type SuggestQuery = z.infer<typeof SuggestQuerySchema>;
|
||||
export type Suggestion = z.infer<typeof SuggestionSchema>;
|
||||
export type SuggestResult = z.infer<typeof SuggestResultSchema>;
|
||||
Reference in New Issue
Block a user