@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,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
+ };