alchemy 0.7.1 → 0.7.3

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
+ import { Resource } from "../resource";
2
+ import { CloudflareApi, createCloudflareApi, } from "./api";
3
+ import { CloudflareApiError, handleApiError } from "./api-error";
4
+ /**
5
+ * Creates and manages Cloudflare Pipelines.
6
+ *
7
+ * Pipelines provide a managed data pipeline service that lets you collect, transform,
8
+ * and route data to various destinations like R2 buckets.
9
+ *
10
+ * @example
11
+ * // Create a basic pipeline with an R2 bucket destination
12
+ * const bucket = await R2Bucket("logs-bucket", {
13
+ * name: "logs-bucket"
14
+ * });
15
+ *
16
+ * const accessKey = alchemy.secret(process.env.R2_ACCESS_KEY_ID!);
17
+ * const secretKey = alchemy.secret(process.env.R2_SECRET_ACCESS_KEY!);
18
+ *
19
+ * const pipeline = await Pipeline("logs-pipeline", {
20
+ * name: "logs-pipeline",
21
+ * destination: {
22
+ * type: "r2",
23
+ * format: "json",
24
+ * path: {
25
+ * bucket: bucket.name,
26
+ * prefix: "app-logs",
27
+ * },
28
+ * credentials: {
29
+ * accessKeyId: accessKey,
30
+ * secretAccessKey: secretKey
31
+ * }
32
+ * },
33
+ * batch: {
34
+ * maxMb: 50,
35
+ * maxSeconds: 60
36
+ * }
37
+ * });
38
+ *
39
+ * @example
40
+ * // Create a pipeline with custom source configuration
41
+ * const customPipeline = await Pipeline("custom-pipeline", {
42
+ * name: "custom-pipeline",
43
+ * source: [{
44
+ * type: "http",
45
+ * format: "json",
46
+ * authentication: true,
47
+ * cors: {
48
+ * origins: ["https://example.com"]
49
+ * }
50
+ * }],
51
+ * destination: {
52
+ * type: "r2",
53
+ * format: "json",
54
+ * path: {
55
+ * bucket: "my-bucket",
56
+ * prefix: "data"
57
+ * },
58
+ * credentials: {
59
+ * accessKeyId: alchemy.secret(process.env.R2_ACCESS_KEY_ID!),
60
+ * secretAccessKey: alchemy.secret(process.env.R2_SECRET_ACCESS_KEY!)
61
+ * },
62
+ * compression: {
63
+ * type: "gzip"
64
+ * }
65
+ * }
66
+ * });
67
+ *
68
+ * @see https://developers.cloudflare.com/pipelines/
69
+ */
70
+ export const Pipeline = Resource("cloudflare::Pipeline", async function (id, props) {
71
+ const api = await createCloudflareApi(props);
72
+ const pipelineName = props.name || id;
73
+ if (this.phase === "delete") {
74
+ console.log("Deleting Cloudflare Pipeline:", pipelineName);
75
+ if (props.delete !== false) {
76
+ // Delete Pipeline
77
+ await deletePipeline(api, pipelineName);
78
+ }
79
+ // Return void (a deleted pipeline has no content)
80
+ return this.destroy();
81
+ }
82
+ else {
83
+ let pipelineData;
84
+ if (this.phase === "create") {
85
+ console.log("Creating Cloudflare Pipeline:", pipelineName);
86
+ pipelineData = await createPipeline(api, pipelineName, props);
87
+ }
88
+ else {
89
+ // Update operation
90
+ if (this.output?.id) {
91
+ console.log("Updating Cloudflare Pipeline:", pipelineName);
92
+ // Check if name is being changed, which is not allowed
93
+ if (props.name !== this.output.name) {
94
+ throw new Error("Cannot update Pipeline name after creation. Pipeline name is immutable.");
95
+ }
96
+ // Update the pipeline with new settings
97
+ pipelineData = await updatePipeline(api, pipelineName, props);
98
+ }
99
+ else {
100
+ // If no ID exists, fall back to creating a new pipeline
101
+ console.log("No existing Pipeline ID found, creating new Cloudflare Pipeline:", pipelineName);
102
+ pipelineData = await createPipeline(api, pipelineName, props);
103
+ }
104
+ }
105
+ return this({
106
+ type: "pipeline",
107
+ id: pipelineData.result.id,
108
+ name: pipelineName,
109
+ endpoint: pipelineData.result.endpoint,
110
+ version: pipelineData.result.version,
111
+ source: pipelineData.result.source.map((s) => ({
112
+ type: s.type,
113
+ format: s.format,
114
+ authentication: s.authentication,
115
+ cors: s.cors,
116
+ })),
117
+ destination: props.destination, // Use the input destination, not the API response
118
+ compression: props.compression,
119
+ accountId: api.accountId,
120
+ });
121
+ }
122
+ });
123
+ /**
124
+ * Get a pipeline
125
+ */
126
+ export async function getPipeline(api, pipelineName) {
127
+ const response = await api.get(`/accounts/${api.accountId}/pipelines/${pipelineName}`);
128
+ if (!response.ok) {
129
+ return await handleApiError(response, "getting", "Pipeline", pipelineName);
130
+ }
131
+ return (await response.json());
132
+ }
133
+ /**
134
+ * Delete a pipeline
135
+ */
136
+ export async function deletePipeline(api, pipelineName) {
137
+ // Delete Pipeline
138
+ const deleteResponse = await api.delete(`/accounts/${api.accountId}/pipelines/${pipelineName}`);
139
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
140
+ const errorData = await deleteResponse.json().catch(() => ({
141
+ errors: [{ message: deleteResponse.statusText }],
142
+ }));
143
+ throw new CloudflareApiError(`Error deleting Cloudflare Pipeline '${pipelineName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`, deleteResponse);
144
+ }
145
+ }
146
+ /**
147
+ * Create a new pipeline
148
+ */
149
+ export async function createPipeline(api, pipelineName, props) {
150
+ // Prepare the create payload
151
+ const createPayload = preparePipelinePayload(api, pipelineName, props);
152
+ const createResponse = await api.post(`/accounts/${api.accountId}/pipelines`, createPayload);
153
+ if (!createResponse.ok) {
154
+ return await handleApiError(createResponse, "creating", "Pipeline", pipelineName);
155
+ }
156
+ return (await createResponse.json());
157
+ }
158
+ /**
159
+ * Update a pipeline
160
+ */
161
+ export async function updatePipeline(api, pipelineName, props) {
162
+ // Get current pipeline to build update payload
163
+ const currentPipeline = await getPipeline(api, pipelineName);
164
+ // Prepare the update payload
165
+ const updatePayload = preparePipelinePayload(api, pipelineName, props, currentPipeline);
166
+ const updateResponse = await api.put(`/accounts/${api.accountId}/pipelines/${pipelineName}`, updatePayload);
167
+ if (!updateResponse.ok) {
168
+ return await handleApiError(updateResponse, "updating", "Pipeline", pipelineName);
169
+ }
170
+ return (await updateResponse.json());
171
+ }
172
+ /**
173
+ * Helper function to prepare pipeline payload for create/update operations
174
+ */
175
+ function preparePipelinePayload(api, pipelineName, props, currentPipeline) {
176
+ // Prepare the payload with name and source
177
+ const payload = {
178
+ name: pipelineName,
179
+ source: props.source ||
180
+ currentPipeline?.result.source || [
181
+ {
182
+ type: "http",
183
+ format: "json",
184
+ authentication: true,
185
+ cors: { origins: ["*"] },
186
+ },
187
+ ],
188
+ };
189
+ // Handle destination
190
+ if (props.destination) {
191
+ payload.destination = { ...props.destination };
192
+ // Handle special formatting for R2 destination
193
+ const r2Dest = props.destination;
194
+ // Format credentials for API
195
+ if (payload.destination.credentials) {
196
+ payload.destination.credentials = {
197
+ access_key_id: r2Dest.credentials.accessKeyId.unencrypted,
198
+ secret_access_key: r2Dest.credentials.secretAccessKey.unencrypted,
199
+ endpoint: r2Dest.credentials.endpoint ??
200
+ `https://${api.accountId}.r2.cloudflarestorage.com`,
201
+ };
202
+ }
203
+ // Format batch settings
204
+ payload.destination.batch = convertBatchSettings(payload.destination.batch);
205
+ }
206
+ else if (currentPipeline?.result.destination) {
207
+ payload.destination = currentPipeline.result.destination;
208
+ }
209
+ else if (!props.destination && !currentPipeline) {
210
+ throw new Error("An R2 destination is required for creating/updating a pipeline");
211
+ }
212
+ // Add compression if not specified
213
+ if (!payload.destination.compression) {
214
+ payload.destination.compression = { type: "gzip" };
215
+ }
216
+ return payload;
217
+ }
218
+ /**
219
+ * List all pipelines in an account
220
+ */
221
+ export async function listPipelines(api) {
222
+ const response = await api.get(`/accounts/${api.accountId}/pipelines`);
223
+ if (!response.ok) {
224
+ throw new CloudflareApiError(`Failed to list pipelines: ${response.statusText}`, response);
225
+ }
226
+ const data = (await response.json());
227
+ if (!data.success) {
228
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
229
+ throw new Error(`Failed to list pipelines: ${errorMessage}`);
230
+ }
231
+ // Transform API response
232
+ return (data.results || []).map((pipeline) => ({
233
+ name: pipeline.name,
234
+ id: pipeline.id,
235
+ }));
236
+ }
237
+ function convertBatchSettings(batch) {
238
+ const result = {};
239
+ if (batch?.maxMb !== undefined) {
240
+ // Convert MB to bytes
241
+ result.max_bytes = batch.maxMb * 1024 * 1024;
242
+ }
243
+ if (batch?.maxRows !== undefined) {
244
+ result.max_rows = batch.maxRows;
245
+ }
246
+ if (batch?.maxSeconds !== undefined) {
247
+ result.max_duration_s = batch.maxSeconds;
248
+ }
249
+ return result;
250
+ }
@@ -1,6 +1,7 @@
1
1
  import { deserialize, serialize } from "../serde";
