@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +53 -0
- package/pithy.manifest.json +47 -0
- package/src/capability.ts +120 -0
- package/src/cloudflare-test.d.ts +12 -0
- package/src/config/config.ts +181 -0
- package/src/config/workerConfig.ts +75 -0
- package/src/data/document.ts +77 -0
- package/src/data/documents.ts +168 -0
- package/src/data/tables.ts +31 -0
- package/src/embed/embed.ts +126 -0
- package/src/error/errors.ts +146 -0
- package/src/http/guard.ts +27 -0
- package/src/http/handlers.ts +206 -0
- package/src/http/provisionGuard.ts +39 -0
- package/src/http/routes.ts +162 -0
- package/src/http/schemas.ts +158 -0
- package/src/index/drift.ts +119 -0
- package/src/index/filter.ts +278 -0
- package/src/index/index.ts +244 -0
- package/src/index/limits.ts +89 -0
- package/src/index/metadata.ts +160 -0
- package/src/index/provisioned.ts +183 -0
- package/src/index.ts +30 -0
- package/src/migrations/0001_documents.ts +63 -0
- package/src/provision/provisionVector.ts +250 -0
- package/src/provision/resolveVectorConfig.ts +85 -0
- package/src/seeds/example.ts +79 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/reprocess.ts +180 -0
- package/src/workflows/retryPolicy.ts +54 -0
- package/src/workflows/specs.ts +71 -0
- package/src/workflows/worker.ts +130 -0
- package/src/workflows/wrangler.jsonc +42 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
5
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { VectorDocument } from "./document";
|
|
7
|
+
import { VECTOR_DOCUMENTS_TABLE, type VectorDatabase } from "./tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The document corpus's reads and writes, in one place.
|
|
11
|
+
*
|
|
12
|
+
* Everything crossing the JS/SQLite line goes through the table's Zod codecs — `VectorDocument.encode` on
|
|
13
|
+
* the way in, `VectorDocument.parse` on the way out (CLAUDE.md §Data layer). No `JSON.stringify`, no epoch
|
|
14
|
+
* arithmetic, no `0/1` anywhere in this file.
|
|
15
|
+
*
|
|
16
|
+
* The store is a **structural seam** ({@link DocumentStore}) rather than a class over Kysely, because the
|
|
17
|
+
* reprocess Workflow and the HTTP handlers both take it and both are unit-tested with a fake. The Kysely
|
|
18
|
+
* implementation is {@link vectorDocuments}, exercised against real D1 in a Workers-runtime test.
|
|
19
|
+
*
|
|
20
|
+
* `model` is written **after** the vector reaches Vectorize, never with the row. A row whose model is null
|
|
21
|
+
* is a document that is durable but not yet searchable, which is precisely the set `pithy vector reprocess`
|
|
22
|
+
* should pick up — so the ordering makes a half-failed write self-healing instead of invisible.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A page of the corpus, read in keyset order. */
|
|
26
|
+
export interface DocumentPageRequest {
|
|
27
|
+
/** The index whose documents to read, as named in `pithy.config.ts`. */
|
|
28
|
+
indexName: string;
|
|
29
|
+
/** Read ids strictly greater than this one. Null starts at the beginning. */
|
|
30
|
+
after: string | null;
|
|
31
|
+
/** How many rows to read. The caller caps this at Vectorize's upsert batch. */
|
|
32
|
+
limit: number;
|
|
33
|
+
/**
|
|
34
|
+
* When set, read only rows whose `model` differs from this one (including rows with no model at all).
|
|
35
|
+
* That is the default `pithy vector reprocess` scan: re-embed exactly what drifted.
|
|
36
|
+
*/
|
|
37
|
+
staleModel?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The corpus operations the routes and the reprocess Workflow use. Structural, so a test injects a fake. */
|
|
41
|
+
export interface DocumentStore {
|
|
42
|
+
/**
|
|
43
|
+
* Insert or replace documents, keyed by `(indexName, id)`. Idempotent — the same call twice leaves the
|
|
44
|
+
* same rows. The same id in two indexes is two documents, and neither displaces the other.
|
|
45
|
+
*/
|
|
46
|
+
put(documents: readonly VectorDocument[]): Promise<void>;
|
|
47
|
+
/** One document, or null. */
|
|
48
|
+
get(indexName: string, id: string): Promise<VectorDocument | null>;
|
|
49
|
+
/** Documents by id, for hydrating a query's matches. Order is not guaranteed; the caller re-orders by score. */
|
|
50
|
+
byIds(indexName: string, ids: readonly string[]): Promise<VectorDocument[]>;
|
|
51
|
+
/** Delete one document. Returns whether a row was there to delete. */
|
|
52
|
+
remove(indexName: string, id: string): Promise<boolean>;
|
|
53
|
+
/** One keyset page, ordered by id ascending. */
|
|
54
|
+
page(request: DocumentPageRequest): Promise<VectorDocument[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Record that these ids, in this index, are now embedded with `model`, as of `at`. The write that makes a
|
|
57
|
+
* row searchable. Scoped by index, so stamping `docs` never touches an identically-named `faqs` row.
|
|
58
|
+
*/
|
|
59
|
+
markEmbedded(indexName: string, ids: readonly string[], model: string, at: Date): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `byIds` binds the index name before its id list — one slot the chunk never gets.
|
|
64
|
+
*
|
|
65
|
+
* A query for `topK: 100` hydrates 100 ids, so a chunk sized at D1's cap itself would bind 101 and be
|
|
66
|
+
* rejected. The count is stated here, next to the query it describes; add a `where` below and this
|
|
67
|
+
* number moves with it.
|
|
68
|
+
*/
|
|
69
|
+
const BY_IDS_FIXED_PARAMETERS = 1;
|
|
70
|
+
|
|
71
|
+
/** `markEmbedded` binds `model`, `updatedAt`, and the index name before a single id is counted. */
|
|
72
|
+
const MARK_EMBEDDED_FIXED_PARAMETERS = 3;
|
|
73
|
+
|
|
74
|
+
/** The Kysely-backed {@link DocumentStore} over the app database's `DB` binding. */
|
|
75
|
+
export function vectorDocuments(db: VectorDatabase): DocumentStore {
|
|
76
|
+
return {
|
|
77
|
+
async put(documents) {
|
|
78
|
+
if (documents.length === 0) return;
|
|
79
|
+
for (const document of documents) {
|
|
80
|
+
// Encode through the table's codecs, then upsert on the primary key: a re-ingest of the same id is
|
|
81
|
+
// a replace, so an interrupted batch can simply be re-run.
|
|
82
|
+
const row = VectorDocument.encode(document);
|
|
83
|
+
await db
|
|
84
|
+
.insertInto(VECTOR_DOCUMENTS_TABLE)
|
|
85
|
+
.values(row)
|
|
86
|
+
.onConflict((conflict) =>
|
|
87
|
+
// The conflict target is the whole primary key. On `id` alone, writing `intro` to `faqs` would
|
|
88
|
+
// overwrite `docs`'s `intro` — same id, different index, different document.
|
|
89
|
+
conflict.columns(["indexName", "id"]).doUpdateSet({
|
|
90
|
+
namespace: row.namespace,
|
|
91
|
+
content: row.content,
|
|
92
|
+
metadata: row.metadata,
|
|
93
|
+
model: row.model,
|
|
94
|
+
updatedAt: row.updatedAt,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.execute();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async get(indexName, id) {
|
|
102
|
+
const row = await db
|
|
103
|
+
.selectFrom(VECTOR_DOCUMENTS_TABLE)
|
|
104
|
+
.selectAll()
|
|
105
|
+
.where("id", "=", id)
|
|
106
|
+
.where("indexName", "=", indexName)
|
|
107
|
+
.executeTakeFirst();
|
|
108
|
+
return row ? VectorDocument.parse(row) : null;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async byIds(indexName, ids) {
|
|
112
|
+
if (ids.length === 0) return [];
|
|
113
|
+
const documents: VectorDocument[] = [];
|
|
114
|
+
for (const group of chunkByBoundParameters(ids, BY_IDS_FIXED_PARAMETERS)) {
|
|
115
|
+
const rows = await db
|
|
116
|
+
.selectFrom(VECTOR_DOCUMENTS_TABLE)
|
|
117
|
+
.selectAll()
|
|
118
|
+
.where("indexName", "=", indexName)
|
|
119
|
+
.where("id", "in", group)
|
|
120
|
+
.execute();
|
|
121
|
+
for (const row of rows) documents.push(VectorDocument.parse(row));
|
|
122
|
+
}
|
|
123
|
+
return documents;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async remove(indexName, id) {
|
|
127
|
+
const result = await db
|
|
128
|
+
.deleteFrom(VECTOR_DOCUMENTS_TABLE)
|
|
129
|
+
.where("id", "=", id)
|
|
130
|
+
.where("indexName", "=", indexName)
|
|
131
|
+
.executeTakeFirst();
|
|
132
|
+
return (result.numDeletedRows ?? 0n) > 0n;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async page(request) {
|
|
136
|
+
let query = db
|
|
137
|
+
.selectFrom(VECTOR_DOCUMENTS_TABLE)
|
|
138
|
+
.selectAll()
|
|
139
|
+
.where("indexName", "=", request.indexName)
|
|
140
|
+
.orderBy("id", "asc")
|
|
141
|
+
.limit(request.limit);
|
|
142
|
+
if (request.after !== null) query = query.where("id", ">", request.after);
|
|
143
|
+
if (request.staleModel !== undefined) {
|
|
144
|
+
// `model IS NULL` counts as stale: a row written but never embedded is exactly what needs a pass.
|
|
145
|
+
query = query.where((builder) =>
|
|
146
|
+
builder.or([builder.eb("model", "is", null), builder.eb("model", "!=", request.staleModel as string)]),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const rows = await query.execute();
|
|
150
|
+
return rows.map((row) => VectorDocument.parse(row));
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async markEmbedded(indexName, ids, model, at) {
|
|
154
|
+
if (ids.length === 0) return;
|
|
155
|
+
// The timestamp goes through the same codec the column is declared with — never a hand-rolled epoch.
|
|
156
|
+
const updatedAt = SQLiteDate.encode(at);
|
|
157
|
+
for (const group of chunkByBoundParameters(ids, MARK_EMBEDDED_FIXED_PARAMETERS)) {
|
|
158
|
+
// Scoped by index, like every other statement here: an id names a row only together with its index.
|
|
159
|
+
await db
|
|
160
|
+
.updateTable(VECTOR_DOCUMENTS_TABLE)
|
|
161
|
+
.set({ model, updatedAt })
|
|
162
|
+
.where("indexName", "=", indexName)
|
|
163
|
+
.where("id", "in", group)
|
|
164
|
+
.execute();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
|
|
6
|
+
import type { Kysely } from "kysely";
|
|
7
|
+
import type { z } from "zod";
|
|
8
|
+
import { VectorDocument } from "./document";
|
|
9
|
+
|
|
10
|
+
/** The document corpus. `CamelCasePlugin` snake-cases it to `pithy_vector_documents`. */
|
|
11
|
+
export const VECTOR_DOCUMENTS_TABLE = "pithyVectorDocuments";
|
|
12
|
+
|
|
13
|
+
/** The vector tables map. One table: the corpus every index hydrates from and re-embeds out of. */
|
|
14
|
+
export function vectorTables(): Record<string, z.ZodObject> {
|
|
15
|
+
return {
|
|
16
|
+
[VECTOR_DOCUMENTS_TABLE]: VectorDocument,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The typed Kysely database over the vector tables. */
|
|
21
|
+
export type VectorTables = {
|
|
22
|
+
[VECTOR_DOCUMENTS_TABLE]: typeof VectorDocument;
|
|
23
|
+
};
|
|
24
|
+
export type VectorDatabase = Kysely<DatabaseSchema<VectorTables>>;
|
|
25
|
+
|
|
26
|
+
/** Build the vector database from the `DB` binding (CamelCasePlugin installed). */
|
|
27
|
+
export function vectorDatabase(d1: D1Database): VectorDatabase {
|
|
28
|
+
return createDatabase(d1, {
|
|
29
|
+
[VECTOR_DOCUMENTS_TABLE]: VectorDocument,
|
|
30
|
+
}) as unknown as VectorDatabase;
|
|
31
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError, UpstreamError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { VectorIndexConfig } from "../config/config";
|
|
7
|
+
import { VectorDimensionMismatchError } from "../error/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The Workers AI embedding call, over the `env.AI` binding (bindings-first — CLAUDE.md §Cloudflare access).
|
|
11
|
+
*
|
|
12
|
+
* The binding is typed structurally as {@link VectorAi} rather than depending on the exact `Ai` shape, so a
|
|
13
|
+
* test injects a fake and the code never reaches for a global. As with the index seam, this is the only way
|
|
14
|
+
* to test any of it: Cloudflare ships no local emulation for Workers AI. The seam is the first parameter and
|
|
15
|
+
* the model id trails, matching `@pithy-sh/media`'s enrichment calls.
|
|
16
|
+
*
|
|
17
|
+
* `@pithy-sh/cloudflare`'s `aiManager` is deliberately not used. It is the REST client — it needs an API
|
|
18
|
+
* token and an account id, which a Worker has neither of, and should not.
|
|
19
|
+
*
|
|
20
|
+
* {@link embedForIndex} is the call production code makes. It pins the model to the index's declared one for
|
|
21
|
+
* writes *and* queries, which removes the failure people actually hit: an index built with one model, queried
|
|
22
|
+
* with another. Nothing errors. The neighbors just come back from a space the query vector does not live in.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The subset of the Workers AI binding this module uses. */
|
|
26
|
+
export interface VectorAi {
|
|
27
|
+
/** Run a model by id with an input body; the response shape varies per model, so it is `unknown`. */
|
|
28
|
+
run(model: string, input: Record<string, unknown>): Promise<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EmbeddingResponse = z
|
|
32
|
+
.object({
|
|
33
|
+
shape: z
|
|
34
|
+
.array(z.number())
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("The response's dimensions as [count, size], when the model reports them."),
|
|
37
|
+
data: z.array(z.array(z.number())).describe("One embedding per input text, in the order the texts were given."),
|
|
38
|
+
})
|
|
39
|
+
.describe("The shape of a Workers AI text-embedding response.");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Embed texts with a named model. Returns one vector per text, in order. The model is a parameter with no
|
|
43
|
+
* default: an embedding whose model is implicit is an embedding nobody can reproduce.
|
|
44
|
+
*/
|
|
45
|
+
export async function embedTexts(ai: VectorAi, texts: string[], model: string): Promise<number[][]> {
|
|
46
|
+
if (texts.length === 0) {
|
|
47
|
+
throw new ValidationError({
|
|
48
|
+
message: "Nothing to embed.",
|
|
49
|
+
action: "Pass at least one piece of text.",
|
|
50
|
+
detail: "embedTexts received an empty batch",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A rejection out of the binding is the one fault in this function a second attempt can answer
|
|
55
|
+
// differently: every other refusal here is deterministic in the input or in the model's answer. It
|
|
56
|
+
// therefore carries `core/upstream_failed`, which `vectorWorkflowRetry` states — a raw throw would be
|
|
57
|
+
// `unclassified` to `classifyWorkflowFault`, and unclassified is terminal (pithy-sh/pithy#348). The
|
|
58
|
+
// binding's own words stay in `detail`, which the HTTP codec strips.
|
|
59
|
+
let raw: unknown;
|
|
60
|
+
try {
|
|
61
|
+
raw = await ai.run(model, { text: texts });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new UpstreamError(
|
|
64
|
+
{
|
|
65
|
+
message: "The embedding model could not be reached.",
|
|
66
|
+
detail: `Workers AI rejected an embedding call with model '${model}'`,
|
|
67
|
+
},
|
|
68
|
+
{ cause: error },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const parsed = EmbeddingResponse.safeParse(raw);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
throw new InternalError({
|
|
74
|
+
message: "The embedding model returned something unexpected.",
|
|
75
|
+
detail: `embedding model '${model}' returned an unexpected shape`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (parsed.data.data.length !== texts.length) {
|
|
79
|
+
throw new InternalError({
|
|
80
|
+
message: "The embedding model returned the wrong number of vectors.",
|
|
81
|
+
detail: `embedding model '${model}' returned ${parsed.data.data.length} vectors for ${texts.length} texts`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return parsed.data.data;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Embed texts for one index, with that index's pinned model, and prove the result fits the index. A model
|
|
89
|
+
* swapped in config without re-creating the index fails here, on the first write or query, naming both — far
|
|
90
|
+
* cheaper than a corpus embedded half in one space and half in another.
|
|
91
|
+
*/
|
|
92
|
+
export async function embedForIndex(ai: VectorAi, index: VectorIndexConfig, texts: string[]): Promise<number[][]> {
|
|
93
|
+
const vectors = await embedTexts(ai, texts, index.model);
|
|
94
|
+
for (const [position, vector] of vectors.entries()) {
|
|
95
|
+
if (vector.length !== index.dimensions) {
|
|
96
|
+
throw new VectorDimensionMismatchError({
|
|
97
|
+
detail: `model '${index.model}' produced ${vector.length} components at position ${position}; the index expects ${index.dimensions}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return vectors;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Texts sent to Workers AI in one call.
|
|
106
|
+
*
|
|
107
|
+
* Not a published Vectorize limit — a deliberate chunk size. A model's per-request text cap varies by model,
|
|
108
|
+
* and one call carrying a thousand documents is a large, slow, all-or-nothing request: a single timeout
|
|
109
|
+
* discards every embedding in it. Chunking costs a few more round trips and makes a failure lose a hundred
|
|
110
|
+
* documents instead of a thousand.
|
|
111
|
+
*/
|
|
112
|
+
export const EMBED_CHUNK = 100;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Embed a batch of any size for one index, in {@link EMBED_CHUNK}-sized calls, preserving order. The form
|
|
116
|
+
* every caller writing more than a handful of documents should use — a route handling a full batch, and every
|
|
117
|
+
* page of a reprocess run.
|
|
118
|
+
*/
|
|
119
|
+
export async function embedBatched(ai: VectorAi, index: VectorIndexConfig, texts: string[]): Promise<number[][]> {
|
|
120
|
+
const vectors: number[][] = [];
|
|
121
|
+
for (let start = 0; start < texts.length; start += EMBED_CHUNK) {
|
|
122
|
+
const chunk = texts.slice(start, start + EMBED_CHUNK);
|
|
123
|
+
for (const vector of await embedForIndex(ai, index, chunk)) vectors.push(vector);
|
|
124
|
+
}
|
|
125
|
+
return vectors;
|
|
126
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/vector` throw sugar. The `vector/*` codes live in core's closed `KitErrorPayload` union
|
|
9
|
+
* (CLAUDE.md §Errors); these subclasses are the package-local vehicles that set one of those members.
|
|
10
|
+
* Runtime code in this package throws one of these, never a plain `new Error`.
|
|
11
|
+
*
|
|
12
|
+
* The messages carry the ceiling that was breached, because "topK is too large" without the number sends the
|
|
13
|
+
* reader to the docs. Public text stays in `message`/`action`; the offending value goes in `detail`, which
|
|
14
|
+
* the HTTP codec strips.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface VectorErrorArgs {
|
|
18
|
+
message?: string;
|
|
19
|
+
action?: string;
|
|
20
|
+
detail?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
23
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
24
|
+
*/
|
|
25
|
+
params?: MessageParams;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class VectorMetadataIndexDriftError extends PithyError {
|
|
29
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
30
|
+
super(
|
|
31
|
+
{
|
|
32
|
+
code: "vector/metadata_index_drift",
|
|
33
|
+
status: 500,
|
|
34
|
+
message: args.message ?? "Search is not configured correctly.",
|
|
35
|
+
action:
|
|
36
|
+
args.action ??
|
|
37
|
+
"Run `pithy vector provision` to create the missing metadata indexes, then re-embed anything written before them.",
|
|
38
|
+
detail: args.detail,
|
|
39
|
+
params: args.params,
|
|
40
|
+
},
|
|
41
|
+
options,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class VectorDimensionMismatchError extends PithyError {
|
|
47
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
48
|
+
super(
|
|
49
|
+
{
|
|
50
|
+
code: "vector/dimension_mismatch",
|
|
51
|
+
status: 400,
|
|
52
|
+
message: args.message ?? "That vector is the wrong size for this index.",
|
|
53
|
+
action:
|
|
54
|
+
args.action ??
|
|
55
|
+
"An index's dimensions are fixed at creation. Embed with the model the index declares, or create a new index.",
|
|
56
|
+
detail: args.detail,
|
|
57
|
+
params: args.params,
|
|
58
|
+
},
|
|
59
|
+
options,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class VectorTopKExceededError extends PithyError {
|
|
65
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
66
|
+
super(
|
|
67
|
+
{
|
|
68
|
+
code: "vector/topk_exceeded",
|
|
69
|
+
status: 400,
|
|
70
|
+
message: args.message ?? "Too many matches requested.",
|
|
71
|
+
action: args.action ?? "Ask for at most 50 matches with values or metadata, 100 without.",
|
|
72
|
+
detail: args.detail,
|
|
73
|
+
params: args.params,
|
|
74
|
+
},
|
|
75
|
+
options,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class VectorFilterTooLargeError extends PithyError {
|
|
81
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
82
|
+
super(
|
|
83
|
+
{
|
|
84
|
+
code: "vector/filter_too_large",
|
|
85
|
+
status: 400,
|
|
86
|
+
message: args.message ?? "That filter is too large.",
|
|
87
|
+
action: args.action ?? "A filter's compact JSON must stay under 2,048 bytes. Narrow it, or split the query.",
|
|
88
|
+
detail: args.detail,
|
|
89
|
+
params: args.params,
|
|
90
|
+
},
|
|
91
|
+
options,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class VectorMetadataTooLargeError extends PithyError {
|
|
97
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
98
|
+
super(
|
|
99
|
+
{
|
|
100
|
+
code: "vector/metadata_too_large",
|
|
101
|
+
status: 400,
|
|
102
|
+
message: args.message ?? "That vector carries too much metadata.",
|
|
103
|
+
action:
|
|
104
|
+
args.action ??
|
|
105
|
+
"A vector's metadata must stay under 10 KiB. Keep long text in the document table and reference it by id.",
|
|
106
|
+
detail: args.detail,
|
|
107
|
+
params: args.params,
|
|
108
|
+
},
|
|
109
|
+
options,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class VectorIndexNotFoundError extends PithyError {
|
|
115
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
116
|
+
super(
|
|
117
|
+
{
|
|
118
|
+
code: "vector/index_not_found",
|
|
119
|
+
status: 404,
|
|
120
|
+
message: args.message ?? "That index does not exist.",
|
|
121
|
+
action: args.action ?? "Check the index name against the `indexes` block in pithy.config.ts.",
|
|
122
|
+
detail: args.detail,
|
|
123
|
+
params: args.params,
|
|
124
|
+
},
|
|
125
|
+
options,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class VectorUnfilterableFieldError extends PithyError {
|
|
131
|
+
constructor(args: VectorErrorArgs = {}, options?: { cause?: unknown }) {
|
|
132
|
+
super(
|
|
133
|
+
{
|
|
134
|
+
code: "vector/unfilterable_field",
|
|
135
|
+
status: 400,
|
|
136
|
+
message: args.message ?? "You cannot filter on that field.",
|
|
137
|
+
action:
|
|
138
|
+
args.action ??
|
|
139
|
+
"Mark the field `.meta({ filterable: true })` in the index's metadata schema, run `pithy vector provision`, and re-embed — Vectorize does not index what was written before the metadata index existed.",
|
|
140
|
+
detail: args.detail,
|
|
141
|
+
params: args.params,
|
|
142
|
+
},
|
|
143
|
+
options,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { UnauthorizedError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { MiddlewareHandler } from "hono";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The vector routes' identity gate. Every vector route declares the `bearer`/`session` verification
|
|
10
|
+
* strategy; this middleware enforces it through the core `AuthContext` seam (`c.var.auth`), which
|
|
11
|
+
* `@pithy-sh/auth` populates. It never validates a credential itself — it only asserts one resolved.
|
|
12
|
+
*
|
|
13
|
+
* It is **copied**, not imported from `@pithy-sh/auth`. That is the point: this package's `dependsOn` stays
|
|
14
|
+
* empty, and a project with no auth capability composed leaves `c.var.auth` null, so every vector route is
|
|
15
|
+
* denied. A missing dependency that denies is a safe default; one that opens is not.
|
|
16
|
+
*/
|
|
17
|
+
export function requireAuth(): MiddlewareHandler<PithyHonoEnv> {
|
|
18
|
+
return async (c, next) => {
|
|
19
|
+
if (!c.var.auth) {
|
|
20
|
+
throw new UnauthorizedError({
|
|
21
|
+
message: "Authentication required.",
|
|
22
|
+
action: "Sign in and retry with a valid session or bearer token.",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
27
|
+
}
|