alchemy 0.6.0 → 0.7.1

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,192 @@
1
+ import { Resource } from "../resource";
2
+ import { CloudflareApi, createCloudflareApi, } from "./api";
3
+ import { CloudflareApiError, handleApiError } from "./api-error";
4
+ /**
5
+ * Creates and manages Cloudflare Queues.
6
+ *
7
+ * Queues provide a managed queue system for reliable message delivery
8
+ * between workers and other systems.
9
+ *
10
+ * @example
11
+ * // Create a basic queue with default settings
12
+ * const basicQueue = await Queue("my-app-queue", {
13
+ * name: "my-app-queue"
14
+ * });
15
+ *
16
+ * @example
17
+ * // Create a queue with custom settings
18
+ * const customQueue = await Queue("delayed-queue", {
19
+ * name: "delayed-queue",
20
+ * settings: {
21
+ * deliveryDelay: 30, // 30 second delay before message delivery
22
+ * messageRetentionPeriod: 86400 // Store messages for 1 day
23
+ * }
24
+ * });
25
+ *
26
+ * @example
27
+ * // Create a paused queue for later activation
28
+ * const pausedQueue = await Queue("paused-queue", {
29
+ * name: "paused-queue",
30
+ * settings: {
31
+ * deliveryPaused: true
32
+ * }
33
+ * });
34
+ *
35
+ * @see https://developers.cloudflare.com/queues/
36
+ */
37
+ export const Queue = Resource("cloudflare::Queue", async function (id, props) {
38
+ const api = await createCloudflareApi(props);
39
+ const queueName = props.name || id;
40
+ if (this.phase === "delete") {
41
+ console.log("Deleting Cloudflare Queue:", queueName);
42
+ if (props.delete !== false) {
43
+ // Delete Queue
44
+ await deleteQueue(api, this.output?.id);
45
+ }
46
+ // Return void (a deleted queue has no content)
47
+ return this.destroy();
48
+ }
49
+ else {
50
+ let queueData;
51
+ if (this.phase === "create") {
52
+ console.log("Creating Cloudflare Queue:", queueName);
53
+ queueData = await createQueue(api, queueName, props);
54
+ }
55
+ else {
56
+ // Update operation
57
+ if (this.output?.id) {
58
+ console.log("Updating Cloudflare Queue:", queueName);
59
+ // Check if name is being changed, which is not allowed
60
+ if (props.name !== this.output.name) {
61
+ throw new Error("Cannot update Queue name after creation. Queue name is immutable.");
62
+ }
63
+ // Update the queue with new settings
64
+ queueData = await updateQueue(api, this.output.id, props);
65
+ }
66
+ else {
67
+ // If no ID exists, fall back to creating a new queue
68
+ console.log("No existing Queue ID found, creating new Cloudflare Queue:", queueName);
69
+ queueData = await createQueue(api, queueName, props);
70
+ }
71
+ }
72
+ return this({
73
+ type: "queue",
74
+ id: queueData.result.queue_id || "",
75
+ name: queueName,
76
+ settings: queueData.result.settings
77
+ ? {
78
+ deliveryDelay: queueData.result.settings.delivery_delay,
79
+ deliveryPaused: queueData.result.settings.delivery_paused,
80
+ messageRetentionPeriod: queueData.result.settings.message_retention_period,
81
+ }
82
+ : undefined,
83
+ createdOn: queueData.result.created_on || new Date().toISOString(),
84
+ modifiedOn: queueData.result.modified_on || new Date().toISOString(),
85
+ accountId: api.accountId,
86
+ });
87
+ }
88
+ });
89
+ /**
90
+ * Create a new Cloudflare Queue
91
+ */
92
+ export async function createQueue(api, queueName, props) {
93
+ // Prepare the create payload
94
+ const createPayload = {
95
+ queue_name: queueName,
96
+ };
97
+ // Add settings if provided
98
+ if (props.settings) {
99
+ createPayload.settings = {};
100
+ if (props.settings.deliveryDelay !== undefined) {
101
+ createPayload.settings.delivery_delay = props.settings.deliveryDelay;
102
+ }
103
+ if (props.settings.deliveryPaused !== undefined) {
104
+ createPayload.settings.delivery_paused = props.settings.deliveryPaused;
105
+ }
106
+ if (props.settings.messageRetentionPeriod !== undefined) {
107
+ createPayload.settings.message_retention_period =
108
+ props.settings.messageRetentionPeriod;
109
+ }
110
+ }
111
+ const createResponse = await api.post(`/accounts/${api.accountId}/queues`, createPayload);
112
+ if (!createResponse.ok) {
113
+ return await handleApiError(createResponse, "creating", "Queue", queueName);
114
+ }
115
+ return (await createResponse.json());
116
+ }
117
+ /**
118
+ * Get a Cloudflare Queue
119
+ */
120
+ export async function getQueue(api, queueId) {
121
+ const response = await api.get(`/accounts/${api.accountId}/queues/${queueId}`);
122
+ if (!response.ok) {
123
+ return await handleApiError(response, "getting", "Queue", queueId);
124
+ }
125
+ return (await response.json());
126
+ }
127
+ /**
128
+ * Delete a Cloudflare Queue
129
+ */
130
+ export async function deleteQueue(api, queueId) {
131
+ if (!queueId) {
132
+ console.log("No Queue ID provided, skipping delete");
133
+ return;
134
+ }
135
+ // Delete Queue
136
+ const deleteResponse = await api.delete(`/accounts/${api.accountId}/queues/${queueId}`);
137
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
138
+ const errorData = await deleteResponse.json().catch(() => ({
139
+ errors: [{ message: deleteResponse.statusText }],
140
+ }));
141
+ throw new CloudflareApiError(`Error deleting Cloudflare Queue '${queueId}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`, deleteResponse);
142
+ }
143
+ }
144
+ /**
145
+ * Update a Cloudflare Queue
146
+ *
147
+ * Note: According to Cloudflare API, the queue name cannot be changed after creation.
148
+ * Only the settings can be updated.
149
+ */
150
+ export async function updateQueue(api, queueId, props) {
151
+ // Prepare the update payload - only include settings
152
+ const updatePayload = {};
153
+ // Add settings if provided
154
+ if (props.settings) {
155
+ updatePayload.settings = {};
156
+ if (props.settings.deliveryDelay !== undefined) {
157
+ updatePayload.settings.delivery_delay = props.settings.deliveryDelay;
158
+ }
159
+ if (props.settings.deliveryPaused !== undefined) {
160
+ updatePayload.settings.delivery_paused = props.settings.deliveryPaused;
161
+ }
162
+ if (props.settings.messageRetentionPeriod !== undefined) {
163
+ updatePayload.settings.message_retention_period =
164
+ props.settings.messageRetentionPeriod;
165
+ }
166
+ }
167
+ // Use PATCH for partial updates (only settings can be updated)
168
+ const updateResponse = await api.patch(`/accounts/${api.accountId}/queues/${queueId}`, updatePayload);
169
+ if (!updateResponse.ok) {
170
+ return await handleApiError(updateResponse, "updating", "Queue", queueId);
171
+ }
172
+ return (await updateResponse.json());
173
+ }
174
+ /**
175
+ * List all Cloudflare Queues in an account
176
+ */
177
+ export async function listQueues(api) {
178
+ const response = await api.get(`/accounts/${api.accountId}/queues`);
179
+ if (!response.ok) {
180
+ throw new CloudflareApiError(`Failed to list queues: ${response.statusText}`, response);
181
+ }
182
+ const data = (await response.json());
183
+ if (!data.success) {
184
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
185
+ throw new Error(`Failed to list queues: ${errorMessage}`);
186
+ }
187
+ // Transform API response
188
+ return (data.result || []).map((queue) => ({
189
+ name: queue.queue_name,
190
+ id: queue.queue_id,
191
+ }));
192
+ }
@@ -2,13 +2,14 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs/promises";
3
3
  import { Bundle } from "../esbuild/bundle";