2
2
  import { withExponentialBackoff } from "../util/retry";
3
3
  import { createCloudflareApi, } from "./api";
4
+ import { CloudflareApiError, handleApiError } from "./api-error";
4
5
  /**
5
6
  * State store implementation using Cloudflare R2 API
6
7
  * Uses R2 for immediate consistency compared to KV's eventual consistency
@@ -69,17 +70,12 @@ export class R2RestStateStore {
69
70
  const response = await withExponentialBackoff(async () => {
70
71
  const response = await this.api.get(listPath);
71
72
  if (!response.ok) {
72
- const errorData = await response.json().catch(() => ({
73
- errors: [{ message: response.statusText }],
74
- }));
75
- throw new Error(`Error listing R2 objects: ${errorData.errors?.[0]?.message || response.statusText}`);
73
+ await handleApiError(response, "list", "bucket", this.bucketName);
76
74
  }
77
75
  return response;
78
76
  },
79
77
  // Retry on transient errors
80
- (error) => error.message?.includes("502") ||
81
- error.message?.includes("503") ||
82
- error.message?.includes("timeout"), 5, // 5 retry attempts
78
+ isRetryableError, 5, // 5 retry attempts
83
79
  1000 // Start with 1 second delay
84
80
  );
85
81
  const data = (await response.json());
@@ -117,21 +113,13 @@ export class R2RestStateStore {
117
113
  try {
118
114
  const response = await withExponentialBackoff(async () => {
119
115
  const response = await this.api.get(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
120
- if (!response.ok) {
121
- if (response.status === 404) {
122
- return response;
123
- }
124
- const errorData = await response.json().catch(() => ({
125
- errors: [{ message: response.statusText }],
126
- }));
127
- throw new Error(`Error getting R2 object: ${errorData.errors?.[0]?.message || response.statusText}`);
116
+ if (!response.ok && response.status !== 404) {
117
+ await handleApiError(response, "get", "object", key);
128
118
  }
129
119
  return response;
130
120
  },
131
121
  // Retry on transient errors
132
- (error) => error.message?.includes("502") ||
133
- error.message?.includes("503") ||
134
- error.message?.includes("timeout"), 5, // 5 retry attempts
122
+ isRetryableError, 5, // 5 retry attempts
135
123
  1000 // Start with 1 second delay
136
124
  );
137
125
  if (response.status === 404) {
@@ -202,15 +190,12 @@ export class R2RestStateStore {
202
190
  },
203
191
  });
204
192
  if (!response.ok) {
205
- const errorData = await response.json().catch(() => ({
206
- errors: [{ message: response.statusText }],
207
- }));
208
- throw new Error(`Error writing to R2: ${errorData.errors?.[0]?.message || response.statusText}`);
193
+ await handleApiError(response, "put", "object", objectKey);
209
194
  }
210
195
  return response;
211
196
  },
212
197
  // Retry on transient errors
213
- (error) => error.message?.includes("503") || error.message?.includes("timeout"), 5, // 5 retry attempts
198
+ isRetryableError, 5, // 5 retry attempts
214
199
  1000 // Start with 1 second delay
215
200
  );
216
201
  }
@@ -224,17 +209,10 @@ export class R2RestStateStore {
224
209
  await withExponentialBackoff(async () => {
225
210
  const response = await this.api.delete(`/accounts/${this.api.accountId}/r2/buckets/${this.bucketName}/objects/${this.getObjectKey(key)}`);
226
211
  if (!response.ok && response.status !== 404) {
227
- const errorData = await response.json().catch(() => ({
228
- errors: [{ message: response.statusText }],
229
- }));
230
- throw new Error(`Error deleting from R2: ${errorData.errors?.[0]?.message || response.statusText}`);
212
+ await handleApiError(response, "delete", "object", key);
231
213
  }
232
214
  return response;
233
- },
234
- // Retry on transient errors
235
- (error) => error.message?.includes("502") ||
236
- error.message?.includes("503") ||
237
- error.message?.includes("timeout"), 5, // 5 retry attempts
215
+ }, isRetryableError, 5, // 5 retry attempts
238
216
  1000 // Start with 1 second delay
239
217
  );
240
218
  }
@@ -275,3 +253,13 @@ export class R2RestStateStore {
275
253
  }
276
254
  }
277
255
  }
256
+ function isRetryableError(error) {
257
+ if (error instanceof CloudflareApiError) {
258
+ return (error.status === 500 ||
259
+ error.status === 502 ||
260
+ error.status === 503 ||
261
+ error.message.includes("timeout") ||
262
+ error.message.includes("internal error"));
263
+ }
264
+ return false;
265
+ }
@@ -74,6 +74,15 @@ export interface WorkerProps<B extends Bindings = Bindings> extends CloudflareAp
74
74
  * Whether to adopt the Worker if it already exists when creating
75
75
  */
