alchemy 0.87.0 → 0.90.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.
Files changed (79) hide show
  1. package/bin/alchemy.js +485 -543
  2. package/lib/cloudflare/compatibility-date.d.ts +1 -1
  3. package/lib/cloudflare/compatibility-date.d.ts.map +1 -1
  4. package/lib/cloudflare/dns-records.d.ts +6 -0
  5. package/lib/cloudflare/dns-records.d.ts.map +1 -1
  6. package/lib/cloudflare/dns-records.js +14 -12
  7. package/lib/cloudflare/dns-records.js.map +1 -1
  8. package/lib/cloudflare/index.d.ts +1 -0
  9. package/lib/cloudflare/index.d.ts.map +1 -1
  10. package/lib/cloudflare/index.js +1 -0
  11. package/lib/cloudflare/index.js.map +1 -1
  12. package/lib/cloudflare/r2-bucket-notification.d.ts +312 -0
  13. package/lib/cloudflare/r2-bucket-notification.d.ts.map +1 -0
  14. package/lib/cloudflare/r2-bucket-notification.js +387 -0
  15. package/lib/cloudflare/r2-bucket-notification.js.map +1 -0
  16. package/lib/cloudflare/worker.d.ts.map +1 -1
  17. package/lib/cloudflare/worker.js +8 -1
  18. package/lib/cloudflare/worker.js.map +1 -1
  19. package/lib/cloudflare/workflow.d.ts +17 -0
  20. package/lib/cloudflare/workflow.d.ts.map +1 -1
  21. package/lib/cloudflare/workflow.js +1 -0
  22. package/lib/cloudflare/workflow.js.map +1 -1
  23. package/lib/cloudflare/wrangler.json.js +1 -0
  24. package/lib/cloudflare/wrangler.json.js.map +1 -1
  25. package/lib/docker/api.d.ts +40 -0
  26. package/lib/docker/api.d.ts.map +1 -1
  27. package/lib/docker/api.js +30 -0
  28. package/lib/docker/api.js.map +1 -1
  29. package/lib/docker/network.d.ts.map +1 -1
  30. package/lib/docker/network.js +24 -7
  31. package/lib/docker/network.js.map +1 -1
  32. package/lib/encrypt.d.ts.map +1 -1
  33. package/lib/encrypt.js +2 -2
  34. package/lib/encrypt.js.map +1 -1
  35. package/lib/planetscale/api.d.ts +1 -0
  36. package/lib/planetscale/api.d.ts.map +1 -1
  37. package/lib/planetscale/api.js +1 -1
  38. package/lib/planetscale/api.js.map +1 -1
  39. package/lib/planetscale/branch.d.ts +28 -0
  40. package/lib/planetscale/branch.d.ts.map +1 -1
  41. package/lib/planetscale/branch.js +12 -0
  42. package/lib/planetscale/branch.js.map +1 -1
  43. package/lib/planetscale/database-extensions.d.ts +515 -0
  44. package/lib/planetscale/database-extensions.d.ts.map +1 -0
  45. package/lib/planetscale/database-extensions.js +389 -0
  46. package/lib/planetscale/database-extensions.js.map +1 -0
  47. package/lib/planetscale/database.d.ts +40 -2
  48. package/lib/planetscale/database.d.ts.map +1 -1
  49. package/lib/planetscale/database.js +76 -1
  50. package/lib/planetscale/database.js.map +1 -1
  51. package/lib/scope.d.ts.map +1 -1
  52. package/lib/scope.js +1 -2
  53. package/lib/scope.js.map +1 -1
  54. package/lib/util/telemetry.d.ts +1 -6
  55. package/lib/util/telemetry.d.ts.map +1 -1
  56. package/lib/util/telemetry.js.map +1 -1
  57. package/package.json +6 -6
  58. package/src/cloudflare/dns-records.ts +25 -16
  59. package/src/cloudflare/index.ts +1 -0
  60. package/src/cloudflare/r2-bucket-notification.ts +809 -0
  61. package/src/cloudflare/worker.ts +8 -1
  62. package/src/cloudflare/workflow.ts +18 -0
  63. package/src/cloudflare/wrangler.json.ts +1 -0
  64. package/src/docker/api.ts +61 -0
  65. package/src/docker/network.ts +27 -7
  66. package/src/encrypt.ts +2 -0
  67. package/src/planetscale/api.ts +1 -1
  68. package/src/planetscale/branch.ts +45 -0
  69. package/src/planetscale/database-extensions.ts +947 -0
  70. package/src/planetscale/database.ts +141 -2
  71. package/src/scope.ts +1 -2
  72. package/src/util/telemetry.ts +1 -20
  73. package/workers/cloudflare-state-store.js +57 -57
  74. package/workers/tunnel-proxy.js +1 -1
  75. package/lib/state/instrumented-state-store.d.ts +0 -19
  76. package/lib/state/instrumented-state-store.d.ts.map +0 -1
  77. package/lib/state/instrumented-state-store.js +0 -68
  78. package/lib/state/instrumented-state-store.js.map +0 -1
  79. package/src/state/instrumented-state-store.ts +0 -108
