@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,89 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { NAMESPACE_LIMITS } from "@pithy-sh/core/src/naming/limits";
5
+
6
+ /**
7
+ * Vectorize's published ceilings, in one place, verified against
8
+ * https://developers.cloudflare.com/vectorize/platform/limits/ and
9
+ * https://developers.cloudflare.com/vectorize/reference/metadata-filtering/ on 2026-07-27.
10
+ *
11
+ * They live here rather than inline at each call site for one reason: a limit that is enforced in two places
12
+ * eventually disagrees with itself. Every guard in this package reads a const from this file, so the number
13
+ * an error message quotes is the number the check used.
14
+ *
15
+ * Nothing here throws. These are values and predicates; the typed errors are raised by the module that owns
16
+ * the boundary (`index/index.ts` for a query or an upsert, `index/filter.ts` for a filter, `config/config.ts`
17
+ * for a config parse), because that module knows which `vector/*` code applies.
18
+ */
19
+
20
+ /** Components per vector, float32. An index's `dimensions` is fixed at creation and cannot be changed. */
21
+ export const MAX_DIMENSIONS = 1536;
22
+
23
+ /**
24
+ * Bytes in an index name, a namespace name, or any other Vectorize identifier.
25
+ *
26
+ * The index-name half of this is the same number the naming facade holds
27
+ * (`NAMESPACE_LIMITS.vectorizeIndex` in `@pithy-sh/core/src/naming/limits`), so it is read from there
28
+ * rather than typed twice — a limit written in two files eventually disagrees with itself. The rest of
29
+ * Vectorize's identifiers share the ceiling but are not composed by the facade, which is why this
30
+ * package still owns the constant they are checked against.
31
+ *
32
+ * Vectorize also constrains the charset: `^([a-z]+[a-z0-9_-]*[a-z0-9]+)$` — an index name must **start
33
+ * with a letter** and end alphanumeric. Pithy's project rule already requires a letter-leading project,
34
+ * and every composed name leads with the project, so the two agree by construction.
35
+ */
36
+ export const MAX_NAME_BYTES = NAMESPACE_LIMITS.vectorizeIndex.maxLength;
37
+
38
+ /** Bytes in a vector id. A document whose id exceeds this can be stored but never addressed in the index. */
39
+ export const MAX_VECTOR_ID_BYTES = 64;
40
+
41
+ /**
42
+ * Bytes of metadata carried on one vector. Long source text belongs in D1, which is why this package has a table.
43
+ * Like the filter ceiling, the compact JSON must be **under** this — 10,240 is already too large.
44
+ */
45
+ export const MAX_METADATA_BYTES = 10 * 1024;
46
+
47
+ /** The filter's compact JSON must be **under** this — 2,048 is already too large, not the last accepted size. */
48
+ export const MAX_FILTER_BYTES = 2048;
49
+
50
+ /** Characters in a filter key. Vectorize states this as characters, not bytes. */
51
+ export const MAX_FILTER_KEY_LENGTH = 512;
52
+
53
+ /** Metadata indexes per index. A hard ceiling, which is what makes filterability a provisioning-time decision. */
54
+ export const MAX_METADATA_INDEXES = 10;
55
+
56
+ /** Vectors per upsert call from a Worker. The HTTP API allows 5,000; the binding does not. */
57
+ export const MAX_UPSERT_BATCH = 1000;
58
+
59
+ /** topK when a query returns values or metadata — the payload makes the response the constraint. */
60
+ export const MAX_TOPK_WITH_PAYLOAD = 50;
61
+
62
+ /** topK when a query returns neither values nor metadata. */
63
+ export const MAX_TOPK_WITHOUT_PAYLOAD = 100;
64
+
65
+ /** Matches a query returns when the caller names no `topK`. Small on purpose: a search page, not a scan. */
66
+ export const DEFAULT_TOPK = 10;
67
+
68
+ const encoder = new TextEncoder();
69
+
70
+ /** Byte length of a string as UTF-8. Cloudflare states these limits in bytes, and `String.length` is not that. */
71
+ export function byteLength(value: string): number {
72
+ return encoder.encode(value).length;
73
+ }
74
+
75
+ /**
76
+ * Why a key is unusable as a metadata filter key, or `null` when it is fine. Vectorize's rules: non-empty, no
77
+ * dots (reserved for nesting into sub-objects), no leading `$` (reserved for operators), at most 512
78
+ * characters. Returned as a message rather than thrown so a config parse can turn it into a Zod issue and a
79
+ * request boundary can turn it into a typed error.
80
+ */
81
+ export function filterKeyProblem(key: string): string | null {
82
+ if (key.length === 0) return "a filter key cannot be empty";
83
+ if (key.startsWith("$")) return `\`${key}\` starts with $, which Vectorize reserves for operators`;
84
+ if (key.includes(".")) return `\`${key}\` contains a dot, which Vectorize reserves for nested fields`;
85
+ if (key.length > MAX_FILTER_KEY_LENGTH) {
86
+ return `\`${key.slice(0, 32)}…\` is longer than ${MAX_FILTER_KEY_LENGTH} characters`;
87
+ }
88
+ return null;
89
+ }
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import { filterKeyProblem, MAX_METADATA_INDEXES } from "./limits";
7
+
8
+ /**
9
+ * One introspection pass over an index's metadata schema, two outputs: the metadata indexes to provision, and
10
+ * — via `index/filter.ts` — the shape of the filters the index can answer. They come from the same source
11
+ * because they must agree. Cloudflare states it verbatim:
12
+ *
13
+ * > "Vectors upserted before a metadata index was created won't have their metadata contained in that index."
14
+ *
15
+ * Filtering on a field indexed late does **not** error. It silently returns partial results: every vector
16
+ * written before the index existed simply fails to match. Combined with a hard ceiling of ten metadata indexes
17
+ * per index, that makes filterability a provisioning-time decision wearing the costume of a query-time one. A
18
+ * thin wrapper over the binding cannot remove that footgun. Declaring it in the schema can, because then one
19
+ * declaration drives provisioning, the filter type, the drift check `pithy vector provision` runs against the
20
+ * live index, and the boot check the Worker runs against what that command recorded.
21
+ *
22
+ * The marker is Zod 4's `.meta({ filterable: true })`, beside the field's mandatory `.describe()`. `.meta()`
23
+ * merges rather than replaces, in either order, so a field carries both.
24
+ */
25
+
26
+ /** The value type of an indexed metadata property. Vectorize indexes these three and nothing else. */
27
+ export const MetadataIndexType = z
28
+ .enum(["string", "number", "boolean"])
29
+ .describe("The value type of an indexed metadata property, which decides how a filter compares against it.");
30
+ export type MetadataIndexType = z.infer<typeof MetadataIndexType>;
31
+
32
+ /** One metadata index to provision on a Vectorize index — a property name and the type it is indexed as. */
33
+ export const MetadataIndexDescriptor = z
34
+ .object({
35
+ propertyName: z.string().min(1).describe("The metadata property this index covers, as named in the schema."),
36
+ indexType: MetadataIndexType.describe("How Vectorize stores and compares this property's values."),
37
+ })
38
+ .describe("One metadata index derived from a field marked filterable in an index's metadata schema.");
39
+ export type MetadataIndexDescriptor = z.infer<typeof MetadataIndexDescriptor>;
40
+
41
+ /** The result of one introspection pass: what to provision, and every reason the schema is not provisionable. */
42
+ export interface MetadataIntrospection {
43
+ /** The metadata indexes, in declaration order. Empty when nothing is marked filterable. */
44
+ indexes: MetadataIndexDescriptor[];
45
+ /** Human-readable reasons the schema cannot be honored. Empty means the schema is good. */
46
+ problems: string[];
47
+ }
48
+
49
+ /** Strip `.optional()`, `.nullable()`, `.default()` and friends down to the type that carries the value. */
50
+ function unwrap(schema: z.ZodType): z.ZodType {
51
+ let current: z.ZodType = schema;
52
+ for (;;) {
53
+ const inner = (current as unknown as { def?: { innerType?: z.ZodType } }).def?.innerType;
54
+ if (!inner) return current;
55
+ current = inner;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Whether a field is marked filterable. The marker is read at every level of the wrapper chain, so
61
+ * `z.string().meta({ filterable: true }).optional()` counts — the wrapper is a different schema object and
62
+ * carries no metadata of its own, and a reader who wrapped a marked field plainly meant to keep the mark.
63
+ */
64
+ export function isFilterable(schema: z.ZodType): boolean {
65
+ let current: z.ZodType | undefined = schema;
66
+ while (current) {
67
+ const meta = current.meta() as { filterable?: unknown } | undefined;
68
+ if (meta?.filterable === true) return true;
69
+ current = (current as unknown as { def?: { innerType?: z.ZodType } }).def?.innerType;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ /** The type of every value a `z.enum`/`z.literal` admits, or null when they are not all one indexable type. */
75
+ function uniformPrimitiveType(values: readonly unknown[]): MetadataIndexType | null {
76
+ if (values.length === 0) return null;
77
+ const first = typeof values[0];
78
+ if (first !== "string" && first !== "number" && first !== "boolean") return null;
79
+ return values.every((value) => typeof value === first) ? first : null;
80
+ }
81
+
82
+ /** The metadata index type a field maps to, or null when Vectorize cannot index it. */
83
+ function indexTypeOf(schema: z.ZodType): MetadataIndexType | null {
84
+ const base = unwrap(schema);
85
+ const def = (base as unknown as { def: { type: string; entries?: Record<string, unknown>; values?: unknown[] } }).def;
86
+ switch (def.type) {
87
+ case "string":
88
+ return "string";
89
+ case "number":
90
+ return "number";
91
+ case "boolean":
92
+ return "boolean";
93
+ // An enum or a literal is a constrained primitive; index it as whatever primitive it constrains, so a
94
+ // status field declared as an enum is filterable without restating it as a bare string.
95
+ case "enum":
96
+ return uniformPrimitiveType(Object.values(def.entries ?? {}));
97
+ case "literal":
98
+ return uniformPrimitiveType(def.values ?? []);
99
+ default:
100
+ return null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Introspect an index's metadata schema. Never throws — it returns every problem it found, so a config parse
106
+ * can report them all as Zod issues at once instead of failing on the first.
107
+ */
108
+ export function introspectMetadata(metadata: z.ZodObject): MetadataIntrospection {
109
+ const indexes: MetadataIndexDescriptor[] = [];
110
+ const problems: string[] = [];
111
+
112
+ for (const [propertyName, field] of Object.entries(metadata.shape)) {
113
+ const schema = field as z.ZodType;
114
+ if (!isFilterable(schema)) continue;
115
+
116
+ const keyProblem = filterKeyProblem(propertyName);
117
+ if (keyProblem) {
118
+ problems.push(`Metadata field ${keyProblem}.`);
119
+ continue;
120
+ }
121
+
122
+ const indexType = indexTypeOf(schema);
123
+ if (!indexType) {
124
+ problems.push(
125
+ `Metadata field \`${propertyName}\` is marked filterable but Vectorize cannot index its type. Only strings, numbers, and booleans are indexable.`,
126
+ );
127
+ continue;
128
+ }
129
+
130
+ indexes.push({ propertyName, indexType });
131
+ }
132
+
133
+ // The eleventh filterable field fails here, at assembly, rather than at provisioning time — where Vectorize
134
+ // would reject it after the first ten already exist, leaving the index half-configured and every filter on
135
+ // the rejected field silently partial.
136
+ if (indexes.length > MAX_METADATA_INDEXES) {
137
+ problems.push(
138
+ `${indexes.length} fields are marked filterable; Vectorize allows ${MAX_METADATA_INDEXES} metadata indexes per index. Drop ${indexes.length - MAX_METADATA_INDEXES}: ${indexes.map((index) => index.propertyName).join(", ")}.`,
139
+ );
140
+ }
141
+
142
+ return { indexes, problems };
143
+ }
144
+
145
+ /**
146
+ * The metadata indexes an index's schema declares. Throws on any problem — this is the form provisioning and
147
+ * the drift check call, where a half-usable answer is worse than none.
148
+ */
149
+ export function metadataIndexes(metadata: z.ZodObject): MetadataIndexDescriptor[] {
150
+ const { indexes, problems } = introspectMetadata(metadata);
151
+ if (problems.length > 0) {
152
+ throw new ValidationError({
153
+ message: "This index's metadata schema cannot be provisioned.",
154
+ action: "Fix the fields marked filterable in the index's metadata schema, then run `pithy vector provision`.",
155
+ detail: problems.join(" "),
156
+ issues: problems.map((problem) => ({ path: ["metadata"], message: problem, code: "custom" })),
157
+ });
158
+ }
159
+ return indexes;
160
+ }
@@ -0,0 +1,183 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import type { VectorConfig } from "../config/config";
6
+ import { VectorMetadataIndexDriftError } from "../error/errors";
7
+ import { compareMetadataIndexes } from "./drift";
8
+ import { type MetadataIndexDescriptor, metadataIndexes } from "./metadata";
9
+
10
+ /**
11
+ * The provisioning record — what `pithy vector provision` observed on the live indexes — and the boot check
12
+ * that reads it.
13
+ *
14
+ * The failure being guarded is the one Vectorize refuses to report. Someone marks a metadata field
15
+ * `filterable`, deploys, and never re-runs provision. Every query filtering on that field returns a short,
16
+ * plausible result set, because Cloudflare states it verbatim: *"Vectors upserted before a metadata index was
17
+ * created won't have their metadata contained in that index."* Nothing errors. The bug surfaces weeks later
18
+ * as "search feels wrong". A startup error costs one deploy; that costs trust.
19
+ *
20
+ * **Why a record and not a live call.** Reading an index's metadata indexes is a Cloudflare control-plane
21
+ * call, authorized only by an account API token. Doing it at boot would put a control-plane token on the
22
+ * request path of every search — a strictly worse trade than the bug it catches. So the token stays where it
23
+ * already is: `pithy vector provision` holds it, already compares declared against live, and now **writes
24
+ * down what it saw** as one Zod-validated JSON var ({@link VECTOR_PROVISIONED_VAR}) — the same way
25
+ * `MEDIA_CONFIG` and `EMAIL_THEME` carry structured config into a prebuilt worker. The Worker compares its
26
+ * declared filterable set against that record with no network at all.
27
+ *
28
+ * **Be honest about what this proves.** It proves the config declares nothing that provisioning did not
29
+ * observe *the last time it ran*. It does not prove anything about Cloudflare right now: someone who deletes
30
+ * a metadata index from the dashboard leaves the record stale and this check silent. What it catches is the
31
+ * common, silent case — a schema edited and deployed without re-provisioning — because the record only ever
32
+ * changes when provision runs.
33
+ *
34
+ * **An absent record fails, but only when something is declared filterable.** The two cases are not the same
35
+ * project. A project with no filterable field has nothing that can drift, so it boots — an adopter who has
36
+ * added vector but not provisioned yet still serves every other route. A project that *declares* filterable
37
+ * fields with no record has provably never provisioned, which means the index does not exist either and every
38
+ * filtered search is already wrong. Booting it would serve exactly the silently-partial results this package
39
+ * exists to prevent, so it fails and names the command. Running `pithy vector provision` is the whole fix.
40
+ *
41
+ * A live metadata index the config does not declare is **not** fatal here either. It may predate the config
42
+ * or belong to another consumer of the same index, and refusing to boot over a leftover would be hostile. It
43
+ * costs one of the ten slots, and `pithy vector provision` is where it gets reported.
44
+ */
45
+
46
+ /**
47
+ * The wrangler var the record travels in. Written per environment by `pithy vector provision`, read off the
48
+ * Worker's env at boot. A var, not a binding: it is plain data, and it must be diffable in `wrangler.jsonc`.
49
+ */
50
+ export const VECTOR_PROVISIONED_VAR = "VECTOR_PROVISIONED";
51
+
52
+ /** One metadata index as provisioning found it on the live Vectorize index. */
53
+ export const ObservedMetadataIndex = z
54
+ .object({
55
+ propertyName: z.string().min(1).describe("The metadata property the live index covers."),
56
+ indexType: z
57
+ .string()
58
+ .min(1)
59
+ .describe(
60
+ "The type Vectorize reports the index was created with, recorded verbatim rather than narrowed to the three types this package can declare — a record of an observation must round-trip whatever was observed, including a type only Cloudflare knows about.",
61
+ ),
62
+ })
63
+ .describe("One metadata index `pithy vector provision` observed on a live Vectorize index.");
64
+ export type ObservedMetadataIndex = z.infer<typeof ObservedMetadataIndex>;
65
+
66
+ /** One configured index as provisioning left it: the Vectorize name, and every metadata index it then had. */
67
+ export const ProvisionedIndex = z
68
+ .object({
69
+ indexName: z.string().min(1).describe("The Vectorize index name this configured index was provisioned as."),
70
+ metadataIndexes: z
71
+ .array(ObservedMetadataIndex)
72
+ .describe(
73
+ "Every metadata index live on that index when provisioning finished — the declared ones, each waited for until Cloudflare showed it, plus any the config does not declare.",
74
+ ),
75
+ })
76
+ .describe("What `pithy vector provision` last observed for one configured index.");
77
+ export type ProvisionedIndex = z.infer<typeof ProvisionedIndex>;
78
+
79
+ /** The whole record, keyed by the config's index names. The value of the `VECTOR_PROVISIONED` var. */
80
+ export const VectorProvisionRecord = z
81
+ .object({
82
+ indexes: z
83
+ .record(z.string(), ProvisionedIndex)
84
+ .describe("Each configured index, keyed by the name used in pithy.config.ts, as provisioning last saw it."),
85
+ })
86
+ .describe("What `pithy vector provision` observed, carried into the Worker so boot can check drift offline.");
87
+ export type VectorProvisionRecord = z.infer<typeof VectorProvisionRecord>;
88
+
89
+ /**
90
+ * Read the record off a Worker env. Absent or blank returns `undefined` — the caller decides what absence
91
+ * means. Present but unreadable **throws**: a record that cannot be parsed cannot be compared against, and
92
+ * treating it as absence would silently downgrade a corrupted record into "never provisioned".
93
+ */
94
+ export function readProvisionRecord(env: Record<string, unknown>): VectorProvisionRecord | undefined {
95
+ const raw = env[VECTOR_PROVISIONED_VAR];
96
+ if (typeof raw !== "string" || raw.trim() === "") return undefined;
97
+
98
+ let decoded: unknown;
99
+ try {
100
+ decoded = JSON.parse(raw);
101
+ } catch (cause) {
102
+ throw new VectorMetadataIndexDriftError(
103
+ {
104
+ message: `The ${VECTOR_PROVISIONED_VAR} var is not valid JSON, so the metadata indexes cannot be checked.`,
105
+ action: `Run \`pithy vector provision --env <env>\` to rewrite ${VECTOR_PROVISIONED_VAR}, then redeploy.`,
106
+ detail: cause instanceof Error ? cause.message : String(cause),
107
+ },
108
+ { cause },
109
+ );
110
+ }
111
+
112
+ const parsed = VectorProvisionRecord.safeParse(decoded);
113
+ if (!parsed.success) {
114
+ throw new VectorMetadataIndexDriftError({
115
+ message: `The ${VECTOR_PROVISIONED_VAR} var is not a provisioning record, so the metadata indexes cannot be checked.`,
116
+ action: `Run \`pithy vector provision --env <env>\` to rewrite ${VECTOR_PROVISIONED_VAR}, then redeploy.`,
117
+ detail: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "),
118
+ });
119
+ }
120
+ return parsed.data;
121
+ }
122
+
123
+ /** Every configured index that declares at least one filterable field, with the descriptors it declares. */
124
+ function declaredFilterable(config: VectorConfig): Map<string, MetadataIndexDescriptor[]> {
125
+ const declared = new Map<string, MetadataIndexDescriptor[]>();
126
+ for (const [name, index] of Object.entries(config.indexes)) {
127
+ const fields = index.metadata ? metadataIndexes(index.metadata) : [];
128
+ if (fields.length > 0) declared.set(name, fields);
129
+ }
130
+ return declared;
131
+ }
132
+
133
+ /**
134
+ * Refuse to boot when the config declares a filterable field that provisioning never observed, or observed
135
+ * as a different type. Pure — no network, no token, one comparison of two plain lists.
136
+ *
137
+ * The field names ride in the public `message` rather than in `detail`, for the reason core's
138
+ * `validateBindings` gives for doing the same: they are config keys, not secrets, and this surfaces at
139
+ * startup, where the reader is the operator staring at a 500 and needs to be told which field and which
140
+ * command. `detail` carries the same facts for the log.
141
+ */
142
+ export function assertProvisionedMetadataIndexes(
143
+ config: VectorConfig,
144
+ record: VectorProvisionRecord | undefined,
145
+ ): void {
146
+ const declared = declaredFilterable(config);
147
+ if (declared.size === 0) return;
148
+
149
+ if (!record) {
150
+ const fields = [...declared].flatMap(([name, descriptors]) =>
151
+ descriptors.map((descriptor) => `${name}.${descriptor.propertyName}`),
152
+ );
153
+ throw new VectorMetadataIndexDriftError({
154
+ message: `No metadata index has been provisioned for ${fields.join(", ")}, so every filter naming one returns partial results.`,
155
+ action: "Run `pithy vector provision --env <env>`, then redeploy. Re-embed anything written before it.",
156
+ detail: `no ${VECTOR_PROVISIONED_VAR} var on env; declared filterable: ${fields.join(", ")}`,
157
+ });
158
+ }
159
+
160
+ const missing: string[] = [];
161
+ const mismatched: string[] = [];
162
+ for (const [name, descriptors] of declared) {
163
+ // An index absent from the record has no observed metadata indexes at all, which the comparison already
164
+ // reports as every declared field missing — no separate branch, no separate message to keep in step.
165
+ const report = compareMetadataIndexes(descriptors, record.indexes[name]?.metadataIndexes ?? []);
166
+ for (const entry of report.missing) missing.push(`${name}.${entry.propertyName} (${entry.indexType})`);
167
+ for (const entry of report.mismatched) {
168
+ mismatched.push(`${name}.${entry.propertyName} (declared ${entry.declared}, indexed as ${entry.live})`);
169
+ }
170
+ }
171
+ if (missing.length === 0 && mismatched.length === 0) return;
172
+
173
+ const problems = [
174
+ missing.length > 0 ? `missing: ${missing.join(", ")}` : "",
175
+ mismatched.length > 0 ? `mismatched: ${mismatched.join(", ")}` : "",
176
+ ].filter(Boolean);
177
+ throw new VectorMetadataIndexDriftError({
178
+ message: `Search is not configured correctly — ${problems.join("; ")}.`,
179
+ action:
180
+ "Run `pithy vector provision --env <env>` to create the missing metadata indexes, then redeploy and re-embed. A mis-typed index is fixed only by `pithy vector reset`, which rebuilds the index from the corpus.",
181
+ detail: `${problems.join("; ")}; checked against what \`pithy vector provision\` last observed, not against Cloudflare now`,
182
+ });
183
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add vector` wires into `pithy.config.ts`. Deliberately narrow:
6
+ * the capability factory, its config types, the `filterable` marker an adopter puts on a metadata field, and
7
+ * the typed filter builder. Every other module is imported by deep path (`@pithy-sh/vector/src/...`); this is
8
+ * the documented contract, not a barrel over the package.
9
+ */
10
+
11
+ export {
12
+ isVectorCapability,
13
+ VECTOR_MIGRATION_ORDER,
14
+ type VectorCapability,
15
+ type VectorOptions,
16
+ vector,
17
+ } from "./capability";
18
+ export {
19
+ DEFAULT_VECTORIZE_BINDING,
20
+ resolveIndex,
21
+ VectorConfig,
22
+ type VectorConfigInput,
23
+ VectorIndexConfig,
24
+ VectorMetric,
25
+ } from "./config/config";
26
+ export { VectorDocument, type VectorDocumentRow } from "./data/document";
27
+ export { filterable, type VectorFilter, vectorFilter } from "./index/filter";
28
+ export { type MetadataIndexDescriptor, metadataIndexes } from "./index/metadata";
29
+ export { vectorExampleSeed } from "./seeds/example";
30
+ export { VECTOR_CAPABILITY, VectorReprocessParams, vectorWorkflows } from "./workflows/specs";
@@ -0,0 +1,63 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type Kysely, sql } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * The document corpus: the durable text and metadata behind every vector in every index.
9
+ *
10
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
11
+ *
12
+ * Two details are load-bearing. The primary key is **`(indexName, id)`** — text, adopter-supplied, never
13
+ * autoincremented — because the only join between a match and its content is that id, and a second surrogate
14
+ * key would be a second thing to keep in sync. It is composite rather than `id` alone because a vector id is
15
+ * unique *within* a Vectorize index, not across them: two indexes may each hold a document called `intro`,
16
+ * and keying on `id` alone would let a write to one silently overwrite the other's content, metadata, and
17
+ * index. The `CHECK` constraint enforces Vectorize's 64-byte id ceiling in SQLite itself, measured on the id
18
+ * cast to a blob so it counts bytes rather than characters: a row whose id the index cannot address is a row
19
+ * that can be written but never searched, and catching that at insert is far cheaper than discovering it as a
20
+ * permanently missing search result.
21
+ *
22
+ * Two indexes, each for a real read. `(indexName, namespace, id)` is hydration and listing — the path every
23
+ * search result takes. `(indexName, model)` is `pithy vector reprocess`, which scans for rows whose model
24
+ * differs from the configured one; without it that scan is a full table read of the corpus.
25
+ */
26
+ export const vector_0001_documents: Migration = {
27
+ up: async (db: Kysely<unknown>): Promise<void> => {
28
+ await db.schema
29
+ .createTable("pithyVectorDocuments")
30
+ .addColumn("id", "text", (c) => c.notNull())
31
+ .addColumn("indexName", "text", (c) => c.notNull())
32
+ .addColumn("namespace", "text")
33
+ .addColumn("content", "text")
34
+ .addColumn("metadata", "text", (c) => c.notNull().defaultTo("{}"))
35
+ .addColumn("model", "text")
36
+ .addColumn("createdAt", "integer", (c) => c.notNull())
37
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
38
+ // An id is unique within one Vectorize index, never across them. Two indexes may each hold `intro`.
39
+ .addPrimaryKeyConstraint("pithyVectorDocumentsPk", ["indexName", "id"])
40
+ // Vectorize caps a vector id at 64 bytes. CAST to blob so length() counts bytes, not characters.
41
+ .addCheckConstraint("pithyVectorDocumentsIdLength", sql`length(cast(id as blob)) <= 64`)
42
+ .execute();
43
+
44
+ // Hydration and listing: a search returns ids, and this is the read that turns them back into documents.
45
+ await db.schema
46
+ .createIndex("pithyVectorDocumentsIndexIdx")
47
+ .on("pithyVectorDocuments")
48
+ .columns(["indexName", "namespace", "id"])
49
+ .execute();
50
+
51
+ // Re-embedding: `pithy vector reprocess` selects the rows whose model differs from the configured one.
52
+ await db.schema
53
+ .createIndex("pithyVectorDocumentsModelIdx")
54
+ .on("pithyVectorDocuments")
55
+ .columns(["indexName", "model"])
56
+ .execute();
57
+ },
58
+ down: async (db: Kysely<unknown>): Promise<void> => {
59
+ await db.schema.dropIndex("pithyVectorDocumentsModelIdx").execute();
60
+ await db.schema.dropIndex("pithyVectorDocumentsIndexIdx").execute();
61
+ await db.schema.dropTable("pithyVectorDocuments").execute();
62
+ },
63
+ };