76
76
  adopt?: boolean;
77
+ /**
78
+ * The compatibility date for the worker
79
+ * @default "2024-09-09"
80
+ */
81
+ compatibilityDate?: string;
82
+ /**
83
+ * The compatibility flags for the worker
84
+ */
85
+ compatibilityFlags?: string[];
77
86
  }
78
87
  /**
79
88
  * Output returned after Worker creation/update
@@ -239,6 +239,8 @@ class NotFoundError extends Error {
239
239
  async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult) {
240
240
  // Prepare metadata with bindings
241
241
  const meta = {
242
+ compatibility_date: props.compatibilityDate ?? "2024-09-09",
243
+ compatibility_flags: props.compatibilityFlags,
242
244
  bindings: [],
243
245
  observability: {
244
246
  enabled: props.observability?.enabled !== false,
@@ -344,6 +346,13 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
344
346
  queue_name: binding.name,
345
347
  });
346
348
  }
349
+ else if (binding.type === "pipeline") {
350
+ meta.bindings.push({
351
+ type: "pipelines",
352
+ name: bindingName,
353
+ pipeline: binding.name,
354
+ });
355
+ }
347
356
  else {
348
357
  // @ts-expect-error - we should never reach here
349
358
  throw new Error(`Unsupported binding type: ${binding.type}`);
@@ -422,6 +431,10 @@ async function bundleWorkerScript(props) {
422
431
  minify: true,
423
432
  options: {
424
433
  keepNames: true, // Important for Durable Object classes
434
+ loader: {
435
+ ".sql": "text",
436
+ ".json": "json",
437
+ },
425
438
  },
426
439
  };
427
440
  // Merge with user-provided options
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "module": "./lib/index.js",
6
6
  "scripts": {
@@ -9,6 +9,7 @@ import type { R2Bucket } from "./bucket";
9
9
  import type { D1Database } from "./d1-database";
10
10
  import type { DurableObjectNamespace } from "./durable-object-namespace";
11
11
  import type { KVNamespace } from "./kv-namespace";
12
+ import type { Pipeline } from "./pipeline";
12
13
  import type { Queue } from "./queue";
13
14
  import type { Worker } from "./worker";
14
15
  import type { Workflow } from "./workflow";
@@ -25,6 +26,7 @@ export type Binding =
25
26
  | D1Database
26
27
  | DurableObjectNamespace
27
28
  | KVNamespace
29
+ | Pipeline
28
30
  | Queue
29
31
  | R2Bucket
30
32
  | Secret
@@ -47,6 +49,7 @@ export type WorkerBindingSpec =
47
49
  | WorkerBindingJson
48
50
  | WorkerBindingKVNamespace
49
51
  | WorkerBindingMTLSCertificate
52
+ | WorkerBindingPipeline
50
53
  | WorkerBindingPlainText
51
54
  | WorkerBindingQueue
52
55
  | WorkerBindingR2Bucket
@@ -335,3 +338,15 @@ export interface WorkerBindingWorkflow {
335
338
  */
