@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,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
5
|
+
import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one durable job vector owns, declared once.
|
|
10
|
+
*
|
|
11
|
+
* Re-embedding a corpus is the job that has to be a Workflow. Ten million vectors is an ordinary size for a
|
|
12
|
+
* Vectorize index, and re-embedding them is hours of Workers AI calls: a request would time out, a queue
|
|
13
|
+
* consumer would lose its place, and a script on someone's laptop would die with the lid. A Workflow's steps
|
|
14
|
+
* are journalled, so an interrupted run resumes at the page it reached rather than starting over — and
|
|
15
|
+
* starting over is not merely slow, it is what makes people not re-embed at all.
|
|
16
|
+
*
|
|
17
|
+
* The spec is `optional: true`: the binding exists only once `pithy vector provision` has deployed the
|
|
18
|
+
* reprocess worker, and a project that has not provisioned must still boot and serve every search route.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The capability name — the first segment of the dispatch key and of every deployed workflow name. */
|
|
22
|
+
export const VECTOR_CAPABILITY = "vector";
|
|
23
|
+
|
|
24
|
+
/** The parameters a reprocess run takes: which index, how much of it, and how it is scoped. */
|
|
25
|
+
export const VectorReprocessParams = z
|
|
26
|
+
.object({
|
|
27
|
+
index: z.string().min(1).describe("The index to re-embed, as named in pithy.config.ts. One run covers one index."),
|
|
28
|
+
all: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"Re-embed every document, not just the drifted ones. The default pass selects only rows whose model differs from the configured one — including rows that were never embedded at all.",
|
|
33
|
+
),
|
|
34
|
+
filter: z
|
|
35
|
+
.record(z.string(), z.unknown())
|
|
36
|
+
.optional()
|
|
37
|
+
.describe(
|
|
38
|
+
"Narrow the run to documents whose metadata matches this filter. Evaluated against the corpus rows, so it uses the same operators a query filter does.",
|
|
39
|
+
),
|
|
40
|
+
pageSize: z
|
|
41
|
+
.number()
|
|
42
|
+
.int()
|
|
43
|
+
.min(1)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe(
|
|
46
|
+
"Documents per Workflow step. Defaults to the Vectorize upsert ceiling of 1,000, which is also the largest batch one step can write.",
|
|
47
|
+
),
|
|
48
|
+
})
|
|
49
|
+
.describe("The instance parameters of a reprocess run — which index to re-embed, and how much of it.");
|
|
50
|
+
export type VectorReprocessParams = z.infer<typeof VectorReprocessParams>;
|
|
51
|
+
|
|
52
|
+
/** Vector's durable jobs, keyed by job name. The key is the second segment of the `vector/<job>` dispatch key. */
|
|
53
|
+
export const vectorWorkflows = {
|
|
54
|
+
reprocess: {
|
|
55
|
+
binding: "VECTOR_REPROCESS",
|
|
56
|
+
className: "VectorReprocessWorkflow",
|
|
57
|
+
params: VectorReprocessParams,
|
|
58
|
+
optional: true,
|
|
59
|
+
},
|
|
60
|
+
} as const satisfies WorkflowSpecMap;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Vector's jobs as a dispatch registry, keyed `vector/<job>`. Built from the same map and core's
|
|
64
|
+
* {@link workflowKey}, so the dispatch key, the deployed name, and the binding cannot drift apart.
|
|
65
|
+
*/
|
|
66
|
+
export const vectorWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
|
|
67
|
+
Object.entries(vectorWorkflows).map(([job, spec]) => {
|
|
68
|
+
const key = workflowKey(VECTOR_CAPABILITY, job);
|
|
69
|
+
return [key, { key, capability: VECTOR_CAPABILITY, job, spec }];
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
5
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
|
|
9
|
+
import { workflowHostEntry } from "@pithy-sh/core/src/workflow/hostEntry";
|
|
10
|
+
import type { VectorIndexConfig } from "../config/config";
|
|
11
|
+
import { VectorWorkerConfig, type VectorWorkerIndex } from "../config/workerConfig";
|
|
12
|
+
import { vectorDocuments } from "../data/documents";
|
|
13
|
+
import { vectorDatabase } from "../data/tables";
|
|
14
|
+
import type { VectorAi } from "../embed/embed";
|
|
15
|
+
import { VectorIndexNotFoundError } from "../error/errors";
|
|
16
|
+
import { compileFilter } from "../index/filter";
|
|
17
|
+
import type { VectorStore } from "../index/index";
|
|
18
|
+
import { type ReprocessReport, reprocessIndex } from "./reprocess";
|
|
19
|
+
import { vectorWorkflowRetry } from "./retryPolicy";
|
|
20
|
+
import { VECTOR_CAPABILITY, VectorReprocessParams } from "./specs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The prebuilt vector worker. `pithy vector provision` deploys one per environment; the adopter authors no
|
|
24
|
+
* code for it. It hosts the reprocess Workflow — a thin durable shell around the tested orchestration in
|
|
25
|
+
* `reprocess.ts` — and nothing else. Every search route lives in the app worker.
|
|
26
|
+
*
|
|
27
|
+
* This module imports `cloudflare:workers`, so it runs only in the Workers runtime and is excluded from the
|
|
28
|
+
* node meta-test glob.
|
|
29
|
+
*
|
|
30
|
+
* Its config arrives as `VECTOR_CONFIG`, a **projection** of the capability's config rather than the config
|
|
31
|
+
* itself: an index's `metadata` is a live Zod schema, which cannot travel through a wrangler var. The
|
|
32
|
+
* projection carries the introspected filterable descriptors instead, which is all this worker needs to
|
|
33
|
+
* validate a `--filter`.
|
|
34
|
+
*
|
|
35
|
+
* **The default export is what makes this an ES module** (#426). It exports one Workflow class and has no
|
|
36
|
+
* cron, so until now it exported no default — and wrangler infers a worker's module format from exactly
|
|
37
|
+
* that, so the build read it as a service worker and refused `cloudflare:workers` outright. The host did not
|
|
38
|
+
* build, `pithy dev` carried on past it, and a reprocess dispatched at it went nowhere. The refusal it
|
|
39
|
+
* exports is the honest body for a host with no request surface; see `@pithy-sh/core/src/workflow/hostEntry`.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The vector worker's env: the corpus database, the AI binding, its config, and one Vectorize binding per index. */
|
|
43
|
+
export interface VectorWorkerEnv {
|
|
44
|
+
/** The app database the document corpus lives in. */
|
|
45
|
+
DB: D1Database;
|
|
46
|
+
/** The Workers AI binding. Marked remote in the host config — Workers AI has no local emulation. */
|
|
47
|
+
AI: VectorAi;
|
|
48
|
+
/** The projected vector config as one JSON string, filled at provision. */
|
|
49
|
+
VECTOR_CONFIG?: string;
|
|
50
|
+
/** Each index's Vectorize binding, addressed by the name its config declares. */
|
|
51
|
+
[binding: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse the projected config off the env. A worker with no config cannot do anything but say so. */
|
|
55
|
+
function workerConfig(env: VectorWorkerEnv): VectorWorkerConfig {
|
|
56
|
+
if (!env.VECTOR_CONFIG) {
|
|
57
|
+
throw new InternalError({
|
|
58
|
+
message: "The vector worker has no configuration.",
|
|
59
|
+
action: "Run `pithy vector provision` — it deploys this worker with its config.",
|
|
60
|
+
detail: "VECTOR_CONFIG was absent from the worker env",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return VectorWorkerConfig.parse(JSON.parse(env.VECTOR_CONFIG));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The index's config in the shape the shared code takes. Metadata is absent by design — see the file note. */
|
|
67
|
+
function indexConfig(index: VectorWorkerIndex): VectorIndexConfig {
|
|
68
|
+
return {
|
|
69
|
+
model: index.model,
|
|
70
|
+
dimensions: index.dimensions,
|
|
71
|
+
metric: index.metric,
|
|
72
|
+
binding: index.binding,
|
|
73
|
+
...(index.namespace !== undefined ? { namespace: index.namespace } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Re-embed a corpus, one journalled page per step. */
|
|
78
|
+
export class VectorReprocessWorkflow extends WorkflowEntrypoint<VectorWorkerEnv, VectorReprocessParams> {
|
|
79
|
+
override async run(event: WorkflowEvent<VectorReprocessParams>, step: WorkflowStep): Promise<ReprocessReport> {
|
|
80
|
+
const params = VectorReprocessParams.parse(event.payload);
|
|
81
|
+
const config = workerConfig(this.env);
|
|
82
|
+
|
|
83
|
+
const index = config.indexes[params.index];
|
|
84
|
+
if (!index) {
|
|
85
|
+
throw new VectorIndexNotFoundError({
|
|
86
|
+
detail: `no index '${params.index}' in VECTOR_CONFIG; deployed: ${Object.keys(config.indexes).join(", ") || "none"}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const store = this.env[index.binding] as VectorStore | undefined;
|
|
91
|
+
if (!store) {
|
|
92
|
+
throw new InternalError({
|
|
93
|
+
message: "The vector worker cannot reach that index.",
|
|
94
|
+
action: "Re-run `pithy vector provision` so the worker binds every configured index.",
|
|
95
|
+
detail: `the \`${index.binding}\` Vectorize binding was not present on the worker env`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Compiled against the *provisioned* metadata indexes, so a filter naming a field this index cannot
|
|
100
|
+
// filter on fails before a single document is re-embedded.
|
|
101
|
+
const filter = params.filter ? compileFilter(index.filterable, params.filter) : undefined;
|
|
102
|
+
|
|
103
|
+
return reprocessIndex(
|
|
104
|
+
{
|
|
105
|
+
documents: vectorDocuments(vectorDatabase(this.env.DB)),
|
|
106
|
+
store,
|
|
107
|
+
ai: this.env.AI,
|
|
108
|
+
index: indexConfig(index),
|
|
109
|
+
indexName: params.index,
|
|
110
|
+
now: () => new Date(),
|
|
111
|
+
},
|
|
112
|
+
// Under `vectorWorkflowRetry`: an unreachable embedding model re-drives the page, and a pinned
|
|
113
|
+
// dimension, a filter the index cannot answer, or a shape nobody recognizes fails on page one —
|
|
114
|
+
// which is where a config error belongs in a job thousands of pages long.
|
|
115
|
+
classifiedSteps(step, vectorWorkflowRetry, NonRetryableError),
|
|
116
|
+
{
|
|
117
|
+
...(params.all !== undefined ? { all: params.all } : {}),
|
|
118
|
+
...(filter ? { filter } : {}),
|
|
119
|
+
...(params.pageSize !== undefined ? { pageSize: params.pageSize } : {}),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The module's default export, and therefore its format. See `hostEntry` for why a Workflow host needs one
|
|
127
|
+
* at all, and why this one refuses rather than being empty: nothing reaches this worker over HTTP — search
|
|
128
|
+
* lives in the app worker, and a re-embed starts on the `VECTOR_REPROCESS` binding.
|
|
129
|
+
*/
|
|
130
|
+
export default workflowHostEntry(VECTOR_CAPABILITY);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The prebuilt vector worker. Like the email and media workers, this is a TEMPLATE, not a wrangler
|
|
3
|
+
// env-stanza file: each environment is a separate worker. `pithy vector provision` resolves it into one
|
|
4
|
+
// complete config per environment — filling the `<...>` placeholders and the per-index Vectorize bindings
|
|
5
|
+
// — and deploys each with `wrangler deploy --config <resolved>`. The adopter authors none of it.
|
|
6
|
+
// Resolved per project and env → <project>-staging-vector / <project>-prod-vector. Worker
|
|
7
|
+
// script names are account-scoped, so the project segment is what stops a second Pithy project's
|
|
8
|
+
// deploy overwriting this one's running worker instead of colliding with it.
|
|
9
|
+
"name": "pithy-vector",
|
|
10
|
+
"main": "./worker.ts",
|
|
11
|
+
// The compatibility date every Worker in this repository runs on. Stated once in the repository
|
|
12
|
+
// root's `compatibility.ts` and copied here because JSONC cannot import it —
|
|
13
|
+
// `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
|
|
14
|
+
"compatibility_date": "2026-06-01",
|
|
15
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
16
|
+
|
|
17
|
+
// No public URL. Search lives in the app worker; this one is reached only by Workflow dispatch.
|
|
18
|
+
"workers_dev": false,
|
|
19
|
+
|
|
20
|
+
// The app database — the document corpus a re-embed reads its text out of.
|
|
21
|
+
"d1_databases": [{ "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" }],
|
|
22
|
+
|
|
23
|
+
// One entry per configured index, rewritten at provision from the config's `binding` names. `remote` is
|
|
24
|
+
// not optional here: Vectorize has no local emulation, and a Workflow host always runs locally in dev.
|
|
25
|
+
"vectorize": [{ "binding": "VECTORIZE", "index_name": "<filled-at-provision>", "remote": true }],
|
|
26
|
+
|
|
27
|
+
// Workers AI — the embedding model. Remote for the same reason.
|
|
28
|
+
"ai": { "binding": "AI", "remote": true },
|
|
29
|
+
|
|
30
|
+
// The one Workflow this worker hosts. `class_name` matches the exported WorkflowEntrypoint subclass;
|
|
31
|
+
// `binding` is what the app worker and `pithy vector reprocess` dispatch to.
|
|
32
|
+
"workflows": [
|
|
33
|
+
{ "binding": "VECTOR_REPROCESS", "name": "pithy-vector-reprocess", "class_name": "VectorReprocessWorkflow" }
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
"vars": {
|
|
37
|
+
// The projected vector config as one JSON blob, filled at provision. A projection, not the config: an
|
|
38
|
+
// index's metadata is a Zod schema, and a schema cannot travel through a wrangler var.
|
|
39
|
+
"VECTOR_CONFIG": "<filled-at-provision>",
|
|
40
|
+
"ENVIRONMENT": "<filled-at-provision>"
|
|
41
|
+
}
|
|
42
|
+
}
|