@pithy-sh/cloudflare 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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/package.json +48 -0
  4. package/src/ai/aiManager.ts +227 -0
  5. package/src/ai/vectorizeManager.ts +161 -0
  6. package/src/ai/vectorizeProvisioner.ts +266 -0
  7. package/src/client/accounts.ts +80 -0
  8. package/src/client/clients.ts +244 -0
  9. package/src/client/errors.ts +143 -0
  10. package/src/client/manager.ts +85 -0
  11. package/src/d1/d1Manager.ts +171 -0
  12. package/src/d1/d1PreparedStatement.ts +114 -0
  13. package/src/d1/d1Provisioner.ts +75 -0
  14. package/src/email/emailRoutingManager.ts +143 -0
  15. package/src/email/emailSendManager.ts +81 -0
  16. package/src/env/devVars.ts +90 -0
  17. package/src/hostnames/customHostnamesManager.ts +134 -0
  18. package/src/kv/kvManager.ts +202 -0
  19. package/src/kv/kvProvisioner.ts +80 -0
  20. package/src/media/assetSeeder.ts +87 -0
  21. package/src/media/imageManager.ts +125 -0
  22. package/src/media/ownership.ts +59 -0
  23. package/src/media/streamManager.ts +198 -0
  24. package/src/queue/queueManager.ts +185 -0
  25. package/src/r2/r2Credentials.ts +17 -0
  26. package/src/r2/r2Manager.ts +548 -0
  27. package/src/r2/r2Provisioner.ts +99 -0
  28. package/src/secrets/secretsStoreManager.ts +177 -0
  29. package/src/secrets/secretsStores.ts +75 -0
  30. package/src/test-utils/emailRoutingRules.ts +122 -0
  31. package/src/test-utils/fixtureReportSetup.ts +31 -0
  32. package/src/test-utils/fixtures.ts +372 -0
  33. package/src/test-utils/harness.ts +413 -0
  34. package/src/test-utils/inboundRecorder.ts +189 -0
  35. package/src/test-utils/integrationSetup.ts +46 -0
  36. package/src/test-utils/reap.ts +297 -0
  37. package/src/tokens/accountTokensManager.ts +334 -0
  38. package/src/tokens/permissions.ts +67 -0
  39. package/src/tokens/profiles.ts +238 -0
  40. package/src/turnstile/turnstileManager.ts +177 -0
  41. package/src/user/userManager.ts +73 -0
  42. package/src/workers/buildsManager.ts +348 -0
  43. package/src/workers/buildsTypes.ts +122 -0
  44. package/src/workers/workersBuildEvent.ts +48 -0
  45. package/src/workers/workersManager.ts +423 -0
  46. package/src/workers/workersProvisioner.ts +167 -0
  47. package/src/workflows/stepFailure.ts +280 -0
  48. package/src/workflows/workflowsClient.ts +213 -0
  49. package/src/zones/zonesManager.ts +92 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @pithy-sh/cloudflare
