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,371 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import {
4
+ CloudflareApi,
5
+ createCloudflareApi,
6
+ type CloudflareApiOptions,
7
+ } from "./api";
8
+ import { CloudflareApiError, handleApiError } from "./api-error";
9
+
10
+ /**
11
+ * Settings for a Cloudflare Queue
12
+ */
13
+ export interface QueueSettings {
14
+ /**
15
+ * Delay in seconds before message delivery
16
+ * Queue will not deliver messages until this time has elapsed
17
+ */
18
+ deliveryDelay?: number;
19
+
20
+ /**
21
+ * Whether delivery is paused
22
+ * If true, the queue will not deliver messages to consumers
23
+ */
24
+ deliveryPaused?: boolean;
25
+
26
+ /**
27
+ * Period in seconds to retain messages
28
+ * Messages will be automatically deleted after this time
29
+ */
30
+ messageRetentionPeriod?: number;
31
+ }
32
+
33
+ /**
34
+ * Properties for creating or updating a Cloudflare Queue
35
+ */
36
+ export interface QueueProps extends CloudflareApiOptions {
37
+ /**
38
+ * Name of the queue
39
+ * Required during creation
40
+ * Cannot be changed after creation
41
+ */
42
+ name: string;
43
+
44
+ /**
45
+ * Settings for the queue
46
+ * These can be updated after queue creation
47
+ */
48
+ settings?: QueueSettings;
49
+
50
+ /**
51
+ * Whether to delete the queue.
52
+ * If set to false, the queue will remain but the resource will be removed from state
53
+ *
54
+ * @default true
55
+ */
56
+ delete?: boolean;
57
+ }
58
+
59
+ /**
60
+ * Output returned after Cloudflare Queue creation/update
61
+ */
62
+ export interface Queue extends Resource<"cloudflare::Queue">, QueueProps {
63
+ /**
64
+ * Type identifier for Cloudflare Queue
65
+ */
66
+ type: "queue";
67
+
68
+ /**
69
+ * The unique ID of the queue
70
+ */
71
+ id: string;
72
+
73
+ /**
74
+ * Time when the queue was created
75
+ */
76
+ createdOn: string;
77
+
78
+ /**
79
+ * Modified timestamp
80
+ */
81
+ modifiedOn: string;
82
+ }
83
+
84
+ interface CloudflareQueueResponse {
85
+ result: {
86
+ queue_id?: string;
87
+ queue_name: string;
88
+ created_on?: string;
89
+ modified_on?: string;
90
+ settings?: {
91
+ delivery_delay?: number;
92
+ delivery_paused?: boolean;
93
+ message_retention_period?: number;
94
+ };
95
+ };
96
+ success: boolean;
97
+ errors: Array<{ code: number; message: string }>;
98
+ messages: string[];
99
+ }
100
+
101
+ /**
102
+ * Creates and manages Cloudflare Queues.
103
+ *
104
+ * Queues provide a managed queue system for reliable message delivery
105
+ * between workers and other systems.
106
+ *
107
+ * @example
108
+ * // Create a basic queue with default settings
109
+ * const basicQueue = await Queue("my-app-queue", {
110
+ * name: "my-app-queue"
111
+ * });
112
+ *
113
+ * @example
114
+ * // Create a queue with custom settings
115
+ * const customQueue = await Queue("delayed-queue", {
116
+ * name: "delayed-queue",
117
+ * settings: {
118
+ * deliveryDelay: 30, // 30 second delay before message delivery
119
+ * messageRetentionPeriod: 86400 // Store messages for 1 day
120
+ * }
121
+ * });
122
+ *
123
+ * @example
124
+ * // Create a paused queue for later activation
125
+ * const pausedQueue = await Queue("paused-queue", {
126
+ * name: "paused-queue",
127
+ * settings: {
128
+ * deliveryPaused: true
129
+ * }
130
+ * });
131
+ *
132
+ * @see https://developers.cloudflare.com/queues/
133
+ */
134
+ export const Queue = Resource(
135
+ "cloudflare::Queue",
136
+ async function (
137
+ this: Context<Queue>,
138
+ id: string,
139
+ props: QueueProps
140
+ ): Promise<Queue> {
141
+ const api = await createCloudflareApi(props);
142
+ const queueName = props.name || id;
143
+
144
+ if (this.phase === "delete") {
145
+ console.log("Deleting Cloudflare Queue:", queueName);
146
+ if (props.delete !== false) {
147
+ // Delete Queue
148
+ await deleteQueue(api, this.output?.id);
149
+ }
150
+
151
+ // Return void (a deleted queue has no content)
152
+ return this.destroy();
153
+ } else {
154
+ let queueData: CloudflareQueueResponse;
155
+
156
+ if (this.phase === "create") {
157
+ console.log("Creating Cloudflare Queue:", queueName);
158
+ queueData = await createQueue(api, queueName, props);
159
+ } else {
160
+ // Update operation
161
+ if (this.output?.id) {
162
+ console.log("Updating Cloudflare Queue:", queueName);
163
+
164
+ // Check if name is being changed, which is not allowed
165
+ if (props.name !== this.output.name) {
166
+ throw new Error(
167
+ "Cannot update Queue name after creation. Queue name is immutable."
168
+ );
169
+ }
170
+
171
+ // Update the queue with new settings
172
+ queueData = await updateQueue(api, this.output.id, props);
173
+ } else {
174
+ // If no ID exists, fall back to creating a new queue
175
+ console.log(
176
+ "No existing Queue ID found, creating new Cloudflare Queue:",
177
+ queueName
178
+ );
179
+ queueData = await createQueue(api, queueName, props);
180
+ }
181
+ }
182
+
183
+ return this({
184
+ type: "queue",
185
+ id: queueData.result.queue_id || "",
186
+ name: queueName,
187
+ settings: queueData.result.settings
188
+ ? {
189
+ deliveryDelay: queueData.result.settings.delivery_delay,
190
+ deliveryPaused: queueData.result.settings.delivery_paused,
191
+ messageRetentionPeriod:
192
+ queueData.result.settings.message_retention_period,
193
+ }
194
+ : undefined,
195
+ createdOn: queueData.result.created_on || new Date().toISOString(),
196
+ modifiedOn: queueData.result.modified_on || new Date().toISOString(),
197
+ accountId: api.accountId,
198
+ });
199
+ }
200
+ }
201
+ );
202
+
203
+ /**
204
+ * Create a new Cloudflare Queue
205
+ */
206
+ export async function createQueue(
207
+ api: CloudflareApi,
208
+ queueName: string,
209
+ props: QueueProps
210
+ ): Promise<CloudflareQueueResponse> {
211
+ // Prepare the create payload
212
+ const createPayload: any = {
213
+ queue_name: queueName,
214
+ };
215
+
216
+ // Add settings if provided
217
+ if (props.settings) {
218
+ createPayload.settings = {};
219
+
220
+ if (props.settings.deliveryDelay !== undefined) {
221
+ createPayload.settings.delivery_delay = props.settings.deliveryDelay;
222
+ }
223
+
224
+ if (props.settings.deliveryPaused !== undefined) {
225
+ createPayload.settings.delivery_paused = props.settings.deliveryPaused;
226
+ }
227
+
228
+ if (props.settings.messageRetentionPeriod !== undefined) {
229
+ createPayload.settings.message_retention_period =
230
+ props.settings.messageRetentionPeriod;
231
+ }
232
+ }
233
+
234
+ const createResponse = await api.post(
235
+ `/accounts/${api.accountId}/queues`,
236
+ createPayload
237
+ );
238
+
239
+ if (!createResponse.ok) {
240
+ return await handleApiError(createResponse, "creating", "Queue", queueName);
241
+ }
242
+
243
+ return (await createResponse.json()) as CloudflareQueueResponse;
244
+ }
245
+
246
+ /**
247
+ * Get a Cloudflare Queue
248
+ */
249
+ export async function getQueue(
250
+ api: CloudflareApi,
251
+ queueId: string
252
+ ): Promise<CloudflareQueueResponse> {
253
+ const response = await api.get(
254
+ `/accounts/${api.accountId}/queues/${queueId}`
255
+ );
256
+
257
+ if (!response.ok) {
258
+ return await handleApiError(response, "getting", "Queue", queueId);
259
+ }
260
+
261
+ return (await response.json()) as CloudflareQueueResponse;
262
+ }
263
+
264
+ /**
265
+ * Delete a Cloudflare Queue
266
+ */
267
+ export async function deleteQueue(
268
+ api: CloudflareApi,
269
+ queueId?: string
270
+ ): Promise<void> {
271
+ if (!queueId) {
272
+ console.log("No Queue ID provided, skipping delete");
273
+ return;
274
+ }
275
+
276
+ // Delete Queue
277
+ const deleteResponse = await api.delete(
278
+ `/accounts/${api.accountId}/queues/${queueId}`
279
+ );
280
+
281
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
282
+ const errorData: any = await deleteResponse.json().catch(() => ({
283
+ errors: [{ message: deleteResponse.statusText }],
284
+ }));
285
+ throw new CloudflareApiError(
286
+ `Error deleting Cloudflare Queue '${queueId}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
287
+ deleteResponse
288
+ );
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Update a Cloudflare Queue
294
+ *
295
+ * Note: According to Cloudflare API, the queue name cannot be changed after creation.
296
+ * Only the settings can be updated.
297
+ */
298
+ export async function updateQueue(
299
+ api: CloudflareApi,
300
+ queueId: string,
301
+ props: QueueProps
302
+ ): Promise<CloudflareQueueResponse> {
303
+ // Prepare the update payload - only include settings
304
+ const updatePayload: any = {};
305
+
306
+ // Add settings if provided
307
+ if (props.settings) {
308
+ updatePayload.settings = {};
309
+
310
+ if (props.settings.deliveryDelay !== undefined) {
311
+ updatePayload.settings.delivery_delay = props.settings.deliveryDelay;
312
+ }
313
+
314
+ if (props.settings.deliveryPaused !== undefined) {
315
+ updatePayload.settings.delivery_paused = props.settings.deliveryPaused;
316
+ }
317
+
318
+ if (props.settings.messageRetentionPeriod !== undefined) {
319
+ updatePayload.settings.message_retention_period =
320
+ props.settings.messageRetentionPeriod;
321
+ }
322
+ }
323
+
324
+ // Use PATCH for partial updates (only settings can be updated)
325
+ const updateResponse = await api.patch(
326
+ `/accounts/${api.accountId}/queues/${queueId}`,
327
+ updatePayload
328
+ );
329
+
330
+ if (!updateResponse.ok) {
331
+ return await handleApiError(updateResponse, "updating", "Queue", queueId);
332
+ }
333
+
334
+ return (await updateResponse.json()) as CloudflareQueueResponse;
335
+ }
336
+
337
+ /**
338
+ * List all Cloudflare Queues in an account
339
+ */
340
+ export async function listQueues(
341
+ api: CloudflareApi
342
+ ): Promise<{ name: string; id: string }[]> {
343
+ const response = await api.get(`/accounts/${api.accountId}/queues`);
344
+
345
+ if (!response.ok) {
346
+ throw new CloudflareApiError(
347
+ `Failed to list queues: ${response.statusText}`,
348
+ response
349
+ );
350
+ }
351
+
352
+ const data = (await response.json()) as {
353
+ success: boolean;
354
+ errors?: Array<{ code: number; message: string }>;
355
+ result?: Array<{
356
+ queue_name: string;
357
+ queue_id: string;
358
+ }>;
359
+ };
360
+
361
+ if (!data.success) {
362
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
363
+ throw new Error(`Failed to list queues: ${errorMessage}`);
364
+ }
365
+
366
+ // Transform API response
367
+ return (data.result || []).map((queue) => ({
368
+ name: queue.queue_name,
369
+ id: queue.queue_id,
370
+ }));
371
+ }
@@ -3,27 +3,21 @@ import * as fs from "fs/promises";
3
3
  import type { Context } from "../context";
4
4
  import { Bundle, type BundleProps } from "../esbuild/bundle";
5
5
  import { Resource } from "../resource";
6
- import { isSecret } from "../secret";
7
6
  import { getContentType } from "../util/content-type";
8
7
  import { withExponentialBackoff } from "../util/retry";
9
8
  import { slugify } from "../util/slugify";
10
9
  import {
10
+ createCloudflareApi,
11
11
  type CloudflareApi,
12
12
  type CloudflareApiOptions,
13
- createCloudflareApi,
14
13
  } from "./api";
15
- import type { Assets } from "./assets";
16
- import {
17
- type Bindings,
18
- type WorkerBindingSpec,
19
- isAssets,
20
- isDurableObjectNamespace,
21
- } from "./bindings";
14
+ import { type Assets } from "./assets";
15
+ import { type Bindings, type WorkerBindingSpec } from "./bindings";
22
16
  import type { Bound } from "./bound";
23
- import type { DurableObjectNamespace } from "./durable-object-namespace";
24
- import { isKVNamespace } from "./kv-namespace";
17
+ import { type DurableObjectNamespace } from "./durable-object-namespace";
25
18
  import type { WorkerScriptMetadata } from "./worker-metadata";
26
19
  import type { SingleStepMigration } from "./worker-migration";
20
+ import { upsertWorkflow, type Workflow } from "./workflow";
27
21
 
28
22
  /**
29
23
  * Properties for creating or updating a Worker
@@ -261,10 +255,16 @@ export const Worker = Resource(
261
255
 
262
256
  // Find any assets bindings
263
257
  const assetsBindings: { name: string; assets: Assets }[] = [];
258
+ const workflowsBindings: Workflow[] = [];
259
+
264
260
  if (props.bindings) {
265
261
  for (const [bindingName, binding] of Object.entries(props.bindings)) {
266
- if (isAssets(binding)) {
267
- assetsBindings.push({ name: bindingName, assets: binding });
262
+ if (typeof binding === "object") {
263
+ if (binding.type === "assets") {
264
+ assetsBindings.push({ name: bindingName, assets: binding });
265
+ } else if (binding.type === "workflow") {
266
+ workflowsBindings.push(binding);
267
+ }
268
268
  }
269
269
  }
270
270
  }
@@ -292,9 +292,16 @@ export const Worker = Resource(
292
292
  assetUploadResult
293
293
  );
294
294
 
295
- // Upload the worker script
296
295
  await putWorker(api, workerName, scriptContent, scriptMetadata);
297
296
 
297
+ for (const workflow of workflowsBindings) {
298
+ await upsertWorkflow(api, {
299
+ workflowName: workflow.workflowName,
300
+ className: workflow.className,
301
+ scriptName: workerName,
302
+ });
303
+ }
304
+
298
305
  // TODO: it is less than ideal that this can fail, resulting in state problem
299
306
  await this.set("bindings", props.bindings);
300
307
 
@@ -311,8 +318,10 @@ export const Worker = Resource(
311
318
 
312
319
  // Construct the output
313
320
  return this({
321
+ ...props,
314
322
  type: "service",
315
323
  id,
324
+ entrypoint: props.entrypoint,
316
325
  name: workerName,
317
326
  script: scriptContent,
318
327
  format: props.format || "esm", // Include format in the output
@@ -513,17 +522,23 @@ async function prepareWorkerMetadata<B extends Bindings>(
513
522
  for (const [bindingName, binding] of Object.entries(bindings)) {
514
523
  // Create a copy of the binding to avoid modifying the original
515
524
 
516
- if (isKVNamespace(binding)) {
525
+ if (typeof binding === "string") {
517
526
  meta.bindings.push({
518
- type: "kv_namespace",
527
+ type: "plain_text",
519
528
  name: bindingName,
520
- namespace_id: binding.namespaceId,
529
+ text: binding,
521
530
  });
522
- } else if (typeof binding === "string") {
531
+ } else if (binding.type === "d1") {
523
532
  meta.bindings.push({
524
- type: "plain_text",
533
+ type: "d1",
525
534
  name: bindingName,
526
- text: binding,
535
+ id: binding.id,
536
+ });
537
+ } else if (binding.type === "kv_namespace") {
538
+ meta.bindings.push({
539
+ type: "kv_namespace",
540
+ name: bindingName,
541
+ namespace_id: binding.namespaceId,
527
542
  });
528
543
  } else if (binding.type === "service") {
529
544
  meta.bindings.push({
@@ -532,60 +547,85 @@ async function prepareWorkerMetadata<B extends Bindings>(
532
547
  service: binding.id,
533
548
  });
534
549
  } else if (binding.type === "durable_object_namespace") {
535
- const stableId = binding.id;
536
- const className = binding.className;
537
-
538
550
  meta.bindings.push({
539
551
  type: "durable_object_namespace",
540
552
  name: bindingName,
541
- class_name: className,
553
+ class_name: binding.className,
542
554
  script_name: binding.scriptName,
543
555
  environment: binding.environment,
544
556
  namespace_id: binding.namespaceId,
545
557
  });
546
-
547
- const oldBinding: DurableObjectNamespace | undefined = Object.values(
548
- oldBindings ?? {}
549
- )
550
- ?.filter(isDurableObjectNamespace)
551
- ?.find((b) => b.id === stableId);
552
-
553
- if (!oldBinding) {
554
- if (binding.sqlite) {
555
- meta.migrations!.new_sqlite_classes!.push(className);
556
- } else {
557
- meta.migrations!.new_classes!.push(className);
558
- }
559
- } else if (oldBinding.className !== className) {
560
- meta.migrations!.renamed_classes!.push({
561
- from: oldBinding.className,
562
- to: className,
563
- });
564
- }
558
+ configureClassMigration(binding, binding.id, binding.className);
565
559
  } else if (binding.type === "r2_bucket") {
566
560
  meta.bindings.push({
567
561
  type: "r2_bucket",
568
562
  name: bindingName,
569
563
  bucket_name: binding.name,
570
564
  });
571
- } else if (isAssets(binding)) {
565
+ } else if (binding.type === "assets") {
572
566
  meta.bindings.push({
573
567
  type: "assets",
574
568
  name: bindingName,
575
569
  });
576
- } else if (isSecret(binding)) {
570
+ } else if (binding.type === "secret") {
577
571
  meta.bindings.push({
578
572
  type: "secret_text",
579
573
  name: bindingName,
580
574
  text: binding.unencrypted,
581
575
  });
576
+ } else if (binding.type === "workflow") {
577
+ meta.bindings.push({
578
+ type: "workflow",
579
+ name: bindingName,
580
+ workflow_name: binding.workflowName,
581
+ class_name: binding.className,
582
+ // this should be set if the Workflow is in another script ...
583
+ // script_name: ??,
584
+ });
585
+ // it's unclear whether this is needed, but it works both ways
586
+ configureClassMigration(binding, binding.id, binding.className);
587
+ } else if (binding.type === "queue") {
588
+ meta.bindings.push({
589
+ type: "queue",
590
+ name: bindingName,
591
+ queue_name: binding.name,
592
+ });
582
593
  } else {
583
594
  // @ts-expect-error - we should never reach here
584
595
  throw new Error(`Unsupported binding type: ${binding.type}`);
585
596
  }
586
597
  }
587
598
 
599
+ function configureClassMigration(
600
+ binding: DurableObjectNamespace | Workflow,
601
+ stableId: string,
602
+ className: string
603
+ ) {
604
+ const oldBinding: DurableObjectNamespace | Workflow | undefined =
605
+ Object.values(oldBindings ?? {})
606
+ ?.filter(
607
+ (b) =>
608
+ typeof b === "object" &&
609
+ (b.type === "durable_object_namespace" || b.type === "workflow")
610
+ )
611
+ ?.find((b) => b.id === stableId);
612
+
613
+ if (!oldBinding) {
614
+ if (binding.type === "durable_object_namespace" && binding.sqlite) {
615
+ meta.migrations!.new_sqlite_classes!.push(className);
616
+ } else {
617
+ meta.migrations!.new_classes!.push(className);
618
+ }
619
+ } else if (oldBinding.className !== className) {
620
+ meta.migrations!.renamed_classes!.push({
621
+ from: oldBinding.className,
622
+ to: className,
623
+ });
624
+ }
625
+ }
626
+
588
627
  // Convert env variables to plain_text bindings
628
+ // TODO(sam): remove Worker.env in favor of always bindings
589
629
  if (props.env) {
590
630
  for (const [key, value] of Object.entries(props.env)) {
591
631
  meta.bindings.push({
@@ -0,0 +1,79 @@
1
+ import type { CloudflareApi } from "./api";
2
+ import { handleApiError } from "./api-error";
3
+
4
+ export interface WorkflowProps {
5
+ /**
6
+ * Name of the workflow
7
+ *
8
+ * @maxLength 64
9
+ * @minLength 1
10
+ * @default - className if provided, otherwise id
11
+ */
12
+ workflowName?: string;
13
+ /**
14
+ * Name of the class that implements the workflow
15
+ *
16
+ * @maxLength 255
17
+ * @minLength 1
18
+ * @default - workflowName if provided, otherwise id
19
+ */
20
+ className?: string;
21
+ }
22
+
23
+ export class Workflow<PARAMS = unknown> {
24
+ public readonly type: "workflow" = "workflow";
25
+ /**
26
+ * Phantom property to preserve workflow params at the type level.
27
+ *
28
+ * No value exists.
29
+ */
30
+ public readonly _PARAMS: PARAMS = undefined!;
31
+
32
+ public readonly workflowName: string;
33
+ public readonly className: string;
34
+
35
+ constructor(
36
+ public readonly id: string,
37
+ props: WorkflowProps = {}
38
+ ) {
39
+ this.workflowName = props.workflowName ?? props.className ?? id;
40
+ this.className = props.className ?? this.workflowName;
41
+ }
42
+ }
43
+
44
+ export interface WorkflowMetadata {
45
+ id: string; // uuid
46
+ class_name: string;
47
+ created_on: string; // date-time
48
+ modified_on: string; // date-time
49
+ name: string; // maxLength: 64, minLength: 1
50
+ script_name: string;
51
+ triggered_on: string; // date-time
52
+ version_id: string; // uuid
53
+ }
54
+
55
+ export async function upsertWorkflow(
56
+ api: CloudflareApi,
57
+ props: WorkflowProps & {
58
+ workflowName: string;
59
+ scriptName: string;
60
+ }
61
+ ) {
62
+ const response = await api.put(
63
+ `/accounts/${api.accountId}/workflows/${props.workflowName}`,
64
+ {
65
+ class_name: props.className,
66
+ script_name: props.scriptName,
67
+ }
68
+ );
69
+
70
+ if (!response.ok) {
71
+ await handleApiError(response, "create", "workflow", props.workflowName);
72
+ }
73
+
74
+ const body = (await response.json()) as {
75
+ result: WorkflowMetadata;
76
+ };
77
+
78
+ return body.result;
79
+ }