@@ -0,0 +1,809 @@
1
+ import type { Context } from "../context.ts";
2
+ import { Resource, ResourceKind } from "../resource.ts";
3
+ import { withExponentialBackoff } from "../util/retry.ts";
4
+ import { CloudflareApiError, handleApiError } from "./api-error.ts";
5
+ import {
6
+ createCloudflareApi,
7
+ type CloudflareApi,
8
+ type CloudflareApiOptions,
9
+ } from "./api.ts";
10
+ import { withJurisdiction, type R2Bucket } from "./bucket.ts";
11
+ import { findQueueByName, type Queue } from "./queue.ts";
12
+
13
+ /**
14
+ * Event types that can trigger R2 bucket notifications
15
+ */
16
+ export type R2BucketNotificationEventType = "object-create" | "object-delete";
17
+
18
+ /**
19
+ * Message payload sent to a Queue when an R2 bucket notification is triggered
20
+ *
21
+ * @see https://developers.cloudflare.com/r2/buckets/event-notifications/#message-format
22
+ */
23
+ export interface R2BucketNotificationMessage {
24
+ /**
25
+ * The Cloudflare account ID that the event is associated with
26
+ */
27
+ account: string;
28
+
29
+ /**
30
+ * The type of action that triggered the event notification
31
+ * Example actions include: PutObject, CopyObject, CompleteMultipartUpload, DeleteObject
32
+ */
33
+ action: string;
34
+
35
+ /**
36
+ * The name of the bucket where the event occurred
37
+ */
38
+ bucket: string;
39
+
40
+ /**
41
+ * Details about the object involved in the event
42
+ */
43
+ object: {
44
+ /**
45
+ * The key (or name) of the object within the bucket
46
+ */
47
+ key: string;
48
+
49
+ /**
50
+ * The size of the object in bytes (not present for object-delete events)
51
+ */
52
+ size?: number;
53
+
54
+ /**
55
+ * The entity tag (eTag) of the object (not present for object-delete events)
56
+ */
57
+ eTag?: string;
58
+ };
59
+
60
+ /**
61
+ * The time when the action that triggered the event occurred (ISO 8601 format)
62
+ */
63
+ eventTime: string;
64
+
65
+ /**
66
+ * Details about the source of a copied object (only present for CopyObject events)
67
+ */
68
+ copySource?: {
69
+ /**
70
+ * The bucket that contained the source object
71
+ */
72
+ bucket: string;
73
+
74
+ /**
75
+ * The name of the source object
76
+ */
77
+ object: string;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Base properties shared by all R2 Bucket Notification configurations
83
+ */
84
+ interface R2BucketNotificationBaseProps extends CloudflareApiOptions {
85
+ /**
86
+ * The R2 bucket to attach the notification rule to.
87
+ * Can be either a bucket name (string) or an R2Bucket resource.
88
+ */
89
+ bucket: string | R2Bucket;
90
+
91
+ /**
92
+ * The queue that will receive notification messages.
93
+ * Can be either a queue name (string) or a Queue resource.
94
+ */
95
+ queue: string | Queue;
96
+
97
+ /**
98
+ * The type of events that will trigger notifications.
99
+ * - "object-create": Triggered when objects are created or overwritten (PutObject, CompleteMultipartUpload)
100
+ * - "object-delete": Triggered when objects are deleted (DeleteObject, CopyObject, LifecycleDeletion)
101
+ */
102
+ eventTypes: R2BucketNotificationEventType[];
103
+
104
+ /**
105
+ * Optional description for the notification rule to help identify it.
106
+ */
107
+ description?: string;
108
+
109
+ /**
110
+ * The jurisdiction where the bucket exists.
111
+ * Required for EU or FedRAMP jurisdictions.
112
+ */
113
+ jurisdiction?: "default" | "eu" | "fedramp";
114
+
115
+ /**
116
+ * Whether to delete the notification rule when the resource is removed from Alchemy.
117
+ * @default true
118
+ */
119
+ delete?: boolean;
120
+
121
+ /**
122
+ * Whether to adopt an existing notification rule if one already exists with the same configuration.
123
+ * @default false
124
+ */
125
+ adopt?: boolean;
126
+ }
127
+
128
+ /**
129
+ * Props with single prefix and suffix (creates one rule)
130
+ */
131
+ interface R2BucketNotificationSingleProps extends R2BucketNotificationBaseProps {
132
+ /**
133
+ * Optional prefix filter - only objects with keys starting with this prefix will trigger notifications.
134
+ */
135
+ prefix?: string;
136
+
137
+ /**
138
+ * Optional suffix filter - only objects with keys ending with this suffix will trigger notifications.
139
+ */
140
+ suffix?: string;
141
+ }
142
+
143
+ /**
144
+ * Props with multiple prefixes (creates one rule per prefix)
145
+ */
146
+ interface R2BucketNotificationMultiplePrefixProps extends R2BucketNotificationBaseProps {
147
+ /**
148
+ * Array of prefix filters - creates one notification rule per prefix.
149
+ */
150
+ prefix: string[];
151
+
152
+ /**
153
+ * Optional suffix filter applied to all rules.
154
+ */
155
+ suffix?: string;
156
+ }
157
+
158
+ /**
159
+ * Props with multiple suffixes (creates one rule per suffix)
160
+ */
161
+ interface R2BucketNotificationMultipleSuffixProps extends R2BucketNotificationBaseProps {
162
+ /**
163
+ * Optional prefix filter applied to all rules.
164
+ */
165
+ prefix?: string;
166
+
167
+ /**
168
+ * Array of suffix filters - creates one notification rule per suffix.
169
+ */
170
+ suffix: string[];
171
+ }
172
+
173
+ /**
174
+ * Properties for creating or updating R2 Bucket Notification rules.
175
+ *
176
+ * Either prefix OR suffix can be an array (creating multiple rules), but not both.
177
+ */
178
+ export type R2BucketNotificationProps =
179
+ | R2BucketNotificationSingleProps
180
+ | R2BucketNotificationMultiplePrefixProps
181
+ | R2BucketNotificationMultipleSuffixProps;
182
+
183
+ /**
184
+ * Output returned after R2 Bucket Notification rule creation/update
185
+ */
186
+ export type R2BucketNotification = Omit<
187
+ R2BucketNotificationBaseProps,
188
+ "bucket" | "queue" | "delete" | "adopt"
189
+ > & {
190
+ /**
191
+ * Resource type identifier
192
+ */
193
+ type: "r2_bucket_notification";
194
+
195
+ /**
196
+ * The Cloudflare-assigned rule ID(s) for this notification.
197
+ * When prefix or suffix is an array, this contains multiple rule IDs.
198
+ */
199
+ ruleId: string | string[];
200
+
201
+ /**
202
+ * The name of the bucket this notification is attached to
203
+ */
204
+ bucketName: string;
205
+
206
+ /**
207
+ * The name of the queue that receives notification messages
208
+ */
209
+ queueName: string;
210
+
211
+ /**
212
+ * Prefix filter(s) - normalized to always be a string or array matching input
213
+ */
214
+ prefix?: string | string[];
215
+
216
+ /**
217
+ * Suffix filter(s) - normalized to always be a string or array matching input
218
+ */
219
+ suffix?: string | string[];
220
+
221
+ /**
222
+ * Time when the notification rule was created
223
+ */
224
+ createdAt?: string;
225
+ };
226
+
227
+ /**
228
+ * Type guard to check if a resource is an R2BucketNotification
229
+ */
230
+ export function isR2BucketNotification(
231
+ resource: any,
232
+ ): resource is R2BucketNotification {
233
+ return resource?.[ResourceKind] === "cloudflare::R2BucketNotification";
234
+ }
235
+
236
+ async function resolveQueueId(
237
+ api: CloudflareApi,
238
+ queue: string | Queue,
239
+ ): Promise<string> {
240
+ if (typeof queue !== "string" && queue.id) {
241
+ return queue.id;
242
+ }
243
+
244
+ const queueName = typeof queue === "string" ? queue : queue.name;
245
+
246
+ return await withExponentialBackoff(
247
+ async () => {
248
+ const queueId = (await findQueueByName(api, queueName))?.result?.queue_id;
249
+ if (!queueId) {
250
+ throw new Error(`Queue "${queueName}" not found`);
251
+ }
252
+ return queueId;
253
+ },
254
+ (error) => error instanceof Error && error.message.includes("not found"),
255
+ 5,
256
+ 1000,
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Creates an event notification rule for an R2 bucket that sends messages to a Queue.
262
+ *
263
+ * Event notifications allow you to trigger automated workflows when objects are created,
264
+ * modified, or deleted in your R2 bucket. Messages are delivered to a Cloudflare Queue
265
+ * where they can be processed by a consumer Worker.
266
+ *
267
+ * @example
268
+ * ## Basic object-create notification
269
+ *
270
+ * Send notifications to a queue when objects are created in a bucket:
271
+ *
272
+ * ```ts
273
+ * import { R2Bucket, Queue, R2BucketNotification } from "alchemy/cloudflare";
274
+ *
275
+ * const bucket = await R2Bucket("uploads");
276
+ * const queue = await Queue("upload-events");
277
+ *
278
+ * await R2BucketNotification("upload-notifications", {
279
+ * bucket,
280
+ * queue,
281
+ * eventTypes: ["object-create"],
282
+ * });
283
+ * ```
284
+ *
285
+ * @example
286
+ * ## Filtered notifications with prefix and suffix
287
+ *
288
+ * Only trigger notifications for specific object patterns:
289
+ *
290
+ * ```ts
291
+ * import { R2Bucket, Queue, R2BucketNotification } from "alchemy/cloudflare";
292
+ *
293
+ * const bucket = await R2Bucket("documents");
294
+ * const queue = await Queue("pdf-processing");
295
+ *
296
+ * await R2BucketNotification("pdf-uploads", {
297
+ * bucket,
298
+ * queue,
299
+ * eventTypes: ["object-create"],
300
+ * prefix: "incoming/",
301
+ * suffix: ".pdf",
302
+ * description: "Process newly uploaded PDF files",
303
+ * });
304
+ * ```
305
+ *
306
+ * @example
307
+ * ## Multiple suffixes for different file types
308
+ *
309
+ * Create rules for multiple file extensions at once:
310
+ *
311
+ * ```ts
312
+ * import { R2Bucket, Queue, R2BucketNotification } from "alchemy/cloudflare";
313
+ *
314
+ * const bucket = await R2Bucket("media");
315
+ * const queue = await Queue("audio-processing");
316
+ *
317
+ * await R2BucketNotification("audio-uploads", {
318
+ * bucket,
319
+ * queue,
320
+ * eventTypes: ["object-create"],
321
+ * prefix: "audio/",
322
+ * suffix: [".mp3", ".wav", ".flac"],
323
+ * });
324
+ * ```
325
+ *
326
+ * @example
327
+ * ## Multiple event types with typed queue
328
+ *
329
+ * Listen for both create and delete events with type-safe message handling:
330
+ *
331
+ * ```ts
332
+ * import { R2Bucket, Queue, R2BucketNotification, R2BucketNotificationMessage } from "alchemy/cloudflare";
333
+ *
334
+ * const bucket = await R2Bucket("assets");
335
+ * const queue = await Queue<R2BucketNotificationMessage>("asset-events");
336
+ *
337
+ * await R2BucketNotification("asset-notifications", {
338
+ * bucket,
339
+ * queue,
340
+ * eventTypes: ["object-create", "object-delete"],
341
+ * });
342
+ * ```
343
+ *
344
+ * @example
345
+ * ## Process notifications with a Worker
346
+ *
347
+ * Complete example showing bucket notifications processed by a Worker:
348
+ *
349
+ * ```ts
350
+ * import { R2Bucket, Queue, R2BucketNotification, Worker, R2BucketNotificationMessage } from "alchemy/cloudflare";
351
+ *
352
+ * const bucket = await R2Bucket("uploads");
353
+ * const queue = await Queue<R2BucketNotificationMessage>("upload-events");
354
+ *
355
+ * await R2BucketNotification("upload-notifications", {
356
+ * bucket,
357
+ * queue,
358
+ * eventTypes: ["object-create"],
359
+ * });
360
+ *
361
+ * await Worker("processor", {
362
+ * entrypoint: "./src/processor.ts",
363
+ * eventSources: [queue],
364
+ * });
365
+ * ```
366
+ *
367
+ * @see https://developers.cloudflare.com/r2/buckets/event-notifications/
368
+ */
369
+ export const R2BucketNotification = Resource(
370
+ "cloudflare::R2BucketNotification",
371
+ async function (
372
+ this: Context<R2BucketNotification>,
373
+ _id: string,
374
+ props: R2BucketNotificationProps,
375
+ ): Promise<R2BucketNotification> {
376
+ const prefixIsArray = Array.isArray(props.prefix);
377
+ const suffixIsArray = Array.isArray(props.suffix);
378
+
379
+ if (prefixIsArray && suffixIsArray) {
380
+ throw new Error(
381
+ "Cannot specify both prefix and suffix as arrays. Only one can be an array at a time.",
382
+ );
383
+ }
384
+
385
+ const bucketName =
386
+ typeof props.bucket === "string" ? props.bucket : props.bucket.name;
387
+ const queueName =
388
+ typeof props.queue === "string" ? props.queue : props.queue.name;
389
+ const jurisdiction =
390
+ typeof props.bucket === "string"
391
+ ? props.jurisdiction
392
+ : (props.bucket.jurisdiction ?? props.jurisdiction);
393
+ const adopt = props.adopt ?? this.scope.adopt;
394
+
395
+ const api = await createCloudflareApi(props);
396
+
397
+ const queueId = await resolveQueueId(api, props.queue);
398
+
399
+ const ruleConfigs: Array<{ prefix?: string; suffix?: string }> = [];
400
+
401
+ if (prefixIsArray) {
402
+ const prefixArray = props.prefix as string[];
403
+ const singleSuffix =
404
+ typeof props.suffix === "string" ? props.suffix : undefined;
405
+ for (const prefix of prefixArray) {
406
+ ruleConfigs.push({ prefix, suffix: singleSuffix });
407
+ }
408
+ } else if (suffixIsArray) {
409
+ const suffixArray = props.suffix as string[];
410
+ const singlePrefix =
411
+ typeof props.prefix === "string" ? props.prefix : undefined;
412
+ for (const suffix of suffixArray) {
413
+ ruleConfigs.push({ prefix: singlePrefix, suffix });
414
+ }
415
+ } else {
416
+ const singlePrefix =
417
+ typeof props.prefix === "string" ? props.prefix : undefined;
418
+ const singleSuffix =
419
+ typeof props.suffix === "string" ? props.suffix : undefined;
420
+ ruleConfigs.push({ prefix: singlePrefix, suffix: singleSuffix });
421
+ }
422
+
423
+ if (this.scope.local) {
424
+ const mockRuleIds = ruleConfigs.map((_, i) =>
425
+ Array.isArray(this.output?.ruleId)
426
+ ? (this.output.ruleId[i] ?? "")
427
+ : (this.output?.ruleId ?? ""),
428
+ );
429
+ return {
430
+ type: "r2_bucket_notification",
431
+ ruleId: ruleConfigs.length === 1 ? mockRuleIds[0] : mockRuleIds,
432
+ bucketName,
433
+ queueName,
434
+ eventTypes: props.eventTypes,
435
+ prefix: props.prefix,
436
+ suffix: props.suffix,
437
+ description: props.description,
438
+ jurisdiction,
439
+ accountId: this.output?.accountId ?? "",
440
+ };
441
+ }
442
+
443
+ if (this.phase === "delete") {
444
+ if (props.delete !== false && this.output?.ruleId) {
445
+ const ruleIds = Array.isArray(this.output.ruleId)
446
+ ? this.output.ruleId
447
+ : [this.output.ruleId];
448
+ await Promise.all(
449
+ ruleIds.map((ruleId) =>
450
+ deleteNotificationRule(api, bucketName, queueId, ruleId, {
451
+ jurisdiction,
452
+ }),
453
+ ),
454
+ );
455
+ }
456
+ return this.destroy();
457
+ }
458
+
459
+ const existingRuleIds: string[] = Array.isArray(this.output?.ruleId)
460
+ ? this.output.ruleId
461
+ : this.output?.ruleId
462
+ ? [this.output.ruleId]
463
+ : [];
464
+
465
+ if (this.phase === "update" && existingRuleIds.length > 0) {
466
+ const existingRules = await listNotificationRules(api, bucketName, {
467
+ jurisdiction,
468
+ });
469
+
470
+ for (const existingRuleId of existingRuleIds) {
471
+ const existingRule = existingRules.find(
472
+ (r) => r.ruleId === existingRuleId,
473
+ );
474
+ if (existingRule) {
475
+ await deleteNotificationRule(
476
+ api,
477
+ bucketName,
478
+ existingRule.queueId,
479
+ existingRuleId,
480
+ { jurisdiction },
481
+ );
482
+ }
483
+ }
484
+ }
485
+
486
+ const createdRuleIds: string[] = [];
487
+
488
+ for (const config of ruleConfigs) {
489
+ try {
490
+ const result = await createNotificationRule(api, bucketName, {
491
+ queueId,
492
+ queueName,
493
+ eventTypes: props.eventTypes,
494
+ prefix: config.prefix,
495
+ suffix: config.suffix,
496
+ description: props.description,
497
+ jurisdiction,
498
+ });
499
+ createdRuleIds.push(result.ruleId);
500
+ } catch (err) {
501
+ const isConflictError =
502
+ err instanceof CloudflareApiError &&
503
+ (err.status === 409 ||
504
+ (err.status === 400 &&
505
+ err.errorData?.some(
506
+ (e: { code: number; message: string }) => e.code === 11020,
507
+ )));
508
+
509
+ if (isConflictError && adopt) {
510
+ const matchingRule = await withExponentialBackoff(
511
+ async () => {
512
+ const existingRules = await listNotificationRules(
513
+ api,
514
+ bucketName,
515
+ {
516
+ jurisdiction,
517
+ },
518
+ );
519
+ const match = existingRules.find((r) =>
520
+ matchesNotificationRule(r, {
521
+ queueId,
522
+ queueName,
523
+ eventTypes: props.eventTypes,
524
+ prefix: config.prefix,
525
+ suffix: config.suffix,
526
+ }),
527
+ );
528
+ if (!match) {
529
+ throw new Error(
530
+ `Failed to find existing notification rule for bucket ${bucketName} and queue ${queueName}`,
531
+ );
532
+ }
533
+ return match;
534
+ },
535
+ (error) =>
536
+ error instanceof Error &&
537
+ error.message.includes(
538
+ "Failed to find existing notification rule",
539
+ ),
540
+ 5,
541
+ 1000,
542
+ );
543
+ if (matchingRule) {
544
+ createdRuleIds.push(matchingRule.ruleId);
545
+ } else {
546
+ throw err;
547
+ }
548
+ } else {
549
+ throw err;
550
+ }
551
+ }
552
+ }
553
+
554
+ return {
555
+ type: "r2_bucket_notification",
556
+ ruleId: createdRuleIds.length === 1 ? createdRuleIds[0] : createdRuleIds,
557
+ bucketName,
558
+ queueName,
559
+ eventTypes: props.eventTypes,
560
+ prefix: props.prefix,
561
+ suffix: props.suffix,
562
+ description: props.description,
563
+ jurisdiction,
564
+ accountId: api.accountId,
565
+ };
566
+ },
567
+ );
568
+
569
+ interface NotificationRuleInfo {
570
+ ruleId: string;
571
+ queueId: string;
572
+ queueName: string;
573
+ actions: string[];
574
+ prefix: string;
575
+ suffix: string;
576
+ createdAt?: string;
577
+ }
578
+
579
+ interface CreateNotificationRuleOptions {
580
+ queueId: string;
581
+ queueName: string;
582
+ eventTypes: R2BucketNotificationEventType[];
583
+ prefix?: string;
584
+ suffix?: string;
585
+ description?: string;
586
+ jurisdiction?: string;
587
+ }
588
+
589
+ function mapEventTypesToActions(
590
+ eventTypes: R2BucketNotificationEventType[],
591
+ ): string[] {
592
+ const actions: string[] = [];
593
+ for (const eventType of eventTypes) {
594
+ if (eventType === "object-create") {
595
+ actions.push("PutObject", "CompleteMultipartUpload", "CopyObject");
596
+ } else if (eventType === "object-delete") {
597
+ actions.push("DeleteObject", "LifecycleDeletion");
598
+ }
599
+ }
600
+ return [...new Set(actions)].sort();
601
+ }
602
+
603
+ function arraysEqual(a: string[], b: string[]): boolean {
604
+ const sortedA = [...a].sort();
605
+ const sortedB = [...b].sort();
606
+ return (
607
+ sortedA.length === sortedB.length &&
608
+ sortedA.every((v, i) => v === sortedB[i])
609
+ );
610
+ }
611
+
612
+ function normalizeRuleFilter(value?: string): string {
613
+ return value ?? "";
614
+ }
615
+
616
+ function normalizeQueueId(queueId: string): string {
617
+ return queueId.replaceAll("-", "");
618
+ }
619
+
620
+ function matchesNotificationRule(
621
+ rule: NotificationRuleInfo,
622
+ options: {
623
+ queueId: string;
624
+ queueName?: string;
625
+ eventTypes: R2BucketNotificationEventType[];
626
+ prefix?: string;
627
+ suffix?: string;
628
+ },
629
+ ): boolean {
630
+ return (
631
+ (normalizeQueueId(rule.queueId) === normalizeQueueId(options.queueId) ||
632
+ (options.queueName !== undefined &&
633
+ rule.queueName === options.queueName)) &&
634
+ arraysEqual(rule.actions, mapEventTypesToActions(options.eventTypes)) &&
635
+ rule.prefix === normalizeRuleFilter(options.prefix) &&
636
+ rule.suffix === normalizeRuleFilter(options.suffix)
637
+ );
638
+ }
639
+
640
+ async function createNotificationRule(
641
+ api: CloudflareApi,
642
+ bucketName: string,
643
+ options: CreateNotificationRuleOptions,
644
+ ): Promise<{ ruleId: string }> {
645
+ const payload: any = {
646
+ rules: [
647
+ {
648
+ actions: mapEventTypesToActions(options.eventTypes),
649
+ prefix: options.prefix ?? "",
650
+ suffix: options.suffix ?? "",
651
+ },
652
+ ],
653
+ };
654
+
655
+ const response = await api.put(
656
+ `/accounts/${api.accountId}/event_notifications/r2/${bucketName}/configuration/queues/${options.queueId}`,
657
+ payload,
658
+ {
659
+ headers: withJurisdiction({ jurisdiction: options.jurisdiction }),
660
+ },
661
+ );
662
+
663
+ if (!response.ok) {
664
+ return await handleApiError(
665
+ response,
666
+ "creating",
667
+ "R2 Bucket Notification",
668
+ `for bucket ${bucketName}`,
669
+ );
670
+ }
671
+
672
+ const data = (await response.json()) as {
673
+ success: boolean;
674
+ result?: { event_notification_detail_id?: string };
675
+ errors?: Array<{ code: number; message: string }>;
676
+ };
677
+
678
+ if (data.result?.event_notification_detail_id) {
679
+ return { ruleId: data.result.event_notification_detail_id };
680
+ }
681
+
682
+ const createdRule = await withExponentialBackoff(
683
+ async () => {
684
+ const rules = await listNotificationRules(api, bucketName, {
685
+ jurisdiction: options.jurisdiction,
686
+ });
687
+ const match = rules.find((r) => matchesNotificationRule(r, options));
688
+ if (!match) {
689
+ throw new Error(
690
+ `Failed to find created notification rule for bucket ${bucketName} and queue ${options.queueName}`,
691
+ );
692
+ }
693
+ return match;
694
+ },
695
+ (error) =>
696
+ error instanceof Error && error.message.includes("Failed to find"),
697
+ 5,
698
+ 1000,
699
+ );
700
+
701
+ if (!createdRule) {
702
+ throw new Error(
703
+ `Failed to find created notification rule for bucket ${bucketName} and queue ${options.queueName}`,
704
+ );
705
+ }
706
+
707
+ return { ruleId: createdRule.ruleId };
708
+ }
709
+
710
+ async function listNotificationRules(
711
+ api: CloudflareApi,
712
+ bucketName: string,
713
+ options: { jurisdiction?: string } = {},
714
+ ): Promise<NotificationRuleInfo[]> {
715
+ const response = await api.get(
716
+ `/accounts/${api.accountId}/event_notifications/r2/${bucketName}/configuration`,
717
+ {
718
+ headers: withJurisdiction(options),
719
+ },
720
+ );
721
+
722
+ if (!response.ok) {
723
+ if (response.status === 404) {
724
+ return [];
725
+ }
726
+ return await handleApiError(
727
+ response,
728
+ "listing",
729
+ "R2 Bucket Notifications",
730
+ `for bucket ${bucketName}`,
731
+ );
732
+ }
733
+
734
+ const data = (await response.json()) as {
735
+ success: boolean;
736
+ result?: {
737
+ bucketName?: string;
738
+ queues?: Array<{
739
+ queueId: string;
740
+ queueName: string;
741
+ rules: Array<{
742
+ ruleId: string;
743
+ prefix: string;
744
+ suffix: string;
745
+ actions: string[];
746
+ createdAt?: string;
747
+ }>;
748
+ }>;
749
+ };
750
+ errors?: Array<{ code: number; message: string }>;
751
+ };
752
+
753
+ if (!data.success || !data.result?.queues) {
754
+ return [];
755
+ }
756
+
757
+ const rules: NotificationRuleInfo[] = [];
758
+ for (const queue of data.result.queues) {
759
+ for (const rule of queue.rules) {
760
+ rules.push({
761
+ ruleId: rule.ruleId,
762
+ queueId: queue.queueId,
763
+ queueName: queue.queueName,
764
+ actions: rule.actions,
765
+ prefix: rule.prefix,
766
+ suffix: rule.suffix,
767
+ createdAt: rule.createdAt,
768
+ });
769
+ }
770
+ }
771
+
772
+ return rules;
773
+ }
774
+
775
+ async function deleteNotificationRule(
776
+ api: CloudflareApi,
777
+ bucketName: string,
778
+ queueId: string,
779
+ ruleId: string,
780
+ options: { jurisdiction?: string } = {},
781
+ ): Promise<void> {
782
+ const response = await api.delete(
783
+ `/accounts/${api.accountId}/event_notifications/r2/${bucketName}/configuration/queues/${queueId}`,
784
+ {
785
+ headers: withJurisdiction(options),
786
+ body: JSON.stringify({ ruleIds: [ruleId] }),
787
+ },
788
+ );
789
+
790
+ if (!response.ok && response.status !== 404) {
791
+ await handleApiError(
792
+ response,
793
+ "deleting",
794
+ "R2 Bucket Notification",
795
+ `rule ${ruleId} for bucket ${bucketName}`,
796
+ );
797
+ }
798
+ }
799
+
800
+ /**
801
+ * List all notification rules for an R2 bucket
802
+ */
803
+ export async function listR2BucketNotifications(
804
+ api: CloudflareApi,
805
+ bucketName: string,
806
+ options: { jurisdiction?: string } = {},
807
+ ): Promise<NotificationRuleInfo[]> {
808
+ return listNotificationRules(api, bucketName, options);
809
+ }