336
339
  script_name?: string;
337
340
  }
341
+
342
+ /**
343
+ * Pipeline binding type
344
+ */
345
+ export interface WorkerBindingPipeline {
346
+ /** The name of the binding */
347
+ name: string;
348
+ /** Type identifier for Pipeline binding */
349
+ type: "pipelines";
350
+ /** Pipeline name */
351
+ pipeline: string;
352
+ }
@@ -1,3 +1,4 @@
1
+ import type { Pipeline } from "cloudflare:pipelines";
1
2
  import type { Secret } from "../secret";
2
3
  import type { Assets } from "./assets";
3
4
  import type { Binding } from "./bindings";
@@ -5,6 +6,7 @@ import type { R2Bucket as _R2Bucket } from "./bucket";
5
6
  import type { D1Database as _D1Database } from "./d1-database";
6
7
  import type { DurableObjectNamespace as _DurableObjectNamespace } from "./durable-object-namespace";
7
8
  import type { KVNamespace as _KVNamespace } from "./kv-namespace";
9
+ import type { Pipeline as _Pipeline } from "./pipeline";
8
10
  import type { Queue as _Queue } from "./queue";
9
11
  import type { Worker as _Worker } from "./worker";
10
12
  import type { Workflow as _Workflow } from "./workflow";
