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,642 @@
1
+ import type { Context } from "../context";
2
+ import { Resource } from "../resource";
3
+ import type { Secret } from "../secret";
4
+ import {
5
+ CloudflareApi,
6
+ createCloudflareApi,
7
+ type CloudflareApiOptions,
8
+ } from "./api";
9
+ import { CloudflareApiError, handleApiError } from "./api-error";
10
+
11
+ /**
12
+ * Settings for compression of pipeline output
13
+ */
14
+ export interface CompressionSettings {
15
+ /**
16
+ * Type of compression to use for pipeline output
17
+ * @default "gzip"
18
+ */
19
+ type: "gzip" | "none";
20
+ }
21
+
22
+ /**
23
+ * Settings for batching behavior of pipeline output
24
+ */
25
+ export interface BatchSettings {
26
+ /**
27
+ * Maximum size of batch in megabytes before delivery (1-100 MB)
28
+ * @default 100
29
+ */
30
+ maxMb?: number;
31
+
32
+ /**
33
+ * Maximum number of rows in a batch before delivery (1-10,000,000 rows)
34
+ * @default 10000000
35
+ */
36
+ maxRows?: number;
37
+
38
+ /**
39
+ * Maximum duration of a batch in seconds before delivery (1-300 seconds)
40
+ * @default 300
41
+ */
42
+ maxSeconds?: number;
43
+ }
44
+
45
+ /**
46
+ * Configuration for a pipeline HTTP source
47
+ */
48
+ export interface HttpSource {
49
+ /**
50
+ * Format of the source data
51
+ * @default "json"
52
+ */
53
+ format: "json";
54
+
55
+ /**
56
+ * Type of source
57
+ */
58
+ type: "http";
59
+
60
+ /**
61
+ * Whether authentication is required
62
+ * @default true
63
+ */
64
+ authentication?: boolean;
65
+
66
+ /**
67
+ * CORS configuration for HTTP endpoint source
68
+ */
69
+ cors?: {
70
+ /**
71
+ * Allowed origins for CORS requests
72
+ * @default ["*"]
73
+ */
74
+ origins: string[];
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Configuration for a pipeline binding source
80
+ */
81
+ export interface BindingSource {
82
+ /**
83
+ * Format of the source data
84
+ * @default "json"
85
+ */
86
+ format: "json";
87
+
88
+ /**
89
+ * Type of source
90
+ */
91
+ type: "binding";
92
+ }
93
+
94
+ /**
95
+ * Configuration for a pipeline source
96
+ */
97
+ export type PipelineSource = HttpSource | BindingSource;
98
+
99
+ /**
100
+ * Configuration for an R2 destination
101
+ */
102
+ export interface R2DestinationConfig {
103
+ /**
104
+ * Type of destination (R2)
105
+ */
106
+ type: "r2";
107
+
108
+ /**
109
+ * Format of the output data
110
+ * @default "json"
111
+ */
112
+ format: "json" | "ndjson";
113
+
114
+ /**
115
+ * Path configuration for the R2 destination
116
+ */
117
+ path: {
118
+ /**
119
+ * R2 bucket name
120
+ */
121
+ bucket: string;
122
+
123
+ /**
124
+ * Optional prefix for files in the bucket
125
+ */
126
+ prefix?: string;
127
+
128
+ /**
129
+ * Optional filename pattern
130
+ * @default "${slug}${extension}"
131
+ */
132
+ filename?: string;
133
+
134
+ /**
135
+ * Optional filepath pattern
136
+ * @default "${date}/${hour}"
137
+ */
138
+ filepath?: string;
139
+ };
140
+
141
+ /**
142
+ * Compression settings
143
+ */
144
+ compression?: CompressionSettings;
145
+
146
+ /**
147
+ * Batch settings
148
+ */
149
+ batch?: BatchSettings;
150
+
151
+ /**
152
+ * Credentials for the R2 bucket
153
+ * Required for R2 destinations
154
+ */
155
+ credentials: {
156
+ /**
157
+ * Access key ID for the R2 bucket
158
+ */
159
+ accessKeyId: Secret;
160
+
161
+ /**
162
+ * Secret access key for the R2 bucket
163
+ */
164
+ secretAccessKey: Secret;
165
+
166
+ /**
167
+ * Endpoint for the R2 bucket
168
+ */
169
+ endpoint?: string;
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Allowed destination types
175
+ */
176
+ export type PipelineDestination = R2DestinationConfig;
177
+
178
+ /**
179
+ * Properties for creating or updating a Pipeline
180
+ */
181
+ export interface PipelineProps extends CloudflareApiOptions {
182
+ /**
183
+ * Name of the pipeline
184
+ */
185
+ name: string;
186
+
187
+ /**
188
+ * Source configuration for the pipeline
189
+ */
190
+ source: PipelineSource[];
191
+
192
+ /**
193
+ * Destination configuration for the pipeline
194
+ */
195
+ destination: PipelineDestination;
196
+
197
+ /**
198
+ * Compression settings for the pipeline
199
+ * @default { type: "gzip" }
200
+ */
201
+ compression?: CompressionSettings;
202
+
203
+ /**
204
+ * Whether to delete the pipeline.
205
+ * If set to false, the pipeline will remain but the resource will be removed from state
206
+ *
207
+ * @default true
208
+ */
209
+ delete?: boolean;
210
+ }
211
+
212
+ /**
213
+ * Base type for pipeline records
214
+ */
215
+ export interface PipelineRecord {
216
+ [key: string]: any;
217
+ }
218
+
219
+ /**
220
+ * Output returned after Pipeline creation/update
221
+ */
222
+ export interface Pipeline<T extends PipelineRecord = PipelineRecord>
223
+ extends Resource<"cloudflare::Pipeline">,
224
+ PipelineProps {
225
+ /**
226
+ * Type identifier for the Pipeline resource
227
+ */
228
+ type: "pipeline";
229
+
230
+ /**
231
+ * The unique ID of the pipeline
232
+ */
233
+ id: string;
234
+
235
+ /**
236
+ * HTTP endpoint URL for the pipeline
237
+ */
238
+ endpoint: string;
239
+
240
+ /**
241
+ * Version of the pipeline
242
+ */
243
+ version: number;
244
+ }
245
+
246
+ /**
247
+ * Creates and manages Cloudflare Pipelines.
248
+ *
249
+ * Pipelines provide a managed data pipeline service that lets you collect, transform,
250
+ * and route data to various destinations like R2 buckets.
251
+ *
252
+ * @example
253
+ * // Create a basic pipeline with an R2 bucket destination
254
+ * const bucket = await R2Bucket("logs-bucket", {
255
+ * name: "logs-bucket"
256
+ * });
257
+ *
258
+ * const accessKey = alchemy.secret(process.env.R2_ACCESS_KEY_ID!);
259
+ * const secretKey = alchemy.secret(process.env.R2_SECRET_ACCESS_KEY!);
260
+ *
261
+ * const pipeline = await Pipeline("logs-pipeline", {
262
+ * name: "logs-pipeline",
263
+ * destination: {
264
+ * type: "r2",
265
+ * format: "json",
266
+ * path: {
267
+ * bucket: bucket.name,
268
+ * prefix: "app-logs",
269
+ * },
270
+ * credentials: {
271
+ * accessKeyId: accessKey,
272
+ * secretAccessKey: secretKey
273
+ * }
274
+ * },
275
+ * batch: {
276
+ * maxMb: 50,
277
+ * maxSeconds: 60
278
+ * }
279
+ * });
280
+ *
281
+ * @example
282
+ * // Create a pipeline with custom source configuration
283
+ * const customPipeline = await Pipeline("custom-pipeline", {
284
+ * name: "custom-pipeline",
285
+ * source: [{
286
+ * type: "http",
287
+ * format: "json",
288
+ * authentication: true,
289
+ * cors: {
290
+ * origins: ["https://example.com"]
291
+ * }
292
+ * }],
293
+ * destination: {
294
+ * type: "r2",
295
+ * format: "json",
296
+ * path: {
297
+ * bucket: "my-bucket",
298
+ * prefix: "data"
299
+ * },
300
+ * credentials: {
301
+ * accessKeyId: alchemy.secret(process.env.R2_ACCESS_KEY_ID!),
302
+ * secretAccessKey: alchemy.secret(process.env.R2_SECRET_ACCESS_KEY!)
303
+ * },
304
+ * compression: {
305
+ * type: "gzip"
306
+ * }
307
+ * }
308
+ * });
309
+ *
310
+ * @see https://developers.cloudflare.com/pipelines/
311
+ */
312
+ export const Pipeline = Resource("cloudflare::Pipeline", async function <
313
+ T extends PipelineRecord = PipelineRecord,
314
+ >(this: Context<Pipeline<T>>, id: string, props: PipelineProps): Promise<
315
+ Pipeline<T>
316
+ > {
317
+ const api = await createCloudflareApi(props);
318
+ const pipelineName = props.name || id;
319
+
320
+ if (this.phase === "delete") {
321
+ console.log("Deleting Cloudflare Pipeline:", pipelineName);
322
+ if (props.delete !== false) {
323
+ // Delete Pipeline
324
+ await deletePipeline(api, pipelineName);
325
+ }
326
+
327
+ // Return void (a deleted pipeline has no content)
328
+ return this.destroy();
329
+ } else {
330
+ let pipelineData: CloudflarePipelineResponse;
331
+
332
+ if (this.phase === "create") {
333
+ console.log("Creating Cloudflare Pipeline:", pipelineName);
334
+ pipelineData = await createPipeline(api, pipelineName, props);
335
+ } else {
336
+ // Update operation
337
+ if (this.output?.id) {
338
+ console.log("Updating Cloudflare Pipeline:", pipelineName);
339
+
340
+ // Check if name is being changed, which is not allowed
341
+ if (props.name !== this.output.name) {
342
+ throw new Error(
343
+ "Cannot update Pipeline name after creation. Pipeline name is immutable."
344
+ );
345
+ }
346
+
347
+ // Update the pipeline with new settings
348
+ pipelineData = await updatePipeline(api, pipelineName, props);
349
+ } else {
350
+ // If no ID exists, fall back to creating a new pipeline
351
+ console.log(
352
+ "No existing Pipeline ID found, creating new Cloudflare Pipeline:",
353
+ pipelineName
354
+ );
355
+ pipelineData = await createPipeline(api, pipelineName, props);
356
+ }
357
+ }
358
+
359
+ return this({
360
+ type: "pipeline",
361
+ id: pipelineData.result.id,
362
+ name: pipelineName,
363
+ endpoint: pipelineData.result.endpoint,
364
+ version: pipelineData.result.version,
365
+ source: pipelineData.result.source!.map((s) => ({
366
+ type: s.type as "http" | "binding",
367
+ format: s.format as "json",
368
+ authentication: s.authentication,
369
+ cors: s.cors,
370
+ })),
371
+ destination: props.destination, // Use the input destination, not the API response
372
+ compression: props.compression,
373
+ accountId: api.accountId,
374
+ });
375
+ }
376
+ });
377
+
378
+ interface CloudflarePipelineResponse {
379
+ result: {
380
+ id: string;
381
+ name: string;
382
+ endpoint: string;
383
+ version: number;
384
+ source: Array<{
385
+ type: "http" | "binding";
386
+ format: string;
387
+ authentication?: boolean;
388
+ cors?: {
389
+ origins: string[];
390
+ };
391
+ }>;
392
+ destination: {
393
+ type: string;
394
+ format: string;
395
+ path?: {
396
+ bucket: string;
397
+ prefix?: string;
398
+ filename?: string;
399
+ filepath?: string;
400
+ };
401
+ compression?: {
402
+ type: string;
403
+ };
404
+ batch: {
405
+ max_bytes?: number;
406
+ max_rows?: number;
407
+ max_duration_s?: number;
408
+ };
409
+ };
410
+ };
411
+ success: boolean;
412
+ errors: Array<{ code: number; message: string }>;
413
+ messages: string[];
414
+ }
415
+
416
+ /**
417
+ * Get a pipeline
418
+ */
419
+ export async function getPipeline(
420
+ api: CloudflareApi,
421
+ pipelineName: string
422
+ ): Promise<CloudflarePipelineResponse> {
423
+ const response = await api.get(
424
+ `/accounts/${api.accountId}/pipelines/${pipelineName}`
425
+ );
426
+
427
+ if (!response.ok) {
428
+ return await handleApiError(response, "getting", "Pipeline", pipelineName);
429
+ }
430
+
431
+ return (await response.json()) as CloudflarePipelineResponse;
432
+ }
433
+
434
+ /**
435
+ * Delete a pipeline
436
+ */
437
+ export async function deletePipeline(
438
+ api: CloudflareApi,
439
+ pipelineName: string
440
+ ): Promise<void> {
441
+ // Delete Pipeline
442
+ const deleteResponse = await api.delete(
443
+ `/accounts/${api.accountId}/pipelines/${pipelineName}`
444
+ );
445
+
446
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
447
+ const errorData: any = await deleteResponse.json().catch(() => ({
448
+ errors: [{ message: deleteResponse.statusText }],
449
+ }));
450
+ throw new CloudflareApiError(
451
+ `Error deleting Cloudflare Pipeline '${pipelineName}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
452
+ deleteResponse
453
+ );
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Create a new pipeline
459
+ */
460
+ export async function createPipeline(
461
+ api: CloudflareApi,
462
+ pipelineName: string,
463
+ props: PipelineProps
464
+ ): Promise<CloudflarePipelineResponse> {
465
+ // Prepare the create payload
466
+ const createPayload = preparePipelinePayload(api, pipelineName, props);
467
+
468
+ const createResponse = await api.post(
469
+ `/accounts/${api.accountId}/pipelines`,
470
+ createPayload
471
+ );
472
+
473
+ if (!createResponse.ok) {
474
+ return await handleApiError(
475
+ createResponse,
476
+ "creating",
477
+ "Pipeline",
478
+ pipelineName
479
+ );
480
+ }
481
+
482
+ return (await createResponse.json()) as CloudflarePipelineResponse;
483
+ }
484
+
485
+ /**
486
+ * Update a pipeline
487
+ */
488
+ export async function updatePipeline(
489
+ api: CloudflareApi,
490
+ pipelineName: string,
491
+ props: PipelineProps
492
+ ): Promise<CloudflarePipelineResponse> {
493
+ // Get current pipeline to build update payload
494
+ const currentPipeline = await getPipeline(api, pipelineName);
495
+
496
+ // Prepare the update payload
497
+ const updatePayload = preparePipelinePayload(
498
+ api,
499
+ pipelineName,
500
+ props,
501
+ currentPipeline
502
+ );
503
+
504
+ const updateResponse = await api.put(
505
+ `/accounts/${api.accountId}/pipelines/${pipelineName}`,
506
+ updatePayload
507
+ );
508
+
509
+ if (!updateResponse.ok) {
510
+ return await handleApiError(
511
+ updateResponse,
512
+ "updating",
513
+ "Pipeline",
514
+ pipelineName
515
+ );
516
+ }
517
+
518
+ return (await updateResponse.json()) as CloudflarePipelineResponse;
519
+ }
520
+
521
+ /**
522
+ * Helper function to prepare pipeline payload for create/update operations
523
+ */
524
+ function preparePipelinePayload(
525
+ api: CloudflareApi,
526
+ pipelineName: string,
527
+ props: PipelineProps,
528
+ currentPipeline?: CloudflarePipelineResponse
529
+ ): any {
530
+ // Prepare the payload with name and source
531
+ const payload: any = {
532
+ name: pipelineName,
533
+ source: props.source ||
534
+ currentPipeline?.result.source || [
535
+ {
536
+ type: "http",
537
+ format: "json",
538
+ authentication: true,
539
+ cors: { origins: ["*"] },
540
+ },
541
+ ],
542
+ };
543
+
544
+ // Handle destination
545
+ if (props.destination) {
546
+ payload.destination = { ...props.destination };
547
+
548
+ // Handle special formatting for R2 destination
549
+ const r2Dest = props.destination as R2DestinationConfig;
550
+
551
+ // Format credentials for API
552
+ if (payload.destination.credentials) {
553
+ payload.destination.credentials = {
554
+ access_key_id: r2Dest.credentials.accessKeyId.unencrypted,
555
+ secret_access_key: r2Dest.credentials.secretAccessKey.unencrypted,
556
+ endpoint:
557
+ r2Dest.credentials.endpoint ??
558
+ `https://${api.accountId}.r2.cloudflarestorage.com`,
559
+ };
560
+ }
561
+
562
+ // Format batch settings
563
+ payload.destination.batch = convertBatchSettings(payload.destination.batch);
564
+ } else if (currentPipeline?.result.destination) {
565
+ payload.destination = currentPipeline.result.destination;
566
+ } else if (!props.destination && !currentPipeline) {
567
+ throw new Error(
568
+ "An R2 destination is required for creating/updating a pipeline"
569
+ );
570
+ }
571
+
572
+ // Add compression if not specified
573
+ if (!payload.destination.compression) {
574
+ payload.destination.compression = { type: "gzip" };
575
+ }
576
+
577
+ return payload;
578
+ }
579
+
580
+ /**
581
+ * List all pipelines in an account
582
+ */
583
+ export async function listPipelines(
584
+ api: CloudflareApi
585
+ ): Promise<{ name: string; id: string }[]> {
586
+ const response = await api.get(`/accounts/${api.accountId}/pipelines`);
587
+
588
+ if (!response.ok) {
589
+ throw new CloudflareApiError(
590
+ `Failed to list pipelines: ${response.statusText}`,
591
+ response
592
+ );
593
+ }
594
+
595
+ const data = (await response.json()) as {
596
+ success: boolean;
597
+ errors?: Array<{ code: number; message: string }>;
598
+ results?: Array<{
599
+ name: string;
600
+ id: string;
601
+ }>;
602
+ };
603
+
604
+ if (!data.success) {
605
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
606
+ throw new Error(`Failed to list pipelines: ${errorMessage}`);
607
+ }
608
+
609
+ // Transform API response
610
+ return (data.results || []).map((pipeline) => ({
611
+ name: pipeline.name,
612
+ id: pipeline.id,
613
+ }));
614
+ }
615
+
616
+ /**
617
+ * Helper function to convert batch settings to the format expected by the API
618
+ */
619
+ interface CloudflareBatchSettings {
620
+ max_bytes?: number;
621
+ max_rows?: number;
622
+ max_duration_s?: number;
623
+ }
624
+
625
+ function convertBatchSettings(batch?: BatchSettings): CloudflareBatchSettings {
626
+ const result: CloudflareBatchSettings = {};
627
+
628
+ if (batch?.maxMb !== undefined) {
629
+ // Convert MB to bytes
630
+ result.max_bytes = batch.maxMb * 1024 * 1024;
631
+ }
632
+
633
+ if (batch?.maxRows !== undefined) {
634
+ result.max_rows = batch.maxRows;
635
+ }
636
+
637
+ if (batch?.maxSeconds !== undefined) {
638
+ result.max_duration_s = batch.maxSeconds;
639
+ }
640
+
641
+ return result;
642
+ }