@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.
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/package.json +48 -0
- package/src/ai/aiManager.ts +227 -0
- package/src/ai/vectorizeManager.ts +161 -0
- package/src/ai/vectorizeProvisioner.ts +266 -0
- package/src/client/accounts.ts +80 -0
- package/src/client/clients.ts +244 -0
- package/src/client/errors.ts +143 -0
- package/src/client/manager.ts +85 -0
- package/src/d1/d1Manager.ts +171 -0
- package/src/d1/d1PreparedStatement.ts +114 -0
- package/src/d1/d1Provisioner.ts +75 -0
- package/src/email/emailRoutingManager.ts +143 -0
- package/src/email/emailSendManager.ts +81 -0
- package/src/env/devVars.ts +90 -0
- package/src/hostnames/customHostnamesManager.ts +134 -0
- package/src/kv/kvManager.ts +202 -0
- package/src/kv/kvProvisioner.ts +80 -0
- package/src/media/assetSeeder.ts +87 -0
- package/src/media/imageManager.ts +125 -0
- package/src/media/ownership.ts +59 -0
- package/src/media/streamManager.ts +198 -0
- package/src/queue/queueManager.ts +185 -0
- package/src/r2/r2Credentials.ts +17 -0
- package/src/r2/r2Manager.ts +548 -0
- package/src/r2/r2Provisioner.ts +99 -0
- package/src/secrets/secretsStoreManager.ts +177 -0
- package/src/secrets/secretsStores.ts +75 -0
- package/src/test-utils/emailRoutingRules.ts +122 -0
- package/src/test-utils/fixtureReportSetup.ts +31 -0
- package/src/test-utils/fixtures.ts +372 -0
- package/src/test-utils/harness.ts +413 -0
- package/src/test-utils/inboundRecorder.ts +189 -0
- package/src/test-utils/integrationSetup.ts +46 -0
- package/src/test-utils/reap.ts +297 -0
- package/src/tokens/accountTokensManager.ts +334 -0
- package/src/tokens/permissions.ts +67 -0
- package/src/tokens/profiles.ts +238 -0
- package/src/turnstile/turnstileManager.ts +177 -0
- package/src/user/userManager.ts +73 -0
- package/src/workers/buildsManager.ts +348 -0
- package/src/workers/buildsTypes.ts +122 -0
- package/src/workers/workersBuildEvent.ts +48 -0
- package/src/workers/workersManager.ts +423 -0
- package/src/workers/workersProvisioner.ts +167 -0
- package/src/workflows/stepFailure.ts +280 -0
- package/src/workflows/workflowsClient.ts +213 -0
- package/src/zones/zonesManager.ts +92 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cloudflare REST client throw sugar. The `cloudflare/*` codes live in core's closed
|
|
10
|
+
* `KitErrorPayload` union (CLAUDE.md §Errors: capabilities add their codes to the one union); these
|
|
11
|
+
* subclasses are the package-local vehicles that set one of those members — the same pattern as
|
|
12
|
+
* core's `NotFoundError`/`InternalError`, just owned here. Runtime code in this package throws
|
|
13
|
+
* one of these, never a plain `new Error`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Variable parts each subclass accepts; `code`/`status` are fixed by the subclass. */
|
|
17
|
+
interface CloudflareErrorArgs {
|
|
18
|
+
/** Override the public, safe-to-expose message. */
|
|
19
|
+
message?: string;
|
|
20
|
+
/** A remediation hint (CLI action line). */
|
|
21
|
+
action?: string;
|
|
22
|
+
/** Internal context for logs + audit. Never serialized to clients. */
|
|
23
|
+
detail?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
26
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
27
|
+
*/
|
|
28
|
+
params?: MessageParams;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A required piece of configuration (token, account id, resource id) is missing. */
|
|
32
|
+
export class CloudflareNotConfiguredError extends PithyError {
|
|
33
|
+
constructor(args: CloudflareErrorArgs = {}, options?: { cause?: unknown }) {
|
|
34
|
+
super(
|
|
35
|
+
{
|
|
36
|
+
code: "cloudflare/not_configured",
|
|
37
|
+
status: 500,
|
|
38
|
+
message: args.message ?? "The Cloudflare REST client is not fully configured.",
|
|
39
|
+
action: args.action ?? "Provide apiToken, accountId, and any required resource id.",
|
|
40
|
+
detail: args.detail,
|
|
41
|
+
params: args.params,
|
|
42
|
+
},
|
|
43
|
+
options,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A call to the Cloudflare REST API failed (network error, 4xx/5xx from the API). */
|
|
49
|
+
export class CloudflareRequestError extends PithyError {
|
|
50
|
+
constructor(args: CloudflareErrorArgs = {}, options?: { cause?: unknown }) {
|
|
51
|
+
super(
|
|
52
|
+
{
|
|
53
|
+
code: "cloudflare/request_failed",
|
|
54
|
+
status: 502,
|
|
55
|
+
message: args.message ?? "A Cloudflare REST API call failed.",
|
|
56
|
+
action: args.action,
|
|
57
|
+
detail: args.detail,
|
|
58
|
+
params: args.params,
|
|
59
|
+
},
|
|
60
|
+
options,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A Cloudflare REST API response did not match its expected shape (failed Zod validation). */
|
|
66
|
+
export class CloudflareInvalidResponseError extends PithyError {
|
|
67
|
+
constructor(args: CloudflareErrorArgs = {}, options?: { cause?: unknown }) {
|
|
68
|
+
super(
|
|
69
|
+
{
|
|
70
|
+
code: "cloudflare/invalid_response",
|
|
71
|
+
status: 502,
|
|
72
|
+
message: args.message ?? "A Cloudflare REST API response had an unexpected shape.",
|
|
73
|
+
action: args.action,
|
|
74
|
+
detail: args.detail,
|
|
75
|
+
params: args.params,
|
|
76
|
+
},
|
|
77
|
+
options,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run a Cloudflare SDK call and turn any failure into a `CloudflareRequestError`, preserving the
|
|
84
|
+
* original error as `cause` and its message as internal `detail`. `operation` names the call for
|
|
85
|
+
* the audit trail (`"KV get for key 'x'"`). A `PithyError` already in flight (e.g. a
|
|
86
|
+
* not-configured guard) passes through untouched — only foreign throws get wrapped.
|
|
87
|
+
*/
|
|
88
|
+
export async function cloudflareRequest<T>(operation: string, fn: () => Promise<T>): Promise<T> {
|
|
89
|
+
try {
|
|
90
|
+
return await fn();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof PithyError) throw error;
|
|
93
|
+
throw new CloudflareRequestError(
|
|
94
|
+
{ message: `Cloudflare request failed: ${operation}.`, detail: messageOf(error) },
|
|
95
|
+
{ cause: error },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The raw message of an unknown throw, for use as a PithyError `detail`. One source of truth. */
|
|
101
|
+
export function messageOf(error: unknown): string {
|
|
102
|
+
return error instanceof Error ? error.message : String(error);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The most specific failure reason available, for a partial-failure report's `error` field: a
|
|
107
|
+
* `PithyError`'s internal `detail` (the real cause `cloudflareRequest` captured), else its public
|
|
108
|
+
* message, else the raw throw. Centralized so the detail-vs-message precedence lives in one place.
|
|
109
|
+
*/
|
|
110
|
+
export function reasonOf(error: unknown): string {
|
|
111
|
+
if (error instanceof PithyError) return error.payload.detail ?? error.payload.message;
|
|
112
|
+
return messageOf(error);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether a thrown SDK error is an HTTP 404 — the SDK throws on a missing resource, not null. */
|
|
116
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
117
|
+
return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 404;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether a thrown SDK error is an HTTP 403 — the token reached the API but lacks the permission for
|
|
122
|
+
* the operation. The signal a caller uses to turn a raw "Unauthorized" into an actionable
|
|
123
|
+
* "grant this permission group" message instead of a generic request failure.
|
|
124
|
+
*/
|
|
125
|
+
export function isAuthorizationError(error: unknown): boolean {
|
|
126
|
+
return typeof error === "object" && error !== null && (error as { status?: unknown }).status === 403;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decode a Cloudflare response against `schema`, throwing `cloudflare/invalid_response` on a shape
|
|
131
|
+
* mismatch. The decode counterpart to `cloudflareRequest`'s error wrapping — one seam so every
|
|
132
|
+
* manager validates the wire the same way instead of hand-rolling `safeParse` + throw.
|
|
133
|
+
*/
|
|
134
|
+
export function decodeResponse<T extends z.ZodType>(schema: T, raw: unknown, context: string): z.output<T> {
|
|
135
|
+
const parsed = schema.safeParse(raw);
|
|
136
|
+
if (!parsed.success) {
|
|
137
|
+
throw new CloudflareInvalidResponseError({
|
|
138
|
+
message: `A Cloudflare response had an unexpected shape: ${context}.`,
|
|
139
|
+
detail: parsed.error.message,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return parsed.data;
|
|
143
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { Cloudflare } from "cloudflare";
|
|
5
|
+
import type { Account } from "cloudflare/resources/accounts/accounts";
|
|
6
|
+
import { CloudflareNotConfiguredError, cloudflareRequest } from "./errors";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration every manager shares. This is the whole surface: a scoped CF API token and the
|
|
10
|
+
* account it targets. The CMS managers also accepted Worker bindings (dual REST/binding mode);
|
|
11
|
+
* `@pithy-sh/cloudflare` is the *out-of-Worker* client (CLI, CI, provisioning), so it is REST-only
|
|
12
|
+
* — inside a Worker you use the binding directly (CLAUDE.md §Cloudflare access). One token, one
|
|
13
|
+
* account, no env coupling.
|
|
14
|
+
*/
|
|
15
|
+
export interface CloudflareManagerConfig {
|
|
16
|
+
/** A scoped, least-privilege CF API token (from `CLOUDFLARE_API_TOKEN`, minted per environment). */
|
|
17
|
+
apiToken: string;
|
|
18
|
+
/** The Cloudflare account id all operations target (`CLOUDFLARE_ACCOUNT_ID`). */
|
|
19
|
+
accountId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base for every Cloudflare resource manager. Holds the configured SDK client and account id, and
|
|
24
|
+
* carries the account-level operations shared across resources. Subclasses add one resource's REST
|
|
25
|
+
* operations and declare what they are (`getServiceType`) and how to prove access
|
|
26
|
+
* (`validateServiceAccess`).
|
|
27
|
+
*/
|
|
28
|
+
export abstract class CloudflareManager {
|
|
29
|
+
private readonly client: Cloudflare;
|
|
30
|
+
|
|
31
|
+
private readonly apiToken: string;
|
|
32
|
+
|
|
33
|
+
protected readonly accountId: string;
|
|
34
|
+
|
|
35
|
+
constructor(config: CloudflareManagerConfig) {
|
|
36
|
+
if (!config.apiToken) {
|
|
37
|
+
throw new CloudflareNotConfiguredError({ detail: "Missing apiToken in CloudflareManagerConfig." });
|
|
38
|
+
}
|
|
39
|
+
if (!config.accountId) {
|
|
40
|
+
throw new CloudflareNotConfiguredError({ detail: "Missing accountId in CloudflareManagerConfig." });
|
|
41
|
+
}
|
|
42
|
+
this.client = new Cloudflare({ apiToken: config.apiToken });
|
|
43
|
+
this.apiToken = config.apiToken;
|
|
44
|
+
this.accountId = config.accountId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The configured SDK client. Subclasses route typed calls through this. */
|
|
48
|
+
protected getClient(): Cloudflare {
|
|
49
|
+
return this.client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The raw API token, for subclasses that fall back to direct `fetch()` for endpoints the typed
|
|
54
|
+
* SDK does not cover (e.g. Workers Builds, event subscriptions). The documented escape hatch.
|
|
55
|
+
*/
|
|
56
|
+
protected getApiToken(): string {
|
|
57
|
+
return this.apiToken;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The account id all operations target. */
|
|
61
|
+
protected getAccountId(): string {
|
|
62
|
+
return this.accountId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fetch the full account record. Wraps SDK failures as `cloudflare/request_failed`. */
|
|
66
|
+
async getAccountInfo(): Promise<Account> {
|
|
67
|
+
return cloudflareRequest("get account info", () => this.getClient().accounts.get({ account_id: this.accountId }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the credentials work, proved by a lightweight account read. Never throws. */
|
|
71
|
+
async validateCredentials(): Promise<boolean> {
|
|
72
|
+
try {
|
|
73
|
+
await this.getAccountInfo();
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A human label for the resource this manager owns (e.g. "KV Storage"). */
|
|
81
|
+
abstract getServiceType(): string;
|
|
82
|
+
|
|
83
|
+
/** Prove this manager can reach its specific resource. Never throws; returns false on failure. */
|
|
84
|
+
abstract validateServiceAccess(): Promise<boolean>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, D1PreparedStatement, D1Result } from "@cloudflare/workers-types";
|
|
5
|
+
import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { D1 } from "cloudflare/resources/d1/d1";
|
|
7
|
+
import type { QueryResult } from "cloudflare/resources/d1/database";
|
|
8
|
+
import { CloudflareNotConfiguredError, cloudflareRequest, reasonOf } from "../client/errors";
|
|
9
|
+
import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
|
|
10
|
+
import { D1PreparedStatementREST } from "./d1PreparedStatement";
|
|
11
|
+
|
|
12
|
+
/** Config for the D1 manager: the shared client config plus the database it targets. */
|
|
13
|
+
export interface D1ManagerConfig extends CloudflareManagerConfig {
|
|
14
|
+
/** The D1 database id (the REST API addresses databases by id). */
|
|
15
|
+
databaseId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The per-query outcome of `batchQueries`: a partial-failure report, never a thrown batch. */
|
|
19
|
+
export interface D1BatchResult {
|
|
20
|
+
/** The SQL this result is for. */
|
|
21
|
+
query: string;
|
|
22
|
+
/** Whether the query returned at least one result set. */
|
|
23
|
+
success: boolean;
|
|
24
|
+
/** The result sets, present only on success. */
|
|
25
|
+
result?: QueryResult[];
|
|
26
|
+
/** The failure reason, present only when `success` is false. */
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Out-of-Worker D1 access over the REST API. It `implements D1Database`, so the same query builder
|
|
32
|
+
* a Worker runs against a binding runs from a CLI/CI context against D1 over REST — most
|
|
33
|
+
* importantly as the database behind a Kysely `D1Dialect` (kysely-d1), which is how `pithy migrate`
|
|
34
|
+
* promotes/rolls back schema remotely (issue #31). Inside a Worker you use the `D1Database` binding
|
|
35
|
+
* directly; this is the REST counterpart, addressed by database id.
|
|
36
|
+
*/
|
|
37
|
+
export class CloudflareD1Manager extends CloudflareManager implements D1Database {
|
|
38
|
+
private readonly databaseId: string;
|
|
39
|
+
|
|
40
|
+
constructor(config: D1ManagerConfig) {
|
|
41
|
+
super(config);
|
|
42
|
+
if (!config.databaseId) {
|
|
43
|
+
throw new CloudflareNotConfiguredError({ detail: "Missing databaseId for D1 REST access." });
|
|
44
|
+
}
|
|
45
|
+
this.databaseId = config.databaseId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- D1Database surface (what Kysely's D1Dialect drives) ---
|
|
49
|
+
|
|
50
|
+
prepare(query: string): D1PreparedStatement {
|
|
51
|
+
return new D1PreparedStatementREST(query, this.getClient(), this.accountId, this.databaseId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run each statement and collect its result. **Not atomic:** unlike the Worker binding's `batch()`
|
|
56
|
+
* (which runs the statements in one transaction), the D1 REST API exposes no batch/transaction
|
|
57
|
+
* endpoint, so these execute as independent sequential queries — a mid-batch failure leaves earlier
|
|
58
|
+
* statements committed. D1 has no interactive transactions, and Kysely drives migrations through
|
|
59
|
+
* `prepare().all()` per statement rather than this method, so this affects only direct callers.
|
|
60
|
+
*/
|
|
61
|
+
async batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]> {
|
|
62
|
+
const results: D1Result<T>[] = [];
|
|
63
|
+
for (const statement of statements) {
|
|
64
|
+
results.push(await (statement as D1PreparedStatementREST<T>).run<T>());
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async exec(query: string): Promise<D1ExecResult> {
|
|
70
|
+
const response = await cloudflareRequest("D1 exec", () =>
|
|
71
|
+
this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql: query }),
|
|
72
|
+
);
|
|
73
|
+
const result = response.result[0];
|
|
74
|
+
return { count: result?.results?.length ?? 0, duration: result?.meta?.duration ?? 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `dump()` is deprecated and has no REST equivalent — calling it is a programming error. */
|
|
78
|
+
async dump(): Promise<ArrayBuffer> {
|
|
79
|
+
throw new InternalError({ detail: "D1 dump() is not supported via the REST client." });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Sessions are a binding-only feature; the REST client has no equivalent. */
|
|
83
|
+
withSession(): never {
|
|
84
|
+
throw new InternalError({ detail: "D1 withSession() is not supported via the REST client." });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- REST convenience methods ---
|
|
88
|
+
|
|
89
|
+
/** Run one SQL statement and return its raw REST result sets. */
|
|
90
|
+
async executeQuery(sql: string, params?: string[]): Promise<QueryResult[]> {
|
|
91
|
+
const response = await cloudflareRequest("D1 query", () =>
|
|
92
|
+
this.getClient().d1.database.query(this.databaseId, { account_id: this.accountId, sql, params }),
|
|
93
|
+
);
|
|
94
|
+
return response.result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run many independent queries, collecting a per-query result instead of failing the whole batch
|
|
99
|
+
* on the first error — the caller decides what to do with partial failure.
|
|
100
|
+
*/
|
|
101
|
+
async batchQueries(queries: Array<{ sql: string; params?: string[] }>): Promise<D1BatchResult[]> {
|
|
102
|
+
return Promise.all(
|
|
103
|
+
queries.map(async (query): Promise<D1BatchResult> => {
|
|
104
|
+
try {
|
|
105
|
+
const result = await this.executeQuery(query.sql, query.params);
|
|
106
|
+
return { query: query.sql, success: result.length > 0, result };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { query: query.sql, success: false, error: reasonOf(error) };
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fetch the D1 database record. */
|
|
115
|
+
async getDatabaseInfo(): Promise<D1> {
|
|
116
|
+
return cloudflareRequest("get D1 database info", () =>
|
|
117
|
+
this.getClient().d1.database.get(this.databaseId, { account_id: this.accountId }),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List the user tables in the database. */
|
|
122
|
+
async listTables(): Promise<string[]> {
|
|
123
|
+
const result = await this.executeQuery("SELECT name FROM sqlite_master WHERE type='table'");
|
|
124
|
+
return result
|
|
125
|
+
.flatMap((queryResult) => (queryResult.results ?? []) as unknown[])
|
|
126
|
+
.map((row) => (isNamedRow(row) ? row.name : undefined))
|
|
127
|
+
.filter((name): name is string => typeof name === "string");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Read a table's column schema via `PRAGMA table_info`. SQLite pragmas take no bind parameters, so
|
|
132
|
+
* the table name is interpolated — guard it against a strict identifier allowlist first, so the one
|
|
133
|
+
* un-parameterized SQL path in this client can never become an injection sink.
|
|
134
|
+
*/
|
|
135
|
+
async getTableSchema(tableName: string): Promise<unknown[]> {
|
|
136
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) {
|
|
137
|
+
throw new ValidationError({
|
|
138
|
+
message: "Invalid table name.",
|
|
139
|
+
detail: `Table name must be a plain SQL identifier, got: ${tableName}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const result = await this.executeQuery(`PRAGMA table_info(${tableName})`);
|
|
143
|
+
return result.flatMap((queryResult) => (queryResult.results ?? []) as unknown[]);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The database this manager targets and the account it lives in. */
|
|
147
|
+
getD1Info(): { databaseId: string; accountId: string } {
|
|
148
|
+
return { databaseId: this.databaseId, accountId: this.accountId };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getServiceType(): string {
|
|
152
|
+
return "D1 Database";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Prove access by reading the database record. Never throws. */
|
|
156
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
await this.getDatabaseInfo();
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Narrow a result row to one carrying a string `name` column (the `sqlite_master` shape). */
|
|
167
|
+
function isNamedRow(row: unknown): row is { name: string } {
|
|
168
|
+
return (
|
|
169
|
+
typeof row === "object" && row !== null && "name" in row && typeof (row as { name: unknown }).name === "string"
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Meta, D1PreparedStatement, D1Result } from "@cloudflare/workers-types";
|
|
5
|
+
import type { Cloudflare } from "cloudflare";
|
|
6
|
+
import type { QueryResult } from "cloudflare/resources/d1/database";
|
|
7
|
+
import { cloudflareRequest } from "../client/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A `D1PreparedStatement` backed by the D1 REST API instead of a Worker binding. It mirrors the
|
|
11
|
+
* binding's prepared-statement surface (`bind`/`run`/`all`/`first`/`raw`) closely enough that a
|
|
12
|
+
* Kysely `D1Dialect` (kysely-d1) can drive it unchanged — which is how `pithy migrate` runs the
|
|
13
|
+
* same query builder from a CLI/CI context against D1 over REST (issue #31).
|
|
14
|
+
*
|
|
15
|
+
* `D1PreparedStatement` and `D1Meta` are imported by name from `@cloudflare/workers-types`, so this
|
|
16
|
+
* class is structurally a drop-in for the binding's statement. They used to be named off the global
|
|
17
|
+
* scope, which resolved only because this package's own `tsconfig.json` loads the types — and every
|
|
18
|
+
* package here ships raw TypeScript, so an adopter compiling this file got `Cannot find name` unless
|
|
19
|
+
* their program happened to pull the types in too. Importing them is what makes the dependency real
|
|
20
|
+
* (#431).
|
|
21
|
+
*/
|
|
22
|
+
function mapMeta(restMeta?: QueryResult.Meta): D1Meta & Record<string, unknown> {
|
|
23
|
+
return {
|
|
24
|
+
duration: restMeta?.duration ?? 0,
|
|
25
|
+
size_after: restMeta?.size_after ?? 0,
|
|
26
|
+
rows_read: restMeta?.rows_read ?? 0,
|
|
27
|
+
rows_written: restMeta?.rows_written ?? 0,
|
|
28
|
+
last_row_id: restMeta?.last_row_id ?? 0,
|
|
29
|
+
changed_db: restMeta?.changed_db ?? false,
|
|
30
|
+
changes: restMeta?.changes ?? 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The REST `params` array is strings; coerce each bound value the way the D1 REST API expects. */
|
|
35
|
+
function toStringParams(params: unknown[]): string[] {
|
|
36
|
+
return params.map((value) => {
|
|
37
|
+
if (value === null || value === undefined) return "";
|
|
38
|
+
if (typeof value === "string") return value;
|
|
39
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class D1PreparedStatementREST<T = Record<string, unknown>> implements D1PreparedStatement {
|
|
45
|
+
private readonly sql: string;
|
|
46
|
+
|
|
47
|
+
private readonly boundParams: unknown[];
|
|
48
|
+
|
|
49
|
+
private readonly client: Cloudflare;
|
|
50
|
+
|
|
51
|
+
private readonly accountId: string;
|
|
52
|
+
|
|
53
|
+
private readonly databaseId: string;
|
|
54
|
+
|
|
55
|
+
constructor(sql: string, client: Cloudflare, accountId: string, databaseId: string, boundParams: unknown[] = []) {
|
|
56
|
+
this.sql = sql;
|
|
57
|
+
this.boundParams = boundParams;
|
|
58
|
+
this.client = client;
|
|
59
|
+
this.accountId = accountId;
|
|
60
|
+
this.databaseId = databaseId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Bind positional parameters, returning a new statement (the binding's immutable contract). */
|
|
64
|
+
bind(...values: unknown[]): D1PreparedStatementREST<T> {
|
|
65
|
+
return new D1PreparedStatementREST<T>(this.sql, this.client, this.accountId, this.databaseId, values);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
run<U = T>(): Promise<D1Result<U>> {
|
|
69
|
+
return this.executeQuery<U>();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
all<U = T>(): Promise<D1Result<U>> {
|
|
73
|
+
return this.executeQuery<U>();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async first<U = unknown>(colName: string): Promise<U | null>;
|
|
77
|
+
async first<U = T>(): Promise<U | null>;
|
|
78
|
+
async first<U = unknown>(colName?: string): Promise<U | null> {
|
|
79
|
+
const result = await this.executeQuery<Record<string, unknown>>();
|
|
80
|
+
const row = result.results[0] ?? null;
|
|
81
|
+
if (row === null) return null;
|
|
82
|
+
if (colName) return (row[colName] as U) ?? null;
|
|
83
|
+
return row as U;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async raw<U = unknown[]>(options: { columnNames: true }): Promise<[string[], ...U[]]>;
|
|
87
|
+
async raw<U = unknown[]>(options?: { columnNames?: false }): Promise<U[]>;
|
|
88
|
+
async raw<U = unknown[]>(options?: { columnNames?: boolean }): Promise<U[] | [string[], ...U[]]> {
|
|
89
|
+
const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
|
|
90
|
+
const response = await cloudflareRequest("D1 raw query", () =>
|
|
91
|
+
this.client.d1.database.raw(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
|
|
92
|
+
);
|
|
93
|
+
const rawResult = response.result[0];
|
|
94
|
+
const rows = (rawResult?.results?.rows ?? []) as U[];
|
|
95
|
+
if (options?.columnNames) {
|
|
96
|
+
const columns = rawResult?.results?.columns ?? [];
|
|
97
|
+
return [columns, ...rows];
|
|
98
|
+
}
|
|
99
|
+
return rows;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async executeQuery<U>(): Promise<D1Result<U>> {
|
|
103
|
+
const params = this.boundParams.length > 0 ? toStringParams(this.boundParams) : undefined;
|
|
104
|
+
const response = await cloudflareRequest("D1 query", () =>
|
|
105
|
+
this.client.d1.database.query(this.databaseId, { account_id: this.accountId, sql: this.sql, params }),
|
|
106
|
+
);
|
|
107
|
+
const queryResult = response.result[0];
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
meta: mapMeta(queryResult?.meta),
|
|
111
|
+
results: (queryResult?.results ?? []) as U[],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { cloudflareRequest, decodeResponse } from "../client/errors";
|
|
6
|
+
import { CloudflareManager } from "../client/manager";
|
|
7
|
+
|
|
8
|
+
/** A D1 database's identity, decoded from the create response. */
|
|
9
|
+
export const D1DatabaseInfo = z
|
|
10
|
+
.object({
|
|
11
|
+
uuid: z.string().describe("The CF-assigned database id (a uuid) used to address the database."),
|
|
12
|
+
name: z.string().describe("The database name."),
|
|
13
|
+
})
|
|
14
|
+
.describe("A Cloudflare D1 database's identity, as returned by the create endpoint.");
|
|
15
|
+
export type D1DatabaseInfo = z.output<typeof D1DatabaseInfo>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Account-level D1 control plane: **create and delete databases**. Customers use this to stand up
|
|
19
|
+
* and tear down per-environment databases (e.g. ephemeral staging), and `pithy add secrets` uses it
|
|
20
|
+
* to provision each environment's secrets D1. Addressed by account — unlike {@link CloudflareD1Manager},
|
|
21
|
+
* which targets one database id for queries.
|
|
22
|
+
*/
|
|
23
|
+
export class CloudflareD1Provisioner extends CloudflareManager {
|
|
24
|
+
getServiceType(): string {
|
|
25
|
+
return "Cloudflare D1 (control plane)";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Prove access by listing databases — a read, never a destructive create/delete. Never throws. */
|
|
29
|
+
async validateServiceAccess(): Promise<boolean> {
|
|
30
|
+
try {
|
|
31
|
+
await this.getClient().d1.database.list({ account_id: this.accountId });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every D1 database in the account — for prefix-scan reconcile teardown (`pithy feature destroy`). */
|
|
39
|
+
async listDatabases(): Promise<D1DatabaseInfo[]> {
|
|
40
|
+
return cloudflareRequest("list D1 databases", async () => {
|
|
41
|
+
const databases: D1DatabaseInfo[] = [];
|
|
42
|
+
for await (const db of this.getClient().d1.database.list({ account_id: this.accountId })) {
|
|
43
|
+
const parsed = D1DatabaseInfo.safeParse(db);
|
|
44
|
+
if (parsed.success) databases.push(parsed.data);
|
|
45
|
+
}
|
|
46
|
+
return databases;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Find a database by exact name in the account, or `null` — for idempotent provisioning. */
|
|
51
|
+
async findDatabaseByName(name: string): Promise<D1DatabaseInfo | null> {
|
|
52
|
+
return cloudflareRequest(`find D1 database ${name}`, async () => {
|
|
53
|
+
for await (const db of this.getClient().d1.database.list({ account_id: this.accountId, name })) {
|
|
54
|
+
const parsed = D1DatabaseInfo.safeParse(db);
|
|
55
|
+
if (parsed.success && parsed.data.name === name) return parsed.data;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Create a D1 database by name; returns its id and name. */
|
|
62
|
+
async createDatabase(name: string): Promise<D1DatabaseInfo> {
|
|
63
|
+
const response = await cloudflareRequest(`create D1 database ${name}`, () =>
|
|
64
|
+
this.getClient().d1.database.create({ account_id: this.accountId, name }),
|
|
65
|
+
);
|
|
66
|
+
return decodeResponse(D1DatabaseInfo, response, "D1 database create");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delete a D1 database by id. */
|
|
70
|
+
async deleteDatabase(databaseId: string): Promise<void> {
|
|
71
|
+
await cloudflareRequest(`delete D1 database ${databaseId}`, () =>
|
|
72
|
+
this.getClient().d1.database.delete(databaseId, { account_id: this.accountId }),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|