@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,250 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
6
|
+
import type { VectorConfig } from "../config/config";
|
|
7
|
+
import type { MetadataIndexReport } from "../index/drift";
|
|
8
|
+
import { type MetadataIndexDescriptor, metadataIndexes } from "../index/metadata";
|
|
9
|
+
import type { ObservedMetadataIndex, ProvisionedIndex, VectorProvisionRecord } from "../index/provisioned";
|
|
10
|
+
import { VECTOR_CAPABILITY } from "../workflows/specs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The provisioning orchestration for the vector capability — the live counterpart to `pithy add vector`'s
|
|
14
|
+
* config wiring. `pithy add` writes the bindings it can; this creates what they point at.
|
|
15
|
+
*
|
|
16
|
+
* The `vectorize` and `workflows` bindings are not among the ones it can. Wrangler requires an `index_name`
|
|
17
|
+
* on the first and a `name` + `class_name` on the second, and every one of those values is an output of this
|
|
18
|
+
* run — so `add` writes neither (a partial entry stops wrangler loading the config at all) and the CLI writes
|
|
19
|
+
* both, complete, after provisioning succeeds.
|
|
20
|
+
*
|
|
21
|
+
* **The order is the whole contract.** An index is created, then every metadata index its schema declares,
|
|
22
|
+
* and only then is the worker that writes vectors deployed. Cloudflare states it plainly: *"Vectors upserted
|
|
23
|
+
* before a metadata index was created won't have their metadata contained in that index."* A vector written
|
|
24
|
+
* into the window between those two steps is permanently invisible to any filter on that field — no error,
|
|
25
|
+
* no warning, just a search that quietly returns less than it should. Doing this in the wrong order is
|
|
26
|
+
* unrecoverable without a full re-embed, which is exactly why the order lives in tested code rather than in
|
|
27
|
+
* a runbook.
|
|
28
|
+
*
|
|
29
|
+
* The live Cloudflare/wrangler steps sit behind the {@link VectorProvisioner} seam, so the ordering and the
|
|
30
|
+
* idempotency contract are unit-tested without an account. **Every step is idempotent** — find-then-create
|
|
31
|
+
* for the index and its metadata indexes, and a deploy that overwrites — so a re-run reconciles rather than
|
|
32
|
+
* fails. Vectorize applies both index and metadata-index changes asynchronously, so nothing here writes and
|
|
33
|
+
* immediately reads back.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The Vectorize index name for one configured index in one environment —
|
|
38
|
+
* `<project>-<env>-vector-<index>`.
|
|
39
|
+
*
|
|
40
|
+
* Per environment, never shared: a staging index and a prod index that share vectors mean staging's
|
|
41
|
+
* test corpus answers prod's searches, and a staging teardown deletes prod's embeddings.
|
|
42
|
+
*
|
|
43
|
+
* Per **project** for a sharper reason. Vectorize's index namespace is account-wide, and provisioning
|
|
44
|
+
* reuses an index it finds by name — so an unscoped `pithy-vector-docs-prod` would let a second Pithy
|
|
45
|
+
* project adopt this one's index, mix two corpora into one search, and delete both on either teardown.
|
|
46
|
+
* Dimensions and metric are fixed at creation, so the adoption would also silently pin the second
|
|
47
|
+
* project to the first's embedding model.
|
|
48
|
+
*
|
|
49
|
+
* **Composed through the naming facade**, which carries Vectorize's own rule: 64 bytes — not the 63
|
|
50
|
+
* every namespace was once held to — refused rather than truncated, because a shortened index name is
|
|
51
|
+
* a *different, empty* index and re-embedding a corpus is neither free nor quick. The facade also
|
|
52
|
+
* validates the project once and the environment once, so `production` is refused here rather than
|
|
53
|
+
* standing up a fourth environment nothing else knows about.
|
|
54
|
+
*/
|
|
55
|
+
export function vectorIndexName(project: string, index: string, env: string): string {
|
|
56
|
+
const scope = resourceNames(project).env(env);
|
|
57
|
+
try {
|
|
58
|
+
return scope.vectorizeIndex(`${VECTOR_CAPABILITY}-${index}`);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// The facade refuses an over-long Vectorize name as an `InternalError`, on the reasoning that the
|
|
61
|
+
// project and the environment are already validated so only author code can overflow it. That
|
|
62
|
+
// reasoning does not hold here: the trailing segment is a key out of the adopter's
|
|
63
|
+
// `pithy.config.ts`, and config validates it against Vectorize's 64 without knowing what the
|
|
64
|
+
// project and environment will spend of it. So the refusal is restated in the adopter's terms —
|
|
65
|
+
// same limit, same number, named as the fixable thing it is.
|
|
66
|
+
if (!(error instanceof PithyError)) throw error;
|
|
67
|
+
throw new ValidationError({
|
|
68
|
+
message: `The \`${index}\` index cannot be named: ${error.payload.message}`,
|
|
69
|
+
action: `Shorten the \`${index}\` index's key in pithy.config.ts, or the project name — the deployed name is <project>-<env>-${VECTOR_CAPABILITY}-<index>.`,
|
|
70
|
+
detail: error.payload.detail ?? error.payload.message,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The shape an index is created with. Fixed at creation — neither value can be changed afterwards. */
|
|
76
|
+
export interface VectorIndexShape {
|
|
77
|
+
/** Components per vector, matching the model that fills the index. */
|
|
78
|
+
dimensions: number;
|
|
79
|
+
/** How nearest neighbors are scored. */
|
|
80
|
+
metric: "cosine" | "euclidean" | "dot-product";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The live Cloudflare/wrangler seam. Each step must be idempotent. */
|
|
84
|
+
export interface VectorProvisioner {
|
|
85
|
+
/** Verify account prerequisites before anything is created (a workers.dev subdomain, Vectorize access). */
|
|
86
|
+
preflight(): Promise<void>;
|
|
87
|
+
/** Create (or reuse) the index. Idempotent — an existing index with the same shape is reused as-is. */
|
|
88
|
+
ensureIndex(indexName: string, shape: VectorIndexShape): Promise<{ name: string }>;
|
|
89
|
+
/**
|
|
90
|
+
* Create every declared metadata index that is missing, and report what was found. Idempotent.
|
|
91
|
+
*
|
|
92
|
+
* **Returning is the guarantee.** An implementation returns only once every declared index is live on the
|
|
93
|
+
* index — waiting out Cloudflare's asynchronous apply — and throws otherwise. That is what lets
|
|
94
|
+
* {@link provisionVector} record the declared set as observed, and what keeps the very next step (deploying
|
|
95
|
+
* the worker that writes vectors) out of the window where a write would be permanently unfilterable.
|
|
96
|
+
*/
|
|
97
|
+
ensureMetadataIndexes(indexName: string, declared: readonly MetadataIndexDescriptor[]): Promise<MetadataIndexReport>;
|
|
98
|
+
/** Deploy the prebuilt vector worker for this environment, bound to the provisioned indexes. */
|
|
99
|
+
deployWorker(env: string, indexNames: Record<string, string>): Promise<void>;
|
|
100
|
+
/** Delete an index and everything in it. **Destructive** — every vector is lost. Idempotent. */
|
|
101
|
+
deleteIndex(indexName: string): Promise<void>;
|
|
102
|
+
/** Start a reprocess run for one configured index and wait for it. */
|
|
103
|
+
reprocess(env: string, index: string, options: { all?: boolean; filter?: Record<string, unknown> }): Promise<unknown>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What provisioning did for one configured index. */
|
|
107
|
+
export interface VectorIndexResult {
|
|
108
|
+
/** The config's name for the index. */
|
|
109
|
+
index: string;
|
|
110
|
+
/** The Vectorize index name it was provisioned as. */
|
|
111
|
+
indexName: string;
|
|
112
|
+
/** The metadata indexes created on this run — empty on a re-run, which is what idempotent looks like. */
|
|
113
|
+
created: MetadataIndexDescriptor[];
|
|
114
|
+
/** Metadata indexes that exist but the config does not declare. Not fatal; they still spend a slot. */
|
|
115
|
+
extra: { propertyName: string; indexType: string }[];
|
|
116
|
+
/**
|
|
117
|
+
* Every metadata index live on this index when provisioning finished: the declared ones (each confirmed
|
|
118
|
+
* visible by `ensureMetadataIndexes`, which throws rather than return early) plus the undeclared ones.
|
|
119
|
+
* This is what {@link toProvisionRecord} writes into the Worker's env, and what the Worker's boot check
|
|
120
|
+
* compares its declarations against.
|
|
121
|
+
*/
|
|
122
|
+
observed: ObservedMetadataIndex[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What provisioning produced. */
|
|
126
|
+
export interface VectorProvisionResult {
|
|
127
|
+
/** The environment provisioned. */
|
|
128
|
+
env: string;
|
|
129
|
+
/** Each configured index, with what was created for it. */
|
|
130
|
+
indexes: VectorIndexResult[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Provisioning inputs: the resolved config, the project that owns it, and the environment to stand it up in. */
|
|
134
|
+
export interface VectorProvisionOptions {
|
|
135
|
+
/**
|
|
136
|
+
* The project name — the `<project>` segment every index name leads with. The root
|
|
137
|
+
* `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed: an index found by
|
|
138
|
+
* name is *reused*, so a wrong value adopts another project's corpus.
|
|
139
|
+
*/
|
|
140
|
+
project: string;
|
|
141
|
+
/** The app's resolved vector config. */
|
|
142
|
+
config: VectorConfig;
|
|
143
|
+
/** The environment to provision. Vectorize has no local emulation, so `dev` is a real remote index too. */
|
|
144
|
+
env: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The declared metadata indexes for one configured index, or none when it declares no metadata schema. */
|
|
148
|
+
function declaredIndexes(config: VectorConfig, index: string): MetadataIndexDescriptor[] {
|
|
149
|
+
const metadata = config.indexes[index]?.metadata;
|
|
150
|
+
return metadata ? metadataIndexes(metadata) : [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Provision every configured index for one environment: the index, then its metadata indexes, then the
|
|
155
|
+
* worker that will write to it. Idempotent end to end.
|
|
156
|
+
*/
|
|
157
|
+
export async function provisionVector(
|
|
158
|
+
provisioner: VectorProvisioner,
|
|
159
|
+
options: VectorProvisionOptions,
|
|
160
|
+
): Promise<VectorProvisionResult> {
|
|
161
|
+
await provisioner.preflight();
|
|
162
|
+
|
|
163
|
+
const indexNames: Record<string, string> = {};
|
|
164
|
+
const indexes: VectorIndexResult[] = [];
|
|
165
|
+
|
|
166
|
+
for (const [index, indexConfig] of Object.entries(options.config.indexes)) {
|
|
167
|
+
const indexName = vectorIndexName(options.project, index, options.env);
|
|
168
|
+
await provisioner.ensureIndex(indexName, { dimensions: indexConfig.dimensions, metric: indexConfig.metric });
|
|
169
|
+
|
|
170
|
+
// Immediately after the index, and before any worker that could write to it exists.
|
|
171
|
+
const declared = declaredIndexes(options.config, index);
|
|
172
|
+
const report = await provisioner.ensureMetadataIndexes(indexName, declared);
|
|
173
|
+
|
|
174
|
+
indexNames[index] = indexName;
|
|
175
|
+
indexes.push({
|
|
176
|
+
index,
|
|
177
|
+
indexName,
|
|
178
|
+
created: report.missing,
|
|
179
|
+
extra: report.extra,
|
|
180
|
+
// `ensureMetadataIndexes` returned, so every declared index is live. Record the declarations rather
|
|
181
|
+
// than re-listing the index: a fresh list would be a second eventually-consistent read, and could
|
|
182
|
+
// report an index that was just confirmed as absent again.
|
|
183
|
+
observed: [...declared, ...report.extra],
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The worker last: it is the thing that writes vectors, and every metadata index now exists.
|
|
188
|
+
await provisioner.deployWorker(options.env, indexNames);
|
|
189
|
+
|
|
190
|
+
return { env: options.env, indexes };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Project a provisioning result into the record the Worker boots against — the `VECTOR_PROVISIONED` var.
|
|
195
|
+
*
|
|
196
|
+
* This is the whole reason the Worker can check drift without a Cloudflare token: provisioning already holds
|
|
197
|
+
* one, already compared declared against live, and writes down what it saw. The record is therefore a
|
|
198
|
+
* statement about the last provisioning run, not about Cloudflare now, and the boot check says so.
|
|
199
|
+
*/
|
|
200
|
+
export function toProvisionRecord(result: VectorProvisionResult): VectorProvisionRecord {
|
|
201
|
+
const indexes: Record<string, ProvisionedIndex> = {};
|
|
202
|
+
for (const entry of result.indexes) {
|
|
203
|
+
indexes[entry.index] = { indexName: entry.indexName, metadataIndexes: entry.observed };
|
|
204
|
+
}
|
|
205
|
+
return { indexes };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What a reset did. */
|
|
209
|
+
export interface VectorResetResult extends VectorProvisionResult {
|
|
210
|
+
/** The indexes that were deleted and rebuilt, by their Vectorize names. */
|
|
211
|
+
deleted: string[];
|
|
212
|
+
/** The reprocess runs started, one per configured index. */
|
|
213
|
+
reprocessed: string[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Delete every configured index, rebuild it, re-provision its metadata indexes, and re-embed the corpus.
|
|
218
|
+
*
|
|
219
|
+
* This is the repair for the one failure Vectorize cannot repair in place: a metadata index added after
|
|
220
|
+
* vectors were written covers none of them, and there is no backfill. Rebuilding the index and re-embedding
|
|
221
|
+
* from the document corpus is the only way back — which is the third reason `pithy_vector_documents` exists.
|
|
222
|
+
*
|
|
223
|
+
* **Destructive by definition.** Every vector is deleted; only the D1 corpus survives, and only what it holds
|
|
224
|
+
* comes back. The confirmation gate lives in the CLI, where the environment is known.
|
|
225
|
+
*/
|
|
226
|
+
export async function resetVector(
|
|
227
|
+
provisioner: VectorProvisioner,
|
|
228
|
+
options: VectorProvisionOptions,
|
|
229
|
+
): Promise<VectorResetResult> {
|
|
230
|
+
await provisioner.preflight();
|
|
231
|
+
|
|
232
|
+
const deleted: string[] = [];
|
|
233
|
+
for (const index of Object.keys(options.config.indexes)) {
|
|
234
|
+
const indexName = vectorIndexName(options.project, index, options.env);
|
|
235
|
+
await provisioner.deleteIndex(indexName);
|
|
236
|
+
deleted.push(indexName);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Rebuild through the ordinary path, so creation order and the metadata-index rule are stated once.
|
|
240
|
+
const provisioned = await provisionVector(provisioner, options);
|
|
241
|
+
|
|
242
|
+
const reprocessed: string[] = [];
|
|
243
|
+
for (const index of Object.keys(options.config.indexes)) {
|
|
244
|
+
// `all`: the rebuilt index holds nothing, so every document must be re-embedded regardless of its model.
|
|
245
|
+
await provisioner.reprocess(options.env, index, { all: true });
|
|
246
|
+
reprocessed.push(index);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { ...provisioned, deleted, reprocessed };
|
|
250
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type HostVectorizeBinding,
|
|
6
|
+
hostWorkflowsFor,
|
|
7
|
+
resolveWorkflowHost,
|
|
8
|
+
type WorkflowHostTemplate,
|
|
9
|
+
} from "@pithy-sh/core/src/workflow/host";
|
|
10
|
+
import type { VectorConfig } from "../config/config";
|
|
11
|
+
import { toWorkerConfig } from "../config/workerConfig";
|
|
12
|
+
import { VECTOR_CAPABILITY, vectorWorkflowRegistry } from "../workflows/specs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the vector worker's committed `wrangler.jsonc` template into one environment's standalone config.
|
|
16
|
+
* Thin over core's {@link resolveWorkflowHost}: the generic resolver owns the mechanics (clone, fill by
|
|
17
|
+
* binding name, stamp `ENVIRONMENT`), and this file owns only what is vector's. Pure — the caller parses
|
|
18
|
+
* the template and writes the result.
|
|
19
|
+
*
|
|
20
|
+
* Two things are vector's own.
|
|
21
|
+
*
|
|
22
|
+
* **The `vectorize` array is rebuilt, not filled.** A template declares the bindings a capability has *by
|
|
23
|
+
* default*; how many Vectorize bindings a project needs is a fact about its config, because one binding
|
|
24
|
+
* addresses exactly one index. So the array is regenerated from the configured indexes and then handed to the
|
|
25
|
+
* generic resolver to fill and mark remote.
|
|
26
|
+
*
|
|
27
|
+
* **`VECTOR_CONFIG` is a projection.** An index's `metadata` is a live Zod schema; `JSON.stringify` would
|
|
28
|
+
* hand the worker an unusable husk. {@link toWorkerConfig} projects the config into the serializable facts
|
|
29
|
+
* the worker needs — including the *result* of introspecting each metadata schema.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The resolved ids and per-env values for one environment's vector-worker deploy. */
|
|
33
|
+
export interface VectorConfigParams {
|
|
34
|
+
/**
|
|
35
|
+
* The project name — the `<project>` segment the deployed worker and Workflow names lead with. The
|
|
36
|
+
* root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed.
|
|
37
|
+
*/
|
|
38
|
+
project: string;
|
|
39
|
+
/** The target environment. */
|
|
40
|
+
env: string;
|
|
41
|
+
/** The app database id for this environment — where the document corpus lives. */
|
|
42
|
+
appDatabaseId: string;
|
|
43
|
+
/** Config index name → provisioned Vectorize index name, from `provisionVector`. */
|
|
44
|
+
indexNames: Record<string, string>;
|
|
45
|
+
/** The app's resolved vector config. */
|
|
46
|
+
config: VectorConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Fill the template for one environment. */
|
|
50
|
+
export function resolveVectorConfig(template: WorkflowHostTemplate, params: VectorConfigParams): WorkflowHostTemplate {
|
|
51
|
+
const { project, env, appDatabaseId, indexNames, config } = params;
|
|
52
|
+
|
|
53
|
+
// One Vectorize binding per configured index, in config order. `index_name` is a placeholder here; the
|
|
54
|
+
// generic resolver fills it from `vectorizeIndexNames` below, so the mapping lives in exactly one place.
|
|
55
|
+
const vectorize: HostVectorizeBinding[] = Object.entries(config.indexes).map(([index, indexConfig]) => ({
|
|
56
|
+
binding: indexConfig.binding,
|
|
57
|
+
index_name: indexNames[index] ?? "",
|
|
58
|
+
remote: true,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
const vectorizeIndexNames: Record<string, string> = {};
|
|
62
|
+
for (const [index, indexConfig] of Object.entries(config.indexes)) {
|
|
63
|
+
const name = indexNames[index];
|
|
64
|
+
if (name) vectorizeIndexNames[indexConfig.binding] = name;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return resolveWorkflowHost(
|
|
68
|
+
{ ...template, vectorize },
|
|
69
|
+
{
|
|
70
|
+
project,
|
|
71
|
+
capability: VECTOR_CAPABILITY,
|
|
72
|
+
env,
|
|
73
|
+
databaseIds: { DB: appDatabaseId },
|
|
74
|
+
vectorizeIndexNames,
|
|
75
|
+
// The reprocess Workflow, derived from vector's own specs. A Workflow name is account-scoped, so
|
|
76
|
+
// the project has to reach it — and only the registry knows both the project and the job.
|
|
77
|
+
workflows: hostWorkflowsFor(vectorWorkflowRegistry, { project, capability: VECTOR_CAPABILITY, env }).workflows,
|
|
78
|
+
// Neither Vectorize nor Workers AI has a local emulation, and a Workflow host always runs locally in
|
|
79
|
+
// `wrangler dev` — without `remote` the bindings resolve to nothing and every re-embed fails locally
|
|
80
|
+
// for a reason that reads like a code fault.
|
|
81
|
+
remoteBindings: [...Object.values(config.indexes).map((index) => index.binding), "AI"],
|
|
82
|
+
vars: { VECTOR_CONFIG: JSON.stringify(toWorkerConfig(config, (index) => indexNames[index] ?? "")) },
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
|
|
5
|
+
import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
import { VectorDocument } from "../data/document";
|
|
7
|
+
import { VECTOR_DOCUMENTS_TABLE } from "../data/tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the example set sorts in the project's seed registry. It runs after `auth` (100), whose example
|
|
11
|
+
* seeds the users these documents belong to, so the owning identities exist before anything references them.
|
|
12
|
+
*
|
|
13
|
+
* It need not line up with `VECTOR_MIGRATION_ORDER` — a different registry, composed separately by
|
|
14
|
+
* `pithy seed`.
|
|
15
|
+
*/
|
|
16
|
+
const VECTOR_EXAMPLE_SEED_ORDER = 240;
|
|
17
|
+
|
|
18
|
+
/** The index these demo documents belong to — the name `pithy add vector` scaffolds into pithy.config.ts. */
|
|
19
|
+
const EXAMPLE_INDEX = "docs";
|
|
20
|
+
|
|
21
|
+
const now = () => new Date();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A small demo corpus owned by the canonical example cast ({@link EXAMPLE_ADA} et al.). The owner rides in
|
|
25
|
+
* `metadata.ownerId` rather than in a column, because that is how a real corpus scopes: a document's owner
|
|
26
|
+
* is exactly the kind of field an index marks `filterable`, and putting it in metadata means the demo shows
|
|
27
|
+
* the filter path rather than a shape only the seed uses.
|
|
28
|
+
*
|
|
29
|
+
* **`model` is null on purpose.** A seed writes rows into D1; it cannot call Workers AI, so it cannot
|
|
30
|
+
* produce vectors. These documents are a durable corpus that is not yet searchable — which is precisely the
|
|
31
|
+
* state `pithy vector reprocess` exists to resolve, and running it against a freshly seeded dev environment
|
|
32
|
+
* is the shortest honest demonstration of the whole capability.
|
|
33
|
+
*
|
|
34
|
+
* Composed in only when the project turns on `seed.includeExamples`, and only for `dev` and `staging` — an
|
|
35
|
+
* example fixture never targets `prod`.
|
|
36
|
+
*/
|
|
37
|
+
export const vectorExampleSeed: SeedSet = defineSeed({
|
|
38
|
+
name: "example",
|
|
39
|
+
order: VECTOR_EXAMPLE_SEED_ORDER,
|
|
40
|
+
environments: ["dev", "staging"],
|
|
41
|
+
example: true,
|
|
42
|
+
d1: [
|
|
43
|
+
d1SeedGroup("app", VECTOR_DOCUMENTS_TABLE, VectorDocument, [
|
|
44
|
+
{
|
|
45
|
+
id: "example-doc-ada",
|
|
46
|
+
indexName: EXAMPLE_INDEX,
|
|
47
|
+
namespace: null,
|
|
48
|
+
content:
|
|
49
|
+
"An analytical engine can weave algebraic patterns the way a loom weaves flowers and leaves. Its power is in the general rule, not the particular sum.",
|
|
50
|
+
metadata: { ownerId: EXAMPLE_ADA.id, title: "On the analytical engine" },
|
|
51
|
+
model: null,
|
|
52
|
+
createdAt: now(),
|
|
53
|
+
updatedAt: now(),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "example-doc-grace",
|
|
57
|
+
indexName: EXAMPLE_INDEX,
|
|
58
|
+
namespace: null,
|
|
59
|
+
content:
|
|
60
|
+
"A program should be written in words people can read. Compilers exist so that the machine, not the person, does the translating.",
|
|
61
|
+
metadata: { ownerId: EXAMPLE_GRACE.id, title: "On compilers" },
|
|
62
|
+
model: null,
|
|
63
|
+
createdAt: now(),
|
|
64
|
+
updatedAt: now(),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "example-doc-alan",
|
|
68
|
+
indexName: EXAMPLE_INDEX,
|
|
69
|
+
namespace: null,
|
|
70
|
+
content:
|
|
71
|
+
"A machine may be said to think if a person conversing with it cannot tell it from another person. The question is behavior, not substance.",
|
|
72
|
+
metadata: { ownerId: EXAMPLE_ALAN.id, title: "On machine intelligence" },
|
|
73
|
+
model: null,
|
|
74
|
+
createdAt: now(),
|
|
75
|
+
updatedAt: now(),
|
|
76
|
+
},
|
|
77
|
+
]),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/vector knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/vector";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { VectorIndexConfig } from "../config/config";
|
|
5
|
+
import type { VectorDocument } from "../data/document";
|
|
6
|
+
import type { DocumentStore } from "../data/documents";
|
|
7
|
+
import { embedBatched, type VectorAi } from "../embed/embed";
|
|
8
|
+
import { type CompiledFilter, matchesCompiledFilter } from "../index/filter";
|
|
9
|
+
import { upsertVectors, type VectorStore, type VectorUpsert } from "../index/index";
|
|
10
|
+
import { MAX_UPSERT_BATCH } from "../index/limits";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Re-embed a corpus, one durable page at a time.
|
|
14
|
+
*
|
|
15
|
+
* The orchestration is pure: the Workflow step runner, the document store, the index, and the AI binding all
|
|
16
|
+
* arrive as parameters, so the whole of it — including resuming mid-run — is tested with fakes and no
|
|
17
|
+
* network. `workflows/worker.ts` is the thin `WorkflowEntrypoint` that supplies the real ones.
|
|
18
|
+
*
|
|
19
|
+
* **Pagination is keyset, not offset, and that is the correctness argument for the whole file.** The default
|
|
20
|
+
* pass selects rows whose `model` differs from the configured one and then *sets* that model on the rows it
|
|
21
|
+
* writes — so the result set shrinks underneath the scan. With `LIMIT/OFFSET` that is the classic skip bug:
|
|
22
|
+
* page two starts at row 1,000 of a set that just lost its first 1,000 members, and half the corpus is never
|
|
23
|
+
* re-embedded, silently. Reading `id > cursor` in id order cannot skip and cannot repeat, whatever the
|
|
24
|
+
* predicate does to the rows behind the cursor.
|
|
25
|
+
*
|
|
26
|
+
* **Each page is one `step.do`.** Workflow steps are journalled, so an instance that dies at page 4,000
|
|
27
|
+
* resumes at page 4,000 with the same cursor rather than re-embedding four million documents. The step names
|
|
28
|
+
* are derived from a page counter, so a replay asks for the same steps in the same order — which is what
|
|
29
|
+
* makes the journal usable at all.
|
|
30
|
+
*
|
|
31
|
+
* A row written during the run whose id sorts *behind* the cursor is not picked up. That is the honest cost
|
|
32
|
+
* of keyset order, and it is not a problem in practice: a document written after the model changed is
|
|
33
|
+
* embedded with the new model by the write path anyway.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The Workflow step runner, structurally. Injectable, so a test can journal and replay it. */
|
|
37
|
+
export interface ReprocessStep {
|
|
38
|
+
/** Run a named step, or return its journalled result if this instance already completed it. */
|
|
39
|
+
do<T>(name: string, callback: () => Promise<T>): Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a reprocess run needs. Every one is a seam. */
|
|
43
|
+
export interface ReprocessDeps {
|
|
44
|
+
/** The document corpus — the source of the text and the destination of the new model stamp. */
|
|
45
|
+
documents: DocumentStore;
|
|
46
|
+
/** The Vectorize index the re-embedded vectors are written to. */
|
|
47
|
+
store: VectorStore;
|
|
48
|
+
/** The Workers AI binding. */
|
|
49
|
+
ai: VectorAi;
|
|
50
|
+
/** The index's resolved config — the model to embed with and the shape to prove. */
|
|
51
|
+
index: VectorIndexConfig;
|
|
52
|
+
/** The index's config name, which scopes the corpus rows. */
|
|
53
|
+
indexName: string;
|
|
54
|
+
/** Clock, injected so the stamped `updatedAt` is assertable. */
|
|
55
|
+
now(): Date;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How much of the corpus a run covers. */
|
|
59
|
+
export interface ReprocessOptions {
|
|
60
|
+
/** Re-embed every document, not only the ones whose model drifted. */
|
|
61
|
+
all?: boolean;
|
|
62
|
+
/** Narrow the run to documents whose metadata matches. Compile it before passing it in. */
|
|
63
|
+
filter?: CompiledFilter;
|
|
64
|
+
/** Documents per step. Defaults to — and is capped at — the Vectorize upsert ceiling. */
|
|
65
|
+
pageSize?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What one page did. Returned through the step, so a replay restores it exactly. */
|
|
69
|
+
interface PageResult {
|
|
70
|
+
/** The id to read after on the next page, or null when the corpus is exhausted. */
|
|
71
|
+
cursor: string | null;
|
|
72
|
+
/** Rows read. */
|
|
73
|
+
scanned: number;
|
|
74
|
+
/** Rows re-embedded and written. */
|
|
75
|
+
reembedded: number;
|
|
76
|
+
/** Rows that matched but hold no content, so there is nothing to embed. */
|
|
77
|
+
skipped: number;
|
|
78
|
+
/** Whether this was the last page. */
|
|
79
|
+
done: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The tally a run reports. */
|
|
83
|
+
export interface ReprocessReport {
|
|
84
|
+
/** The index that was reprocessed. */
|
|
85
|
+
indexName: string;
|
|
86
|
+
/** How many pages the run took — one Workflow step each. */
|
|
87
|
+
pages: number;
|
|
88
|
+
/** Rows read. */
|
|
89
|
+
scanned: number;
|
|
90
|
+
/** Rows re-embedded and written back. */
|
|
91
|
+
reembedded: number;
|
|
92
|
+
/** Rows selected but skipped because the corpus holds no text for them. */
|
|
93
|
+
skipped: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Re-embed one page's documents and write them back. The body of a step. */
|
|
97
|
+
async function reembedPage(deps: ReprocessDeps, documents: readonly VectorDocument[]): Promise<number> {
|
|
98
|
+
const embeddable = documents.filter((document) => document.content !== null);
|
|
99
|
+
if (embeddable.length === 0) return 0;
|
|
100
|
+
|
|
101
|
+
const vectors = await embedBatched(
|
|
102
|
+
deps.ai,
|
|
103
|
+
deps.index,
|
|
104
|
+
embeddable.map((document) => document.content as string),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const upserts: VectorUpsert[] = embeddable.map((document, position) => ({
|
|
108
|
+
id: document.id,
|
|
109
|
+
values: vectors[position] as number[],
|
|
110
|
+
metadata: document.metadata,
|
|
111
|
+
...(document.namespace ? { namespace: document.namespace } : {}),
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
await upsertVectors(deps.store, deps.index, upserts);
|
|
115
|
+
// Stamped only after the write is accepted, so a step that dies mid-page leaves the rows selectable by the
|
|
116
|
+
// next run rather than marked done.
|
|
117
|
+
await deps.documents.markEmbedded(
|
|
118
|
+
deps.indexName,
|
|
119
|
+
embeddable.map((document) => document.id),
|
|
120
|
+
deps.index.model,
|
|
121
|
+
deps.now(),
|
|
122
|
+
);
|
|
123
|
+
return embeddable.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Re-embed an index's documents. Resumable by construction: every page is a journalled step keyed by its
|
|
128
|
+
* page number, and the cursor that drives the next page comes out of the previous step's result.
|
|
129
|
+
*/
|
|
130
|
+
export async function reprocessIndex(
|
|
131
|
+
deps: ReprocessDeps,
|
|
132
|
+
step: ReprocessStep,
|
|
133
|
+
options: ReprocessOptions = {},
|
|
134
|
+
): Promise<ReprocessReport> {
|
|
135
|
+
const limit = Math.min(options.pageSize ?? MAX_UPSERT_BATCH, MAX_UPSERT_BATCH);
|
|
136
|
+
const report: ReprocessReport = { indexName: deps.indexName, pages: 0, scanned: 0, reembedded: 0, skipped: 0 };
|
|
137
|
+
|
|
138
|
+
let cursor: string | null = null;
|
|
139
|
+
let page = 0;
|
|
140
|
+
|
|
141
|
+
for (;;) {
|
|
142
|
+
page += 1;
|
|
143
|
+
const after = cursor;
|
|
144
|
+
// Zero-padded so the step names sort the way the pages ran — the journal is read by humans too.
|
|
145
|
+
const result: PageResult = await step.do(`page-${String(page).padStart(6, "0")}`, async () => {
|
|
146
|
+
const rows = await deps.documents.page({
|
|
147
|
+
indexName: deps.indexName,
|
|
148
|
+
after,
|
|
149
|
+
limit,
|
|
150
|
+
...(options.all ? {} : { staleModel: deps.index.model }),
|
|
151
|
+
});
|
|
152
|
+
if (rows.length === 0) return { cursor: after, scanned: 0, reembedded: 0, skipped: 0, done: true };
|
|
153
|
+
|
|
154
|
+
const last = rows[rows.length - 1] as VectorDocument;
|
|
155
|
+
const selected = options.filter
|
|
156
|
+
? rows.filter((row) => matchesCompiledFilter(row.metadata, options.filter as CompiledFilter))
|
|
157
|
+
: rows;
|
|
158
|
+
const reembedded = await reembedPage(deps, selected);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
cursor: last.id,
|
|
162
|
+
scanned: rows.length,
|
|
163
|
+
reembedded,
|
|
164
|
+
skipped: selected.length - reembedded,
|
|
165
|
+
// A short page means the corpus is exhausted. A full page might still be the last one; the next
|
|
166
|
+
// step reads zero rows and ends the run, which costs one cheap query and no special case.
|
|
167
|
+
done: rows.length < limit,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
report.pages = page;
|
|
172
|
+
report.scanned += result.scanned;
|
|
173
|
+
report.reembedded += result.reembedded;
|
|
174
|
+
report.skipped += result.skipped;
|
|
175
|
+
cursor = result.cursor;
|
|
176
|
+
if (result.done) break;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return report;
|
|
180
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **What a reprocess retries, and what it refuses to.**
|
|
8
|
+
*
|
|
9
|
+
* A reprocess is a corpus-sized job: one journalled step per keyset page, thousands of them for a
|
|
10
|
+
* corpus worth re-embedding at all. That shape decides the policy in both directions. A page lost to a
|
|
11
|
+
* model blip takes the whole instance's remaining pages with it, so an outage must be retryable; and a
|
|
12
|
+
* refusal that will not change — a model pinned to the wrong dimensions, a filter naming a field the
|
|
13
|
+
* index cannot filter on — must fail on page one rather than on page one after five attempts
|
|
14
|
+
* (pithy-sh/pithy#348).
|
|
15
|
+
*
|
|
16
|
+
* ## Retryable, and why
|
|
17
|
+
*
|
|
18
|
+
* - **`core/upstream_failed`** — Workers AI rejected the embedding call. Raised at the one wrap around
|
|
19
|
+
* `ai.run` in `embedTexts`, and it means the model did not answer. Re-embedding a page is idempotent —
|
|
20
|
+
* the rows are stamped with the new model only *after* the upsert is accepted, so a page that died
|
|
21
|
+
* mid-write is still selectable by the next attempt rather than marked done.
|
|
22
|
+
* - **A transient D1 fault** — the document corpus: the page read and the `markEmbedded` write.
|
|
23
|
+
* Classified in core by `withD1Retry`, never restated here.
|
|
24
|
+
*
|
|
25
|
+
* ## Terminal, and why
|
|
26
|
+
*
|
|
27
|
+
* - **`vector/dimension_mismatch`** — the configured model produced vectors the index cannot hold. An
|
|
28
|
+
* index's dimensions are fixed at creation; this is a config error caught on the first page, which is
|
|
29
|
+
* exactly where you want it, and it wants a new index rather than a backoff.
|
|
30
|
+
* - **`vector/index_not_found`** — no such index in `VECTOR_CONFIG`. Raised before the first step even
|
|
31
|
+
* runs, and answered by `pithy vector provision`.
|
|
32
|
+
* - **`vector/unfilterable_field`, `vector/filter_too_large`, `vector/topk_exceeded`,
|
|
33
|
+
* `vector/metadata_too_large`** — limits and shapes. Every one is deterministic in the input, and
|
|
34
|
+
* three of them are compiled against the provisioned metadata indexes *before* a single document is
|
|
35
|
+
* re-embedded.
|
|
36
|
+
* - **`vector/metadata_index_drift`** — Vectorize is missing an index the config declares. Provisioning,
|
|
37
|
+
* not weather.
|
|
38
|
+
* - **`core/internal`** — the model or the store answered in a shape nobody recognizes, or the binding
|
|
39
|
+
* does not expose the method. A shape is not a transient.
|
|
40
|
+
* - **`validation/invalid_input`** — an empty batch, a `topK` below one, a namespace the schema refuses.
|
|
41
|
+
*
|
|
42
|
+
* **What this cannot say.** A Vectorize `upsert` that fails at the binding throws whatever the binding
|
|
43
|
+
* threw — not a `PithyError`, so `unclassified`, so terminal. The step stamps `markEmbedded` only after
|
|
44
|
+
* the upsert is accepted, so the page is re-selected by the next run rather than skipped; the run is
|
|
45
|
+
* lost, the corpus is not. That is a default rather than a decision, and no policy record can turn it
|
|
46
|
+
* into one.
|
|
47
|
+
*/
|
|
48
|
+
export const vectorWorkflowRetry: WorkflowRetryPolicy = {
|
|
49
|
+
capability: "vector",
|
|
50
|
+
retryable: {
|
|
51
|
+
"core/upstream_failed":
|
|
52
|
+
"The embedding model did not answer; a page is re-selectable until its upsert is accepted, so re-driving it repeats nothing.",
|
|
53
|
+
},
|
|
54
|
+
};
|