@@ -27,4 +29,6 @@ export type Bound<T extends Binding> = T extends _DurableObjectNamespace
27
29
  ? D1Database
28
30
  : T extends _Queue
29
31
  ? Queue
30
- : Service;
32
+ : T extends _Pipeline<infer R>
33
+ ? Pipeline<R>
34
+ : Service;
@@ -415,6 +415,7 @@ export async function emptyBucket(
415
415
 
416
416
  try {
417
417
  do {
418
+ console.log(`Listing objects in bucket ${bucketName}`);
418
419
  // List objects in the bucket
419
420
  const { objects, continuationToken: nextToken } = await listObjects(
420
421
  r2,
@@ -49,6 +49,14 @@ export interface D1DatabaseProps extends CloudflareApiOptions {
49
49
  * @default true
50
50
  */
51
51
  delete?: boolean;
52
+
53
+ /**
54
+ * Whether to adopt an existing database with the same name if it exists
55
+ * If true and a database with the same name exists, it will be adopted rather than creating a new one
56
+ *
57
+ * @default false
58
+ */
59
+ adopt?: boolean;
52
60
  }
53
61
 
54
62
  /**
@@ -108,6 +116,16 @@ export interface D1Database
108
116
  * primaryLocationHint: "wnam"
109
117
  * });
110
118
  *
119
+ * @example
120
+ * // Adopt an existing database if it already exists instead of failing
121
+ * const existingDb = await D1Database("existing-db", {
122
+ * name: "existing-db",
123
+ * adopt: true,
124
+ * readReplication: {
125
+ * mode: "auto"
126
+ * }
127
+ * });
128
+ *
111
129
  * @see https://developers.cloudflare.com/d1/
112
130
  */
113
131
  export const D1Database = Resource(
@@ -135,7 +153,41 @@ export const D1Database = Resource(
135
153
 
136
154
  if (this.phase === "create") {
137
155
  console.log("Creating D1 database:", databaseName);
138
- dbData = await createDatabase(api, databaseName, props);
156
+ try {
157
+ dbData = await createDatabase(api, databaseName, props);
158
+ } catch (error) {
159
+ // Check if this is a "database already exists" error and adopt is enabled
160
+ if (
161
+ props.adopt &&
162
+ error instanceof CloudflareApiError &&
163
+ error.message.includes("already exists")
164
+ ) {
165
+ console.log(`Database ${databaseName} already exists, adopting it`);
166
+ // Find the existing database by name
167
+ const databases = await listDatabases(api, databaseName);
168
+ const existingDb = databases.find((db) => db.name === databaseName);
169
+
170
+ if (!existingDb) {
171
+ throw new Error(
172
+ `Failed to find existing database '${databaseName}' for adoption`
173
+ );
174
+ }
175
+
176
+ // Get the database details using its ID
177
+ dbData = await getDatabase(api, existingDb.id);
178
+
179
+ // Update the database with the provided properties
180
+ if (props.readReplication) {
181
+ console.log(
182
+ `Updating adopted database ${databaseName} with new properties`
183
+ );
184
+ dbData = await updateDatabase(api, existingDb.id, props);
185
+ }
186
+ } else {
187
+ // Re-throw the error if adopt is false or it's not a "database already exists" error
188
+ throw error;
189
+ }
190
+ }
139
191
  } else {
140
192
  // Update operation
141
193
  if (this.output?.id) {
@@ -272,9 +324,15 @@ export async function deleteDatabase(
272
324
  * List all D1 databases in an account
273
325
  */
274
326
  export async function listDatabases(
275
- api: CloudflareApi
327
+ api: CloudflareApi,
328
+ name?: string
276
329
  ): Promise<{ name: string; id: string }[]> {
277
- const response = await api.get(`/accounts/${api.accountId}/d1/database`);
330
+ // Construct query string if name is provided
331
+ const queryParams = name ? `?name=${encodeURIComponent(name)}` : "";
332
+
333
+ const response = await api.get(
334
+ `/accounts/${api.accountId}/d1/database${queryParams}`
335
+ );
278
336
 
279
337
  if (!response.ok) {
280
338
  throw new CloudflareApiError(
@@ -10,6 +10,7 @@ export * from "./dns-records";
10
10
  export * from "./durable-object-namespace";
11
11
  export * from "./kv-namespace";
12
12
  export * from "./permission-groups";
13
+ export * from "./pipeline";
13
14
  export * from "./queue";
14
15
  export * from "./r2-rest-state-store";
15
16
  export * from "./worker";