@pithy-sh/vector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { VectorIndexConfig } from "../config/config";
6
+ import type { VectorDocument } from "../data/document";
7
+ import type { DocumentStore } from "../data/documents";
8
+ import type { VectorAi } from "../embed/embed";
9
+ import { embedBatched } from "../embed/embed";
10
+ import { compileFilter } from "../index/filter";
11
+ import { deleteVectors, queryVectors, upsertVectors, type VectorStore, type VectorUpsert } from "../index/index";
12
+ import type { MetadataIndexDescriptor } from "../index/metadata";
13
+ import type { QueryInput, UpsertDocumentsInput, VectorDocumentInput } from "./schemas";
14
+
15
+ /**
16
+ * The vector route handlers, over an injected {@link HandlerDeps} rather than a request context — so every
17
+ * one of them is unit-tested against fakes with no Worker, no Vectorize, and no network. Vectorize has no
18
+ * local emulation, so this is the only way any of this gets a test at all.
19
+ *
20
+ * Nothing here parses a request. Shape is declared on the route line with `zValidator`, so a handler is
21
+ * handed a value that has already been validated and takes it typed — what is left in these functions is
22
+ * the ordering, the embedding, and the Vectorize ceilings, which are the parts a schema cannot state.
23
+ *
24
+ * Two orderings here are load-bearing and are the reason these are handlers rather than three lines each.
25
+ *
26
+ * **A write lands in D1 first, and the row's `model` is set only after Vectorize accepts the vector.** The
27
+ * corpus is the durable thing: Vectorize hands back ids and scores, never content, so a document that
28
+ * reached the index but not the table can never be re-embedded. Writing D1 first makes a half-failed write
29
+ * recoverable, and leaving `model` null until the vector is accepted makes it recoverable *by the default
30
+ * `pithy vector reprocess` pass*, which selects exactly the rows whose model is not the configured one.
31
+ *
32
+ * **A delete leaves the index first.** The reverse order — D1 then Vectorize — turns a failure into a vector
33
+ * that still matches queries but can never be hydrated, which surfaces as a result set that quietly shrinks.
34
+ *
35
+ * Vectorize applies writes asynchronously: `mutationId` is an acknowledgement, not a completed write.
36
+ * Nothing here reads back what it just wrote.
37
+ */
38
+
39
+ /** Everything a handler needs, resolved per request by the route layer. */
40
+ export interface HandlerDeps {
41
+ /** The document corpus in D1 — what results hydrate from and re-embeds read out of. */
42
+ documents: DocumentStore;
43
+ /** The Vectorize index this request addresses, resolved from the index's declared binding. */
44
+ store: VectorStore;
45
+ /** The Workers AI binding, for embedding text. */
46
+ ai: VectorAi;
47
+ /** The resolved config of the index named in the path. */
48
+ index: VectorIndexConfig;
49
+ /** The index's name as `pithy.config.ts` declares it — the D1 scope key and the path segment. */
50
+ indexName: string;
51
+ /** The metadata indexes this index declares, introspected once at route registration. */
52
+ filterable: readonly MetadataIndexDescriptor[];
53
+ /** Matches to return when a query names no `topK` — the capability's configured `defaultTopK`. */
54
+ defaultTopK: number;
55
+ /** Id source for a document that supplies none. */
56
+ newId(): string;
57
+ /** Clock, injected so timestamps are assertable. */
58
+ now(): Date;
59
+ }
60
+
61
+ /** What a write returns: the ids written and Vectorize's acknowledgement of the enqueued mutation. */
62
+ export interface UpsertResult {
63
+ /** The document ids written, in request order. */
64
+ ids: string[];
65
+ /** Vectorize's mutation id. The write is enqueued, not yet visible to a query. */
66
+ mutationId: string;
67
+ }
68
+
69
+ /** One match, hydrated. `document` is null when the index holds a vector the corpus no longer has. */
70
+ export interface QueryMatchResult {
71
+ /** The matched vector's id. */
72
+ id: string;
73
+ /** The similarity score, as scored by the index's metric. */
74
+ score: number;
75
+ /** The document behind the match, or null when D1 has no row for it. */
76
+ document: VectorDocument | null;
77
+ }
78
+
79
+ /** What a search returns. */
80
+ export interface QueryResult {
81
+ /** How many matches came back. */
82
+ count: number;
83
+ /** The matches, nearest first, each hydrated from the document corpus. */
84
+ matches: QueryMatchResult[];
85
+ }
86
+
87
+ /**
88
+ * Write one or many documents into an index. Text is embedded with the index's pinned model; a supplied
89
+ * `values` array is inserted as-is. Ids may be supplied to make the write idempotent.
90
+ */
91
+ export async function upsertDocuments(deps: HandlerDeps, body: UpsertDocumentsInput): Promise<UpsertResult> {
92
+ // The two accepted write shapes — a batch, or a bare document — normalized into one list.
93
+ const inputs: VectorDocumentInput[] = "documents" in body ? [...body.documents] : [body];
94
+ const at = deps.now();
95
+
96
+ // Embed every text in one call: Workers AI charges per request, and a batch keeps the vectors of one
97
+ // write in the same model invocation.
98
+ const toEmbed = inputs.filter((input) => input.text !== undefined);
99
+ const embedded =
100
+ toEmbed.length > 0
101
+ ? await embedBatched(
102
+ deps.ai,
103
+ deps.index,
104
+ toEmbed.map((input) => input.text as string),
105
+ )
106
+ : [];
107
+
108
+ const ids: string[] = [];
109
+ const documents: VectorDocument[] = [];
110
+ const vectors: VectorUpsert[] = [];
111
+ let embeddedAt = 0;
112
+
113
+ for (const input of inputs) {
114
+ const id = input.id ?? deps.newId();
115
+ const values = input.values ?? (embedded[embeddedAt++] as number[]);
116
+ const namespace = input.namespace ?? deps.index.namespace;
117
+ ids.push(id);
118
+ documents.push({
119
+ id,
120
+ indexName: deps.indexName,
121
+ namespace: namespace ?? null,
122
+ content: input.content ?? input.text ?? null,
123
+ metadata: input.metadata ?? {},
124
+ // Null until Vectorize accepts the vector — see the ordering note at the top of this file.
125
+ model: null,
126
+ createdAt: at,
127
+ updatedAt: at,
128
+ });
129
+ vectors.push({
130
+ id,
131
+ values,
132
+ ...(input.metadata ? { metadata: input.metadata } : {}),
133
+ ...(namespace ? { namespace } : {}),
134
+ });
135
+ }
136
+
137
+ await deps.documents.put(documents);
138
+ const mutationId = await upsertVectors(deps.store, deps.index, vectors);
139
+ await deps.documents.markEmbedded(deps.indexName, ids, deps.index.model, at);
140
+
141
+ return { ids, mutationId };
142
+ }
143
+
144
+ /**
145
+ * Search an index and hydrate the matches from the document corpus. Vectorize returns ids and scores;
146
+ * neither values nor metadata is requested, which both keeps the response small and raises the topK ceiling
147
+ * from 50 to 100 — the content comes from D1, where it is authoritative anyway.
148
+ */
149
+ export async function queryDocuments(deps: HandlerDeps, input: QueryInput): Promise<QueryResult> {
150
+ const vector = input.values ?? ((await embedBatched(deps.ai, deps.index, [input.text as string]))[0] as number[]);
151
+ const filter = input.filter ? compileFilter(deps.filterable, input.filter) : undefined;
152
+
153
+ const result = await queryVectors(deps.store, deps.index, vector, {
154
+ topK: input.topK ?? deps.defaultTopK,
155
+ ...(input.namespace ? { namespace: input.namespace } : {}),
156
+ ...(filter ? { filter } : {}),
157
+ });
158
+
159
+ const hydrated = await deps.documents.byIds(
160
+ deps.indexName,
161
+ result.matches.map((match) => match.id),
162
+ );
163
+ const byId = new Map(hydrated.map((document) => [document.id, document]));
164
+
165
+ return {
166
+ count: result.count,
167
+ // Order comes from Vectorize, not from D1: the score ranking is the answer, and a match the corpus has
168
+ // lost is reported as a null document rather than dropped, because a silently shorter result set is the
169
+ // failure mode this package exists to make visible.
170
+ matches: result.matches.map((match) => ({
171
+ id: match.id,
172
+ score: match.score,
173
+ document: byId.get(match.id) ?? null,
174
+ })),
175
+ };
176
+ }
177
+
178
+ /** Fetch one hydrated document. */
179
+ export async function getDocument(deps: HandlerDeps, id: string): Promise<VectorDocument> {
180
+ const document = await deps.documents.get(deps.indexName, id);
181
+ if (!document) {
182
+ throw new NotFoundError({
183
+ message: "No such document.",
184
+ action: "Check the id, or write the document first.",
185
+ detail: `no document '${id}' in index '${deps.indexName}'`,
186
+ });
187
+ }
188
+ return document;
189
+ }
190
+
191
+ /** What a delete returns. */
192
+ export interface DeleteResult {
193
+ /** The id that was removed. */
194
+ id: string;
195
+ /** Vectorize's mutation id for the enqueued delete. */
196
+ mutationId: string;
197
+ }
198
+
199
+ /** Delete a document from the index and from the corpus, in that order. */
200
+ export async function deleteDocument(deps: HandlerDeps, id: string): Promise<DeleteResult> {
201
+ // Read first, so an unknown id is a 404 rather than a mutation against nothing.
202
+ await getDocument(deps, id);
203
+ const mutationId = await deleteVectors(deps.store, [id]);
204
+ await deps.documents.remove(deps.indexName, id);
205
+ return { id, mutationId };
206
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
5
+ import type { VectorConfig } from "../config/config";
6
+ import { assertProvisionedMetadataIndexes, readProvisionRecord } from "../index/provisioned";
7
+
8
+ /**
9
+ * The boot check, as middleware: compare the filterable fields this config declares against what
10
+ * `pithy vector provision` last observed, and refuse to serve if they disagree.
11
+ *
12
+ * **Why middleware and not the `compose` hook.** `compose` is the obvious home — it fires once when
13
+ * `createBackend` assembles the backend — but it is handed the composed capabilities and nothing else,
14
+ * because in Workers there *is* no `env` at assembly. Bindings and vars arrive per request. The record lives
15
+ * in a var, so the earliest moment it can be read is the first request. That is exactly why core's own
16
+ * `validateBindings` runs there too, memoized, rather than at module load: "in Workers `env` is per-request,
17
+ * so there is no env to check until a request arrives." First request is what boot means here.
18
+ *
19
+ * **It gates every route, not just the vector ones.** Same precedent, same reasoning: a missing binding fails
20
+ * the whole app rather than the capability that needed it, because a Worker deployed against the wrong
21
+ * configuration should be unmistakably broken, not subtly wrong. `GET /health` is registered before any
22
+ * capability middleware and answers without reaching this, so an orchestrator's health probe still gets a
23
+ * response — the deploy fails on real traffic, where a human is reading the error.
24
+ *
25
+ * The result is memoized only on success. A failure leaves the check armed, so it re-runs and re-throws on
26
+ * the next request instead of collapsing into a bare 500 with no explanation.
27
+ */
28
+ export function provisionGuard(config: VectorConfig): PithyMiddleware {
29
+ let verified = false;
30
+ return (app) => {
31
+ app.use("*", async (c, next) => {
32
+ if (!verified) {
33
+ assertProvisionedMetadataIndexes(config, readProvisionRecord(c.env as Record<string, unknown>));
34
+ verified = true;
35
+ }
36
+ await next();
37
+ });
38
+ };
39
+ }
@@ -0,0 +1,162 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { zValidator } from "@hono/zod-validator";
6
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
9
+ import type { Context, Hono } from "hono";
10
+ import type { VectorConfig, VectorIndexConfig } from "../config/config";
11
+ import { vectorDocuments } from "../data/documents";
12
+ import { vectorDatabase } from "../data/tables";
13
+ import type { VectorAi } from "../embed/embed";
14
+ import { VectorIndexNotFoundError } from "../error/errors";
15
+ import type { VectorStore } from "../index/index";
16
+ import { type MetadataIndexDescriptor, metadataIndexes } from "../index/metadata";
17
+ import { requireAuth } from "./guard";
18
+ import { deleteDocument, getDocument, type HandlerDeps, queryDocuments, upsertDocuments } from "./handlers";
19
+ import { QueryInput, UpsertDocumentsInput, VectorDocumentParams, VectorIndexParams } from "./schemas";
20
+
21
+ /**
22
+ * The vector routes, what each accepts, and their declared verification strategies — every one of them
23
+ * `bearer | session`:
24
+ *
25
+ * POST /vector/:index/documents → write one or many param: VectorIndexParams, json: UpsertDocumentsInput
26
+ * POST /vector/:index/query → search param: VectorIndexParams, json: QueryInput
27
+ * GET /vector/:index/documents/:id → fetch one hydrated param: VectorDocumentParams
28
+ * DELETE /vector/:index/documents/:id → delete from both param: VectorDocumentParams
29
+ *
30
+ * Every route is gated by {@link requireAuth} — there is no public vector surface. A corpus is content the
31
+ * adopter owns, and an unauthenticated search endpoint is an exfiltration endpoint. `requireAuth` is copied
32
+ * into this package rather than imported from `@pithy-sh/auth`, so a project with no auth capability
33
+ * composed denies every route instead of serving them open.
34
+ *
35
+ * The validators sit **after** the guard, deliberately: an unauthenticated request carrying a malformed body
36
+ * is a 401, not a 400 — shape is never answered for a caller who has not been verified.
37
+ *
38
+ * `:index` names an index in `pithy.config.ts`, never a Cloudflare resource — an unknown one is a 404 from
39
+ * config alone, before any binding is touched. The param schema bounds the segment's *shape* only; the name
40
+ * is still resolved in the dep resolver, so which indexes exist stays unprobeable. Each index's metadata
41
+ * schema is introspected **once**, here at registration, rather than per request: the descriptors are what
42
+ * the filter compiler checks against, and they change only when the config does.
43
+ */
44
+
45
+ /** The bindings the vector routes read off the request env. */
46
+ export interface VectorRoutesEnv {
47
+ /** The app database the document corpus lives in. */
48
+ DB: D1Database;
49
+ /** The Workers AI binding used to embed text. */
50
+ AI: VectorAi;
51
+ }
52
+
53
+ export interface VectorRoutesOptions {
54
+ /** The resolved vector config. */
55
+ config: VectorConfig;
56
+ /** The path the routes mount under. Defaults to `/vector`. */
57
+ basePath?: string;
58
+ /** Test seam: resolve handler deps for one index from the request context. Defaults to the env resolver. */
59
+ resolveDeps?: (c: Context<PithyHonoEnv>, indexName: string) => HandlerDeps;
60
+ }
61
+
62
+ /** Read a binding by name, throwing rather than returning undefined — a missing binding is a config fault. */
63
+ function binding<T>(c: Context<PithyHonoEnv>, name: string, action: string): T {
64
+ const value = (c.env as Record<string, unknown>)[name] as T | undefined;
65
+ if (!value) {
66
+ throw new InternalError({
67
+ message: "Search is not configured.",
68
+ action,
69
+ detail: `the \`${name}\` binding was not present on env`,
70
+ });
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** The config for a named index, or a 404 — index names come from config, so an unknown one is not found. */
76
+ function indexConfig(config: VectorConfig, name: string): VectorIndexConfig {
77
+ const index = config.indexes[name];
78
+ if (!index) {
79
+ throw new VectorIndexNotFoundError({
80
+ detail: `no index '${name}' in config; declared: ${Object.keys(config.indexes).join(", ") || "none"}`,
81
+ });
82
+ }
83
+ return index;
84
+ }
85
+
86
+ /** Register the vector sub-router. Returned as the capability's `routes` hook. */
87
+ export function registerVectorRoutes(options: VectorRoutesOptions): (app: Hono<PithyHonoEnv>) => void {
88
+ const base = options.basePath ?? "/vector";
89
+ const { config } = options;
90
+
91
+ // Introspect every index's metadata once. `metadataIndexes` throws on an unprovisionable schema, and doing
92
+ // it here means that failure lands at assembly rather than on a caller's first filtered search.
93
+ const filterable = new Map<string, MetadataIndexDescriptor[]>();
94
+ for (const [name, index] of Object.entries(config.indexes)) {
95
+ filterable.set(name, index.metadata ? metadataIndexes(index.metadata) : []);
96
+ }
97
+
98
+ const resolve =
99
+ options.resolveDeps ??
100
+ ((c: Context<PithyHonoEnv>, indexName: string): HandlerDeps => {
101
+ const index = indexConfig(config, indexName);
102
+ return {
103
+ documents: vectorDocuments(vectorDatabase(binding<D1Database>(c, "DB", "Bind a D1 database named DB."))),
104
+ store: binding<VectorStore>(
105
+ c,
106
+ index.binding,
107
+ `Bind the Vectorize index as \`${index.binding}\` in wrangler.jsonc, then run \`pithy vector provision\`.`,
108
+ ),
109
+ ai: binding<VectorAi>(c, "AI", "Bind Workers AI as `AI` in wrangler.jsonc."),
110
+ index,
111
+ indexName,
112
+ filterable: filterable.get(indexName) ?? [],
113
+ defaultTopK: config.defaultTopK,
114
+ newId: () => crypto.randomUUID(),
115
+ now: () => new Date(),
116
+ };
117
+ });
118
+
119
+ return (app) => {
120
+ app.post(
121
+ `${base}/:index/documents`,
122
+ requireAuth(),
123
+ zValidator("param", VectorIndexParams, validationHook),
124
+ zValidator("json", UpsertDocumentsInput, validationHook),
125
+ async (c) => {
126
+ const deps = resolve(c, c.req.valid("param").index);
127
+ return c.json(await upsertDocuments(deps, c.req.valid("json")), 201);
128
+ },
129
+ );
130
+
131
+ app.post(
132
+ `${base}/:index/query`,
133
+ requireAuth(),
134
+ zValidator("param", VectorIndexParams, validationHook),
135
+ zValidator("json", QueryInput, validationHook),
136
+ async (c) => {
137
+ const deps = resolve(c, c.req.valid("param").index);
138
+ return c.json(await queryDocuments(deps, c.req.valid("json")));
139
+ },
140
+ );
141
+
142
+ app.get(
143
+ `${base}/:index/documents/:id`,
144
+ requireAuth(),
145
+ zValidator("param", VectorDocumentParams, validationHook),
146
+ async (c) => {
147
+ const params = c.req.valid("param");
148
+ return c.json(await getDocument(resolve(c, params.index), params.id));
149
+ },
150
+ );
151
+
152
+ app.delete(
153
+ `${base}/:index/documents/:id`,
154
+ requireAuth(),
155
+ zValidator("param", VectorDocumentParams, validationHook),
156
+ async (c) => {
157
+ const params = c.req.valid("param");
158
+ return c.json(await deleteDocument(resolve(c, params.index), params.id));
159
+ },
160
+ );
161
+ };
162
+ }
@@ -0,0 +1,158 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MAX_NAME_BYTES, MAX_TOPK_WITHOUT_PAYLOAD, MAX_UPSERT_BATCH, MAX_VECTOR_ID_BYTES } from "../index/limits";
6
+
7
+ /**
8
+ * The request schemas for the vector routes. Each is declared on the route line with
9
+ * `zValidator(target, Schema, validationHook)`, so a malformed request is a `validation/invalid_input` 400
10
+ * before any handler runs (CLAUDE.md §Zod). They carry only the shape rules — the Vectorize ceilings
11
+ * (dimensions, id bytes, metadata size, filter size) are enforced once, in `index/index.ts`, so the numbers
12
+ * cannot disagree between two files.
13
+ *
14
+ * The param schemas are **shape** checks, not existence checks. Resolving `:index` against the configured
15
+ * indexes stays where it was, in the route's dep resolver, and an index the config does not declare is still
16
+ * a `vector/index_not_found` 404 — never a 400 that would turn the set of configured index names into
17
+ * something a caller can probe for.
18
+ *
19
+ * Every write and every query accepts **either** `text` — embedded here with the index's pinned model — or a
20
+ * precomputed `values` array. Both, or neither, is refused: a caller who sends both has two sources of truth
21
+ * for one vector, and nothing downstream could say which one the index holds.
22
+ */
23
+
24
+ /** An index name is a path segment and a Cloudflare resource name, so it is lowercase, digits, and dashes. */
25
+ const INDEX_NAME = /^[a-z0-9][a-z0-9-]*$/;
26
+
27
+ /**
28
+ * The `:index` segment. Bounded to exactly what `VectorConfig` accepts as a key — a name outside this shape
29
+ * cannot be configured, so it could never have resolved, and rejecting it here narrows nothing.
30
+ */
31
+ const IndexSegment = z
32
+ .string()
33
+ .max(MAX_NAME_BYTES)
34
+ .regex(INDEX_NAME, "An index name is lowercase, digits, and dashes — it is a path segment.")
35
+ .describe(
36
+ "The index this request addresses, as `pithy.config.ts` names it. Resolved against the configured indexes in the route, where an unknown name is a `vector/index_not_found` 404.",
37
+ );
38
+
39
+ /** The path params of a route that names an index and nothing else. */
40
+ export const VectorIndexParams = z
41
+ .object({ index: IndexSegment })
42
+ .describe("The path params of an index-scoped vector route.");
43
+ export type VectorIndexParams = z.output<typeof VectorIndexParams>;
44
+
45
+ /** The path params of a route that addresses one document inside an index. */
46
+ export const VectorDocumentParams = z
47
+ .object({
48
+ index: IndexSegment,
49
+ id: z
50
+ .string()
51
+ .min(1)
52
+ .max(MAX_VECTOR_ID_BYTES)
53
+ .describe(
54
+ `The document's id, which is also its vector id. Opaque to this capability — bounded, not parsed. Vectorize refuses an id over ${MAX_VECTOR_ID_BYTES} bytes at write time, so a longer one could never name a document.`,
55
+ ),
56
+ })
57
+ .describe("The path params of a route addressing one document in one index.");
58
+ export type VectorDocumentParams = z.output<typeof VectorDocumentParams>;
59
+
60
+ /** Why a payload's vector source is unusable, or null. Both bodies take text **or** values, never both. */
61
+ function sourceProblem(value: { text?: unknown; values?: unknown }): string | null {
62
+ const hasText = value.text !== undefined;
63
+ const hasValues = value.values !== undefined;
64
+ if (hasText && hasValues) return "Send `text` or `values`, not both — two sources for one vector.";
65
+ if (!hasText && !hasValues) return "Send `text` to embed, or `values` for a precomputed embedding.";
66
+ return null;
67
+ }
68
+
69
+ /** One document on its way into an index. */
70
+ export const VectorDocumentInput = z
71
+ .object({
72
+ id: z
73
+ .string()
74
+ .min(1)
75
+ .optional()
76
+ .describe(
77
+ "The document's id, which is also its vector id. Supply your own to make a write idempotent — the same id replaces rather than duplicates, within this index. The same id in another index is a different document. Omitted means one is generated.",
78
+ ),
79
+ text: z
80
+ .string()
81
+ .min(1)
82
+ .optional()
83
+ .describe("The text to embed with this index's pinned model. Stored as the document's content."),
84
+ values: z
85
+ .array(z.number())
86
+ .optional()
87
+ .describe("A precomputed embedding, inserted as-is. Must be exactly the index's dimensions long."),
88
+ content: z
89
+ .string()
90
+ .optional()
91
+ .describe("The text to store as this document's content, when it differs from what was embedded."),
92
+ metadata: z
93
+ .record(z.string(), z.unknown())
94
+ .optional()
95
+ .describe("The metadata this vector carries. Fields marked filterable in the index's schema are queryable."),
96
+ namespace: z
97
+ .string()
98
+ .min(1)
99
+ .optional()
100
+ .describe("The namespace this vector belongs to. Falls back to the index's configured namespace."),
101
+ })
102
+ .check((ctx) => {
103
+ const problem = sourceProblem(ctx.value);
104
+ if (problem) ctx.issues.push({ code: "custom", input: ctx.value, path: ["text"], message: problem });
105
+ })
106
+ .describe("One document to write into an index: what to embed (or an embedding), plus its metadata.");
107
+ export type VectorDocumentInput = z.output<typeof VectorDocumentInput>;
108
+
109
+ /** A batch write. The array form; a bare document is accepted too and normalized into this shape. */
110
+ export const VectorDocumentBatch = z
111
+ .object({
112
+ documents: z
113
+ .array(VectorDocumentInput)
114
+ .min(1)
115
+ .max(MAX_UPSERT_BATCH)
116
+ .describe(
117
+ `The documents to write, at most ${MAX_UPSERT_BATCH} — the ceiling the Vectorize binding accepts in one call.`,
118
+ ),
119
+ })
120
+ .describe("A batch of documents to write into an index in one call.");
121
+ export type VectorDocumentBatch = z.output<typeof VectorDocumentBatch>;
122
+
123
+ /** The write body: one document, or a batch. Both land in the same handler path. */
124
+ export const UpsertDocumentsInput = z
125
+ .union([VectorDocumentBatch, VectorDocumentInput])
126
+ .describe("The body of a document write: either a `documents` array or a single document object.");
127
+ export type UpsertDocumentsInput = z.output<typeof UpsertDocumentsInput>;
128
+
129
+ /** A search. */
130
+ export const QueryInput = z
131
+ .object({
132
+ text: z.string().min(1).optional().describe("The query text, embedded with this index's pinned model."),
133
+ values: z.array(z.number()).optional().describe("A precomputed query vector, used as-is."),
134
+ topK: z
135
+ .number()
136
+ .int()
137
+ .min(1)
138
+ .max(MAX_TOPK_WITHOUT_PAYLOAD)
139
+ .optional()
140
+ .describe("How many matches to return. Defaults to the capability's configured `defaultTopK`."),
141
+ namespace: z
142
+ .string()
143
+ .min(1)
144
+ .optional()
145
+ .describe("Restrict the search to one namespace. Falls back to the index's configured namespace."),
146
+ filter: z
147
+ .record(z.string(), z.unknown())
148
+ .optional()
149
+ .describe(
150
+ "A metadata filter over the index's filterable fields. A field with no metadata index is refused rather than silently ignored — filtering on one returns partial results with no error.",
151
+ ),
152
+ })
153
+ .check((ctx) => {
154
+ const problem = sourceProblem(ctx.value);
155
+ if (problem) ctx.issues.push({ code: "custom", input: ctx.value, path: ["text"], message: problem });
156
+ })
157
+ .describe("A similarity search over one index, optionally narrowed by a metadata filter.");
158
+ export type QueryInput = z.output<typeof QueryInput>;