2
+
3
+ One encapsulated client for every out-of-Worker Cloudflare operation.
4
+
5
+ Inside a Worker you use bindings. Outside one — the CLI, CI, provisioning, anything on Node or Bun — and for the control-plane operations a binding cannot do, you use this. Never both, and never a hand-rolled `fetch` to the Cloudflare API.
6
+
7
+ ```sh
8
+ bun add @pithy-sh/cloudflare
9
+ ```
10
+
11
+ It is a library rather than a capability, so it has no `pithy add`. Every capability that provisions anything depends on it already.
12
+
13
+ **Documentation: [pithy.sh/docs/core-concepts/bindings-or-rest](https://pithy.sh/docs/core-concepts/bindings-or-rest).** Which of the two a given operation takes, and why the rule is absolute. The package list is at [pithy.sh/docs/reference/packages](https://pithy.sh/docs/reference/packages).
14
+
15
+ _Everything on the adopter side is on the site. `pithy.sh/docs` is canonical — new adopter prose goes there, not here._
16
+
17
+ ## Live integration tests
18
+
19
+ Mocks prove our call *shapes*. They cannot prove the request, the response decoding, and the error handling are functionally correct against real Cloudflare — that only surfaces live. So every manager that makes real CF calls also has a `*.integration.test.ts` that creates a throwaway resource, exercises the manager against it, and tears it down. These are excluded from the default suite and run via `bun run test:integration` (the `vitest.integration.config.ts` project), gated on credentials so they skip cleanly without them.
20
+
21
+ Credentials come from `packages/cloudflare/.dev.vars`, with `process.env` overlaid per key for anything the file does not set (`loadCloudflareEnv`). **Nothing creates that file.** It used to be a symlink to the root's, wired by a `vars:local` task; #154 removed both, and `pithy dev` and `pithy seed` generate `apps/<worker>/.dev.vars` in an adopter's project — `apps/` is the registry, so nothing regenerates a file in a kit package. Write it, or export the variables:
22
+
23
+ ```sh
24
+ cat > .dev.vars <<'EOF' # a real file, in this package, git-ignored
25
+ CLOUDFLARE_ACCOUNT_ID=…
26
+ CLOUDFLARE_API_TOKEN=…
27
+ EOF
28
+ bun run test:integration # against the account in those creds
29
+ ```
30
+
31
+ Exporting them instead works identically and is how CI runs — the workflow sets `CLOUDFLARE_*` and `SECRETS_STORE_ID` from the `E2E Integration Testing` environment with no `.dev.vars` present. Set a key in both places and the file wins.
32
+
33
+ Point them at a **dedicated test account** — they create and delete real resources.
34
+
35
+ Most managers need only `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`. A few need more, and skip cleanly without it:
36
+
37
+ - **R2** signs presigned URLs with S3 keys, not the CF token. Put them in `.dev.vars` as `R2_CREDENTIALS={"accessKeyId":"…","secretAccessKey":"…"}`; the R2 suite skips when absent.
38
+ - **Secrets Store** reuses the store in `SECRETS_STORE_ID`.
39
+ - **Images** and **Stream** consume paid quota — an account with them merely *enabled* (limit 0) rejects every upload/reservation. Those suites are additionally gated behind `PITHY_IMAGES_PAID=1` / `PITHY_STREAM_PAID=1`; set them only on an account with quota.
40
+ - **Builds** needs a git repo connected via OAuth (not automatable headlessly) — tracked as a separate follow-up issue, not covered here.
41
+
42
+ ### The pattern
43
+
44
+ `src/test-utils/harness.ts` carries the shared scaffolding so each test does not re-derive it:
45
+
46
+ - `loadIntegrationCreds()` — reads `CLOUDFLARE_*` from `.dev.vars` (or `process.env`) and returns `hasCreds`. Gate the suite with `describe.skipIf(!creds.hasCreds)`.
47
+ - `uniqueName(label)` — a collision-proof, lowercase `a-z0-9-` name for the throwaway resource. The label is the distinguishing part only (`"kv"`, `"d1"`); the harness composes the reserved `pithy-int-` prefix and the timestamp the reaper reads. A label that already carries the prefix is **refused**, so pass `uniqueName("kv")`, never `uniqueName("pithy-int-kv")`.
48
+ - `withThrowawayResource(create, exercise, teardown)` — runs `exercise`, then **guarantees `teardown` in a `finally`** so a failed assertion never orphans a real resource. `create` runs outside the `try`, so a creation failure never tears down something that was never created.
49
+ - `withNamedResource(name, create, exercise, teardown)` — the same guarantee for a **named write** (`putSecret(name, …)`, `createBucket(name)`), where a rejected `create` may still have landed. Teardown is armed before `create` runs and addresses the name, so pass an idempotent delete.
50
+ - `reapStaleTestResources(kind)` — deletes debris of one kind an earlier crashed run left behind. You rarely call this directly: `src/test-utils/reap.ts` registers every kind and each `vitest.integration.config.ts` sweeps once per run via `globalSetup`. Add a kind there rather than a `beforeAll` to your suite — a hook inside a `describe.skipIf` does not run when the suite skips, which gated each reaper on the very credential whose absence lets debris pile up.
51
+
52
+ `pithy-int-` is reserved on any account: everything a live test creates is inside it, `pithy init` refuses a project name that would land in it, and the reaper deletes nothing outside it. See [`docs/NAMING.md`](../../docs/NAMING.md). When the resource names are themselves under test, provision under the reserved project `RESERVED_TEST_PROJECT` rather than inventing a name — the project segment comes first and verbatim, so every name the product composes lands in the namespace too.
53
+
54
+ Each test asserts the three things mocks cannot: a happy-path request succeeds, the response decodes to the expected shape, and at least one error/absent path behaves correctly (surfaced as our typed result or a `PithyError`).
55
+
56
+ `src/kv/kvManager.integration.test.ts` is the reference — copy it. The KV namespace itself is created and deleted with the raw SDK (the manager addresses an existing namespace by id), so namespace lifecycle is the harness's `create`/`teardown`:
57
+
58
+ ```ts
59
+ const creds = loadIntegrationCreds();
60
+
61
+ describe.skipIf(!creds.hasCreds)("CloudflareKVManager — LIVE", () => {
62
+ const client = new Cloudflare({ apiToken: creds.apiToken });
63
+
64
+ test("round-trips a key, then reads an absent key as null", async () => {
65
+ await withThrowawayResource(
66
+ () => client.kv.namespaces.create({ account_id: creds.accountId, title: uniqueName("kv") }),
67
+ async (namespace) => {
68
+ const kv = new CloudflareKVManager({ accountId: creds.accountId, apiToken: creds.apiToken, namespaceId: namespace.id });
69
+ expect(await kv.validateServiceAccess()).toBe(true); // happy path
70
+ await kv.set("greeting", "hello");
71
+ expect(await kv.get("greeting")).toBe("hello"); // decoded shape
72
+ await kv.delete("greeting");
73
+ expect(await kv.get("greeting")).toBeNull(); // absent path: 404 -> null
74
+ },
75
+ (namespace) => client.kv.namespaces.delete(namespace.id, { account_id: creds.accountId }).then(() => undefined),
76
+ );
77
+ });
78
+ });
79
+ ```
80
+
81
+ This reference landed first to lock the template; the remaining managers each copy it as their own reviewed slice under [#39](https://github.com/pithy-sh/pithy/issues/39).
82
+
83
+ **This section stays here.** It is for whoever is working on this package, not for an adopter — the site documents the kit, and a contributor's test harness has no page on it. `src/test-utils/harness.ts`, `src/kv/kvManager.integration.test.ts` and `src/d1/d1Provisioner.integration.test.ts` each point a reader at it by name.
84
+
85
+ ## License
86
+
87
+ MIT — adopter-side app value. The root `LICENSE` covers it.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@pithy-sh/cloudflare",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/cloudflare"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "!src/**/*.test.*"
13
+ ],
14
+ "type": "module",
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "exports": {
19
+ "./src/*": "./src/*.ts"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
23
+ "typecheck": "tsc -p tsconfig.json",
24
+ "test": "vitest run",
25
+ "test:node": "vitest run --project=node",
26
+ "test:integration": "vitest run --config vitest.integration.config.ts",
27
+ "clean": "rm -rf dist .turbo",
28
+ "reset": "bun run clean && rm -rf node_modules"
29
+ },
30
+ "dependencies": {
31
+ "@aws-sdk/client-s3": "^3.1111.0",
32
+ "@aws-sdk/s3-request-presigner": "^3.1111.0",
33
+ "@cloudflare/workers-types": "^5.20260729.1",
34
+ "@pithy-sh/core": "workspace:*",
35
+ "cloudflare": "^7.0.0",
36
+ "js-base64": "^3.9.2",
37
+ "kysely": "^0.29.0",
38
+ "zod": "^4.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@pithy-sh/tsconfig": "workspace:*",
42
+ "@types/node": "^22.15.0",
43
+ "@vitest/coverage-v8": "^4.1.0",
44
+ "kysely-d1": "^0.4.0",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,227 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AIRunParams, AIRunResponse } from "cloudflare/resources/ai/ai";
5
+ import type { ModelListResponse } from "cloudflare/resources/ai/models/models";
6
+ import { fromUint8Array } from "js-base64";
7
+ import { z } from "zod";
8
+ import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors";
9
+ import { CloudflareManager } from "../client/manager";
10
+
11
+ /**
12
+ * The default text-generation model. A generic, well-supported instruct model — callers can
13
+ * override per call via `runModel`. Not Leed-specific.
14
+ */
15
+ const DEFAULT_TEXT_MODEL = "@cf/meta/llama-4-scout-17b-16e-instruct";
16
+
17
+ /**
18
+ * The default text-embedding model. `bge-m3` returns a `{ shape, data }` payload matching
19
+ * `VectorEmbeddings`. Override per call via `runModel` for a different embedding model.
20
+ */
21
+ const DEFAULT_EMBEDDING_MODEL = "@cf/baai/bge-m3";
22
+
23
+ /** The default image-to-text (vision) model used by `analyzeImage`. */
24
+ const DEFAULT_IMAGE_TO_TEXT_MODEL = "@cf/llava-hf/llava-1.5-7b-hf";
25
+
26
+ /**
27
+ * The embeddings payload Cloudflare's text-embedding models return: a `shape` describing the
28
+ * `[count, dimensions]` of the result and a `data` array of one vector per input. Generic — this
29
+ * is the raw CF shape, carrying no application meaning, so callers can store or query it directly.
30
+ */
31
+ export const VectorEmbeddings = z
32
+ .object({
33
+ shape: z.array(z.number()).describe("The result shape as [count, dimensions]."),
34
+ data: z.array(z.array(z.number())).describe("One embedding vector per input text."),
35
+ })
36
+ .describe("A Cloudflare text-embedding result: vector shape plus one embedding per input.");
37
+ export type VectorEmbeddings = z.infer<typeof VectorEmbeddings>;
38
+
39
+ /**
40
+ * The text-generation payload Cloudflare's instruct models return over the REST API: the generated
41
+ * text plus optional usage. Generic — no application-specific post-processing.
42
+ */
43
+ /**
44
+ * A text-generation result, normalized to `{ response }` whichever envelope the model returned.
45
+ *
46
+ * Workers AI answers in two shapes and which one you get depends on the model, not on the request.
47
+ * Older models (`@cf/meta/llama-3.1-8b-instruct`) return a flat `{ response }`. Newer ones — including
48
+ * the default `@cf/meta/llama-4-scout-17b-16e-instruct` — return the OpenAI chat-completion envelope
49
+ * with the text at `choices[0].message.content` and **no** top-level `response` at all. Verified live
50
+ * on 2026-07-29; llama-3.1 currently returns both, so the flat arm is matched first.
51
+ *
52
+ * Accepting both and normalizing keeps `generateText`'s contract stable across a model swap, which is
53
+ * a per-call option — a caller overriding `model` should not have to know which era it belongs to.
54
+ */
55
+ const FlatTextGeneration = z
56
+ .object({ response: z.string().describe("The generated text, on the flat legacy envelope.") })
57
+ .describe("The flat text-generation envelope older Workers AI models return.");
58
+
59
+ const ChatCompletionText = z
60
+ .object({
61
+ choices: z
62
+ .array(
63
+ z
64
+ .object({
65
+ message: z
66
+ .object({ content: z.string().describe("The generated text for this choice.") })
67
+ .describe("The assistant message this choice carries."),
68
+ })
69
+ .describe("One completion choice."),
70
+ )
71
+ .min(1)
72
+ .describe("The completions the model returned; the first is the answer."),
73
+ })
74
+ .describe("The OpenAI-compatible chat-completion envelope newer Workers AI models return.");
75
+
76
+ export const TextGeneration = z
77
+ .union([FlatTextGeneration, ChatCompletionText])
78
+ .describe("Either envelope, before normalization: the flat legacy one, or the OpenAI-compatible one.")
79
+ .transform((value, ctx) => {
80
+ if ("response" in value) return { response: value.response };
81
+ const first = value.choices[0];
82
+ if (!first) {
83
+ // `.min(1)` makes this unreachable at runtime; the guard exists because
84
+ // `noUncheckedIndexedAccess` types the access as optional and a silent "" would be worse.
85
+ ctx.addIssue({ code: "custom", message: "The chat-completion envelope carried no choices." });
86
+ return z.NEVER;
87
+ }
88
+ return { response: first.message.content };
89
+ })
90
+ .describe("A Cloudflare text-generation result, normalized to the model's generated text.");
91
+ export type TextGeneration = z.output<typeof TextGeneration>;
92
+
93
+ /**
94
+ * The image-to-text payload Cloudflare's vision models return: a textual description of the image.
95
+ */
96
+ export const ImageToText = z
97
+ .object({
98
+ description: z.string().describe("The model's textual description of the image."),
99
+ })
100
+ .describe("A Cloudflare image-to-text result: a description of the supplied image.");
101
+ export type ImageToText = z.infer<typeof ImageToText>;
102
+
103
+ /** Options for `generateText`. */
104
+ export interface GenerateTextOptions {
105
+ /** The model to run. Defaults to a generic instruct model. */
106
+ model?: string;
107
+ /** Upper bound on generated tokens. Omit for the model default. */
108
+ maxTokens?: number;
109
+ }
110
+
111
+ /** A single chat message passed to a text-generation model. */
112
+ export interface ChatMessage {
113
+ /** The role of the speaker. */
114
+ role: "system" | "user" | "assistant";
115
+ /** The message content. */
116
+ content: string;
117
+ }
118
+
119
+ /** Options for `analyzeImage`. */
120
+ export interface AnalyzeImageOptions {
121
+ /** The model to run. Defaults to a generic vision model. */
122
+ model?: string;
123
+ /** The instruction guiding the description. Defaults to a generic alt-text prompt. */
124
+ prompt?: string;
125
+ /** Upper bound on generated tokens. */
126
+ maxTokens?: number;
127
+ }
128
+
129
+ const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable for alt text.";
130
+
131
+ /**
132
+ * Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
133
+ * embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
134
+ * Worker you use the `Ai` binding directly — this manager is the REST counterpart, addressed by
135
+ * account id. Every model call carries no application logic; the caller chooses the model and
136
+ * interprets the result.
137
+ */
138
+ export class CloudflareAIManager extends CloudflareManager {
139
+ /**
140
+ * Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
141
+ * helper builds on. The account id is injected for you; the rest of `input` is the model's body.
142
+ */
143
+ async runModel(model: string, input: Record<string, unknown>): Promise<AIRunResponse> {
144
+ return cloudflareRequest(`AI run model '${model}'`, () =>
145
+ this.getClient().ai.run(model, { account_id: this.accountId, ...input } as AIRunParams),
146
+ );
147
+ }
148
+
149
+ /**
150
+ * Generate text from a chat prompt. Returns the validated `{ response }` payload. A response that
151
+ * does not carry a string `response` field fails Zod and throws `cloudflare/invalid_response`.
152
+ */
153
+ async generateText(messages: ChatMessage[], options?: GenerateTextOptions): Promise<TextGeneration> {
154
+ const model = options?.model ?? DEFAULT_TEXT_MODEL;
155
+ const raw = await this.runModel(model, {
156
+ messages,
157
+ ...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
158
+ });
159
+ const parsed = TextGeneration.safeParse(raw);
160
+ if (!parsed.success) {
161
+ throw new CloudflareInvalidResponseError({
162
+ message: "The AI text-generation response had an unexpected shape.",
163
+ detail: parsed.error.message,
164
+ });
165
+ }
166
+ return parsed.data;
167
+ }
168
+
169
+ /**
170
+ * Generate embeddings for one or more texts. Returns the validated `{ shape, data }` payload. A
171
+ * response that does not match fails Zod and throws `cloudflare/invalid_response`.
172
+ */
173
+ async generateEmbeddings(texts: string | string[], model?: string): Promise<VectorEmbeddings> {
174
+ const raw = await this.runModel(model ?? DEFAULT_EMBEDDING_MODEL, { text: texts });
175
+ const parsed = VectorEmbeddings.safeParse(raw);
176
+ if (!parsed.success) {
177
+ throw new CloudflareInvalidResponseError({
178
+ message: "The AI embeddings response had an unexpected shape.",
179
+ detail: parsed.error.message,
180
+ });
181
+ }
182
+ return parsed.data;
183
+ }
184
+
185
+ /**
186
+ * Describe an image with a vision model. The image is raw bytes, base64-encoded for the model
187
+ * input. Returns the validated `{ description }` payload.
188
+ */
189
+ async analyzeImage(image: Uint8Array, options?: AnalyzeImageOptions): Promise<ImageToText> {
190
+ const model = options?.model ?? DEFAULT_IMAGE_TO_TEXT_MODEL;
191
+ const raw = await this.runModel(model, {
192
+ image: fromUint8Array(image),
193
+ prompt: options?.prompt ?? DEFAULT_IMAGE_PROMPT,
194
+ ...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
195
+ });
196
+ const parsed = ImageToText.safeParse(raw);
197
+ if (!parsed.success) {
198
+ throw new CloudflareInvalidResponseError({
199
+ message: "The AI image-to-text response had an unexpected shape.",
200
+ detail: parsed.error.message,
201
+ });
202
+ }
203
+ return parsed.data;
204
+ }
205
+
206
+ /** List the AI models available to this account. Returns an empty array when none are present. */
207
+ async listModels(): Promise<ModelListResponse[]> {
208
+ return cloudflareRequest("AI list models", async () => {
209
+ const page = await this.getClient().ai.models.list({ account_id: this.accountId });
210
+ return Array.isArray(page.result) ? page.result : [];
211
+ });
212
+ }
213
+
214
+ getServiceType(): string {
215
+ return "Workers AI";
216
+ }
217
+
218
+ /** Prove access by listing models. Never throws. */
219
+ async validateServiceAccess(): Promise<boolean> {
220
+ try {
221
+ const models = await this.listModels();
222
+ return models.length > 0;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,161 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type {
5
+ IndexDeleteByIDsResponse,
6
+ IndexGetByIDsResponse,
7
+ IndexInfoResponse,
8
+ IndexInsertResponse,
9
+ IndexQueryResponse,
10
+ IndexUpsertResponse,
11
+ } from "cloudflare/resources/vectorize/indexes/indexes";
12
+ import { CloudflareNotConfiguredError, cloudflareRequest } from "../client/errors";
13
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
14
+
15
+ /** Config for the Vectorize manager: the shared client config plus the index it targets. */
16
+ export interface VectorizeManagerConfig extends CloudflareManagerConfig {
17
+ /** The Vectorize index name (the REST API addresses indexes by name, not binding). */
18
+ indexName: string;
19
+ }
20
+
21
+ /** A vector to insert or upsert: an id, its float values, and optional metadata/namespace. */
22
+ export interface VectorizeVector {
23
+ /** The unique identifier for the vector. */
24
+ id: string;
25
+ /** The vector's float components. */
26
+ values: number[];
27
+ /** Arbitrary metadata stored alongside the vector. */
28
+ metadata?: Record<string, unknown>;
29
+ /** An optional namespace partitioning the index. */
30
+ namespace?: string;
31
+ }
32
+
33
+ /** Options narrowing a nearest-neighbor query. */
34
+ export interface VectorizeQueryOptions {
35
+ /** The number of nearest neighbors to return. */
36
+ topK?: number;
37
+ /** Whether to return the matched vectors' values. */
38
+ returnValues?: boolean;
39
+ /** Whether to return no, indexed, or all metadata for matches. */
40
+ returnMetadata?: "none" | "indexed" | "all";
41
+ /** A metadata filter expression limiting results. */
42
+ filter?: unknown;
43
+ }
44
+
45
+ /**
46
+ * Out-of-Worker Vectorize access over the REST API: insert/upsert vectors, query nearest
47
+ * neighbors, fetch and delete by id, and describe the index from a CLI/CI/provisioning context.
48
+ * Inside a Worker you use the `Vectorize` binding directly — this manager is the REST counterpart,
49
+ * addressed by index name.
50
+ */
51
+ export class CloudflareVectorizeManager extends CloudflareManager {
52
+ private readonly indexName: string;
53
+
54
+ constructor(config: VectorizeManagerConfig) {
55
+ super(config);
56
+ if (!config.indexName) {
57
+ throw new CloudflareNotConfiguredError({ detail: "Missing indexName for Vectorize REST access." });
58
+ }
59
+ this.indexName = config.indexName;
60
+ }
61
+
62
+ /** Serialize vectors as an NDJSON file, the body shape the REST insert/upsert endpoints expect. */
63
+ private vectorsToNdjsonFile(vectors: VectorizeVector[]): File {
64
+ const ndjson = vectors.map((vector) => JSON.stringify(vector)).join("\n");
65
+ return new File([ndjson], "vectors.ndjson", { type: "application/x-ndjson" });
66
+ }
67
+
68
+ /** Insert vectors. Returns the async-mutation id for the enqueued change. */
69
+ async insert(vectors: VectorizeVector[]): Promise<IndexInsertResponse | null> {
70
+ return cloudflareRequest("Vectorize insert", () =>
71
+ this.getClient().vectorize.indexes.insert(this.indexName, {
72
+ account_id: this.accountId,
73
+ body: this.vectorsToNdjsonFile(vectors),
74
+ }),
75
+ );
76
+ }
77
+
78
+ /** Upsert vectors, overwriting any with matching ids. Returns the async-mutation id. */
79
+ async upsert(vectors: VectorizeVector[]): Promise<IndexUpsertResponse | null> {
80
+ return cloudflareRequest("Vectorize upsert", () =>
81
+ this.getClient().vectorize.indexes.upsert(this.indexName, {
82
+ account_id: this.accountId,
83
+ body: this.vectorsToNdjsonFile(vectors),
84
+ }),
85
+ );
86
+ }
87
+
88
+ /** Query the index for the nearest neighbors of a vector. */
89
+ async query(vector: number[], options?: VectorizeQueryOptions): Promise<IndexQueryResponse | null> {
90
+ return cloudflareRequest("Vectorize query", () =>
91
+ this.getClient().vectorize.indexes.query(this.indexName, {
92
+ account_id: this.accountId,
93
+ vector,
94
+ ...(options?.topK !== undefined ? { topK: options.topK } : {}),
95
+ ...(options?.returnValues !== undefined ? { returnValues: options.returnValues } : {}),
96
+ ...(options?.returnMetadata !== undefined ? { returnMetadata: options.returnMetadata } : {}),
97
+ ...(options?.filter !== undefined ? { filter: options.filter } : {}),
98
+ }),
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Query by an existing vector's id: fetch its values, then run a nearest-neighbor query with
104
+ * them. Returns an empty result when the id is absent or has no values.
105
+ */
106
+ async queryById(vectorId: string, options?: VectorizeQueryOptions): Promise<IndexQueryResponse | null> {
107
+ const vectors = await this.getByIds([vectorId]);
108
+ const first = vectors[0];
109
+ if (!first || !Array.isArray(first.values) || first.values.length === 0) {
110
+ return { count: 0, matches: [] };
111
+ }
112
+ return this.query(first.values, options);
113
+ }
114
+
115
+ /** Fetch vectors by id. Returns an array (possibly empty); each element carries its values. */
116
+ async getByIds(ids: string[]): Promise<VectorizeVector[]> {
117
+ return cloudflareRequest("Vectorize getByIds", async () => {
118
+ const response = (await this.getClient().vectorize.indexes.getByIDs(this.indexName, {
119
+ account_id: this.accountId,
120
+ ids,
121
+ })) as IndexGetByIDsResponse;
122
+ return (Array.isArray(response) ? response : []) as VectorizeVector[];
123
+ });
124
+ }
125
+
126
+ /** Delete vectors by id. Returns the async-mutation id for the enqueued change. */
127
+ async deleteByIds(ids: string[]): Promise<IndexDeleteByIDsResponse | null> {
128
+ return cloudflareRequest("Vectorize deleteByIds", () =>
129
+ this.getClient().vectorize.indexes.deleteByIDs(this.indexName, {
130
+ account_id: this.accountId,
131
+ ids,
132
+ }),
133
+ );
134
+ }
135
+
136
+ /** Describe the index: dimensions, vector count, and processing watermarks. */
137
+ async describe(): Promise<IndexInfoResponse | null> {
138
+ return cloudflareRequest("Vectorize describe", () =>
139
+ this.getClient().vectorize.indexes.info(this.indexName, { account_id: this.accountId }),
140
+ );
141
+ }
142
+
143
+ /** The index this manager targets and the account it lives in. */
144
+ getVectorizeInfo(): { indexName: string; accountId: string } {
145
+ return { indexName: this.indexName, accountId: this.accountId };
146
+ }
147
+
148
+ getServiceType(): string {
149
+ return "Vectorize";
150
+ }
151
+
152
+ /** Prove access by reading the index record. Never throws. */
153
+ async validateServiceAccess(): Promise<boolean> {
154
+ try {
155
+ await this.getClient().vectorize.indexes.get(this.indexName, { account_id: this.accountId });
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+ }