4
4
  import { Resource } from "../resource";
5
- import { isSecret } from "../secret";
6
5
  import { getContentType } from "../util/content-type";
7
6
  import { withExponentialBackoff } from "../util/retry";
8
7
  import { slugify } from "../util/slugify";
9
8
  import { createCloudflareApi, } from "./api";
10
- import { isAssets, isDurableObjectNamespace, } from "./bindings";
11
- import { isKVNamespace } from "./kv-namespace";
9
+ import {} from "./assets";
10
+ import {} from "./bindings";
11
+ import {} from "./durable-object-namespace";
12
+ import { upsertWorkflow } from "./workflow";
12
13
  /**
13
14
  * A Cloudflare Worker is a serverless function that can be deployed to the Cloudflare network.
14
15
  *
@@ -111,10 +112,16 @@ export const Worker = Resource("cloudflare::Worker", {
111
112
  const scriptContent = props.script ?? (await bundleWorkerScript(props));
112
113
  // Find any assets bindings
113
114
  const assetsBindings = [];
115
+ const workflowsBindings = [];
114
116
  if (props.bindings) {
115
117
  for (const [bindingName, binding] of Object.entries(props.bindings)) {
116
- if (isAssets(binding)) {
117
- assetsBindings.push({ name: bindingName, assets: binding });
118
+ if (typeof binding === "object") {
119
+ if (binding.type === "assets") {
120
+ assetsBindings.push({ name: bindingName, assets: binding });
121
+ }
122
+ else if (binding.type === "workflow") {
123
+ workflowsBindings.push(binding);
124
+ }
118
125
  }
119
126
  }
120
127
  }
@@ -129,8 +136,14 @@ export const Worker = Resource("cloudflare::Worker", {
129
136
  }
130
137
  // Prepare metadata with bindings
131
138
  const scriptMetadata = await prepareWorkerMetadata(this, oldBindings, props, assetUploadResult);
132
- // Upload the worker script
133
139
  await putWorker(api, workerName, scriptContent, scriptMetadata);
140
+ for (const workflow of workflowsBindings) {
141
+ await upsertWorkflow(api, {
142
+ workflowName: workflow.workflowName,
143
+ className: workflow.className,
144
+ scriptName: workerName,
145
+ });
146
+ }
134
147
  // TODO: it is less than ideal that this can fail, resulting in state problem
135
148
  await this.set("bindings", props.bindings);
136
149
  // Handle worker URL if requested
@@ -139,8 +152,10 @@ export const Worker = Resource("cloudflare::Worker", {
139
152
  const now = Date.now();
140
153
  // Construct the output
141
154
  return this({
155
+ ...props,
142
156
  type: "service",
143
157
  id,
158
+ entrypoint: props.entrypoint,
144
159
  name: workerName,
145
160
  script: scriptContent,
146
161
  format: props.format || "esm", // Include format in the output
@@ -251,18 +266,25 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
251
266
  // Convert bindings to the format expected by the API
252
267
  for (const [bindingName, binding] of Object.entries(bindings)) {
253
268
  // Create a copy of the binding to avoid modifying the original
254
- if (isKVNamespace(binding)) {
269
+ if (typeof binding === "string") {
255
270
  meta.bindings.push({
256
- type: "kv_namespace",
271
+ type: "plain_text",
257
272
  name: bindingName,
258
- namespace_id: binding.namespaceId,
273
+ text: binding,
259
274
  });
260
275
  }
261
- else if (typeof binding === "string") {
276
+ else if (binding.type === "d1") {
262
277
  meta.bindings.push({
263
- type: "plain_text",
278
+ type: "d1",
264
279
  name: bindingName,
265
- text: binding,
280
+ id: binding.id,
281
+ });
282
+ }
283
+ else if (binding.type === "kv_namespace") {
284
+ meta.bindings.push({
285
+ type: "kv_namespace",
286
+ name: bindingName,
287
+ namespace_id: binding.namespaceId,
266
288
  });
267
289
  }
268
290
  else if (binding.type === "service") {
@@ -273,33 +295,15 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
273
295
  });
274
296
  }
275
297
  else if (binding.type === "durable_object_namespace") {
276
- const stableId = binding.id;
277
- const className = binding.className;
278
298
  meta.bindings.push({
279
299
  type: "durable_object_namespace",
280
300
  name: bindingName,
281
- class_name: className,
301
+ class_name: binding.className,
282
302
  script_name: binding.scriptName,
283
303
  environment: binding.environment,
284
304
  namespace_id: binding.namespaceId,
285
305
  });
286
- const oldBinding = Object.values(oldBindings ?? {})
287
- ?.filter(isDurableObjectNamespace)
288
- ?.find((b) => b.id === stableId);
289
- if (!oldBinding) {
290
- if (binding.sqlite) {
291
- meta.migrations.new_sqlite_classes.push(className);
292
- }
293
- else {
294
- meta.migrations.new_classes.push(className);
295
- }
296
- }
297
- else if (oldBinding.className !== className) {
298
- meta.migrations.renamed_classes.push({
299
- from: oldBinding.className,
300
- to: className,
301
- });
302
- }
306
+ configureClassMigration(binding, binding.id, binding.className);
303
307
  }
304
308
  else if (binding.type === "r2_bucket") {
305
309
  meta.bindings.push({
@@ -308,25 +312,65 @@ async function prepareWorkerMetadata(ctx, oldBindings, props, assetUploadResult)
308
312
  bucket_name: binding.name,
309
313
  });
310
314
  }
311
- else if (isAssets(binding)) {
315
+ else if (binding.type === "assets") {
312
316
  meta.bindings.push({
313
317
  type: "assets",
314
318
  name: bindingName,
315
319
  });
316
320
  }
317
- else if (isSecret(binding)) {
321
+ else if (binding.type === "secret") {
318
322
  meta.bindings.push({
319
323
  type: "secret_text",
320
324
  name: bindingName,
321
325
  text: binding.unencrypted,
322
326
  });
323
327
  }
328
+ else if (binding.type === "workflow") {
329
+ meta.bindings.push({
330
+ type: "workflow",
331
+ name: bindingName,
332
+ workflow_name: binding.workflowName,
333
+ class_name: binding.className,
334
+ // this should be set if the Workflow is in another script ...
335
+ // script_name: ??,
336
+ });
337
+ // it's unclear whether this is needed, but it works both ways
338
+ configureClassMigration(binding, binding.id, binding.className);
339
+ }
340
+ else if (binding.type === "queue") {
341
+ meta.bindings.push({
342
+ type: "queue",
343
+ name: bindingName,
344
+ queue_name: binding.name,
345
+ });
346
+ }
324
347
  else {
325
348
  // @ts-expect-error - we should never reach here
326
349
  throw new Error(`Unsupported binding type: ${binding.type}`);
327
350
  }
328
351
  }
352
+ function configureClassMigration(binding, stableId, className) {
353
+ const oldBinding = Object.values(oldBindings ?? {})
354
+ ?.filter((b) => typeof b === "object" &&
355
+ (b.type === "durable_object_namespace" || b.type === "workflow"))
356
+ ?.find((b) => b.id === stableId);
357
+ if (!oldBinding) {
358
+ if (binding.type === "durable_object_namespace" && binding.sqlite) {
359
+ meta.migrations.new_sqlite_classes.push(className);
360
+ }
361
+ else {
362
+ meta.migrations.new_classes.push(className);
363
+ }
364
+ }
365
+ else if (oldBinding.className !== className) {
366
+ meta.migrations.renamed_classes.push({
367
+ from: oldBinding.className,
368
+ to: className,
369
+ });
370
+ }
371
+ }
329
372
  // Convert env variables to plain_text bindings
373
+ // TODO(sam): remove Worker.env in favor of always bindings
330
374
  if (props.env) {
331
375
  for (const [key, value] of Object.entries(props.env)) {
332
376
  meta.bindings.push({
@@ -0,0 +1,46 @@
1
+ import type { CloudflareApi } from "./api";
2
+ export interface WorkflowProps {
3
+ /**
4
+ * Name of the workflow
5
+ *
6
+ * @maxLength 64
7
+ * @minLength 1
8
+ * @default - className if provided, otherwise id
9
+ */
10
+ workflowName?: string;
11
+ /**
12
+ * Name of the class that implements the workflow
13
+ *
14
+ * @maxLength 255
15
+ * @minLength 1
16
+ * @default - workflowName if provided, otherwise id
17
+ */
18
+ className?: string;
19
+ }
20
+ export declare class Workflow<PARAMS = unknown> {
21
+ readonly id: string;
22
+ readonly type: "workflow";
23
+ /**
24
+ * Phantom property to preserve workflow params at the type level.
25
+ *
26
+ * No value exists.
27
+ */
28
+ readonly _PARAMS: PARAMS;
29
+ readonly workflowName: string;
30
+ readonly className: string;
31
+ constructor(id: string, props?: WorkflowProps);
32
+ }
33
+ export interface WorkflowMetadata {
34
+ id: string;
35
+ class_name: string;
36
+ created_on: string;
37
+ modified_on: string;
38
+ name: string;
39
+ script_name: string;
40
+ triggered_on: string;
41
+ version_id: string;
42
+ }
43
+ export declare function upsertWorkflow(api: CloudflareApi, props: WorkflowProps & {
44
+ workflowName: string;
45
+ scriptName: string;
46
+ }): Promise<WorkflowMetadata>;
@@ -0,0 +1,29 @@
1
+ import { handleApiError } from "./api-error";
2
+ export class Workflow {
3
+ id;
4
+ type = "workflow";
5
+ /**
6
+ * Phantom property to preserve workflow params at the type level.
7
+ *
8
+ * No value exists.
9
+ */
10
+ _PARAMS = undefined;
11
+ workflowName;
12
+ className;
13
+ constructor(id, props = {}) {
14
+ this.id = id;
15
+ this.workflowName = props.workflowName ?? props.className ?? id;
16
+ this.className = props.className ?? this.workflowName;
17
+ }
18
+ }
19
+ export async function upsertWorkflow(api, props) {
20
+ const response = await api.put(`/accounts/${api.accountId}/workflows/${props.workflowName}`, {
21
+ class_name: props.className,
22
+ script_name: props.scriptName,
23
+ });
24
+ if (!response.ok) {
25
+ await handleApiError(response, "create", "workflow", props.workflowName);
26
+ }
27
+ const body = (await response.json());
28
+ return body.result;
29
+ }