alchemy 0.7.1 → 0.7.2

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
+ }
@@ -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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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";