alchemy 0.15.4 → 0.15.6

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.
@@ -3,8 +3,8 @@ import { Resource } from "../resource.js";
3
3
  import { withExponentialBackoff } from "../util/retry.js";
4
4
  import { handleApiError } from "./api-error.js";
5
5
  import {
6
- type CloudflareApi,
7
6
  createCloudflareApi,
7
+ type CloudflareApi,
8
8
  type CloudflareApiOptions,
9
9
  } from "./api.js";
10
10
 
@@ -22,6 +22,22 @@ export interface KVNamespaceProps extends CloudflareApiOptions {
22
22
  * Only used for initial setup or updates
23
23
  */
24
24
  values?: KVPair[];
25
+
26
+ /**
27
+ * Whether to adopt an existing namespace with the same title if it exists
28
+ * If true and a namespace with the same title exists, it will be adopted rather than creating a new one
29
+ *
30
+ * @default false
31
+ */
32
+ adopt?: boolean;
33
+
34
+ /**
35
+ * Whether to delete the namespace.
36
+ * If set to false, the namespace will remain but the resource will be removed from state
37
+ *
38
+ * @default true
39
+ */
40
+ delete?: boolean;
25
41
  }
26
42
 
27
43
  /**
@@ -112,6 +128,24 @@ export interface KVNamespace
112
128
  * }
113
129
  * }]
114
130
  * });
131
+ *
132
+ * @example
133
+ * // Adopt an existing namespace if it already exists instead of failing
134
+ * const existingNamespace = await KVNamespace("existing-ns", {
135
+ * title: "existing-namespace",
136
+ * adopt: true,
137
+ * values: [{
138
+ * key: "config",
139
+ * value: { setting: "updated-value" }
140
+ * }]
141
+ * });
142
+ *
143
+ * @example
144
+ * // When removing from Alchemy state, keep the namespace in Cloudflare
145
+ * const preservedNamespace = await KVNamespace("preserve-ns", {
146
+ * title: "preserved-namespace",
147
+ * delete: false
148
+ * });
115
149
  */
116
150
  export const KVNamespace = Resource(
117
151
  "cloudflare::KVNamespace",
@@ -126,7 +160,7 @@ export const KVNamespace = Resource(
126
160
  if (this.phase === "delete") {
127
161
  // For delete operations, we need to check if the namespace ID exists in the output
128
162
  const namespaceId = this.output?.namespaceId;
129
- if (namespaceId) {
163
+ if (namespaceId && props.delete !== false) {
130
164
  await deleteKVNamespace(api, namespaceId);
131
165
  }
132
166
 
@@ -145,10 +179,39 @@ export const KVNamespace = Resource(
145
179
  if (this.phase === "update" && namespaceId) {
146
180
  // Can't update a KV namespace title directly, just work with existing ID
147
181
  } else {
148
- // TODO: if it already exists, then check the tags to see if we own it and continue
149
- const { id } = await createKVNamespace(api, props);
150
- createdAt = Date.now();
151
- namespaceId = id;
182
+ try {
183
+ // Try to create the KV namespace
184
+ const { id } = await createKVNamespace(api, props);
185
+ createdAt = Date.now();
186
+ namespaceId = id;
187
+ } catch (error) {
188
+ // Check if this is a "namespace already exists" error and adopt is enabled
189
+ if (
190
+ props.adopt &&
191
+ error instanceof Error &&
192
+ error.message.includes("already exists")
193
+ ) {
194
+ console.log(`Namespace '${props.title}' already exists, adopting it`);
195
+ // Find the existing namespace by title
196
+ const existingNamespace = await findKVNamespaceByTitle(
197
+ api,
198
+ props.title,
199
+ );
200
+
201
+ if (!existingNamespace) {
202
+ throw new Error(
203
+ `Failed to find existing namespace '${props.title}' for adoption`,
204
+ );
205
+ }
206
+
207
+ // Use the existing namespace ID
208
+ namespaceId = existingNamespace.id;
209
+ createdAt = existingNamespace.createdAt || Date.now();
210
+ } else {
211
+ // Re-throw the error if adopt is false or it's not a "namespace already exists" error
212
+ throw error;
213
+ }
214
+ }
152
215
  }
153
216
 
154
217
  await insertKVRecords(api, namespaceId, props);
@@ -232,37 +295,100 @@ export async function insertKVRecords(
232
295
  return item;
233
296
  });
234
297
 
235
- try {
236
- await withExponentialBackoff(
237
- async () => {
238
- const bulkResponse = await api.put(
239
- `/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}/bulk`,
240
- bulkPayload,
241
- );
298
+ await withExponentialBackoff(
299
+ async () => {
300
+ const bulkResponse = await api.put(
301
+ `/accounts/${api.accountId}/storage/kv/namespaces/${namespaceId}/bulk`,
302
+ bulkPayload,
303
+ );
304
+
305
+ if (!bulkResponse.ok) {
306
+ const errorData: any = await bulkResponse.json().catch(() => ({
307
+ errors: [{ message: bulkResponse.statusText }],
308
+ }));
309
+ const errorMessage =
310
+ errorData.errors?.[0]?.message || bulkResponse.statusText;
311
+
312
+ // Throw error to trigger retry
313
+ throw new Error(`Error writing KV batch: ${errorMessage}`);
314
+ }
315
+
316
+ return bulkResponse;
317
+ },
318
+ (error) => {
319
+ // Retry on "namespace not found" errors as they're likely propagation delays
320
+ return error.message?.includes("not found");
321
+ },
322
+ 5, // 5 retry attempts
323
+ 1000, // Start with 1 second delay
324
+ );
325
+ }
326
+ }
327
+ }
242
328
 
243
- if (!bulkResponse.ok) {
244
- const errorData: any = await bulkResponse.json().catch(() => ({
245
- errors: [{ message: bulkResponse.statusText }],
246
- }));
247
- const errorMessage =
248
- errorData.errors?.[0]?.message || bulkResponse.statusText;
249
-
250
- // Throw error to trigger retry
251
- throw new Error(`Error writing KV batch: ${errorMessage}`);
252
- }
253
-
254
- return bulkResponse;
255
- },
256
- (error) => {
257
- // Retry on "namespace not found" errors as they're likely propagation delays
258
- return error.message?.includes("not found");
259
- },
260
- 5, // 5 retry attempts
261
- 1000, // Start with 1 second delay
262
- );
263
- } catch (error: any) {
264
- console.warn(error.message);
265
- }
329
+ /**
330
+ * Interface representing a KV namespace as returned by Cloudflare API
331
+ */
332
+ interface CloudflareKVNamespace {
333
+ id: string;
334
+ title: string;
335
+ supports_url_encoding?: boolean;
336
+ created_on?: string;
337
+ }
338
+
339
+ /**
340
+ * Find a KV namespace by title with pagination support
341
+ */
342
+ export async function findKVNamespaceByTitle(
343
+ api: CloudflareApi,
344
+ title: string,
345
+ ): Promise<{ id: string; createdAt?: number } | null> {
346
+ let page = 1;
347
+ const perPage = 100; // Maximum allowed by API
348
+ let hasMorePages = true;
349
+
350
+ while (hasMorePages) {
351
+ const response = await api.get(
352
+ `/accounts/${api.accountId}/storage/kv/namespaces?page=${page}&per_page=${perPage}`,
353
+ );
354
+
355
+ if (!response.ok) {
356
+ await handleApiError(response, "list", "kv_namespace", "all");
266
357
  }
358
+
359
+ const data = (await response.json()) as {
360
+ result: CloudflareKVNamespace[];
361
+ result_info: {
362
+ count: number;
363
+ page: number;
364
+ per_page: number;
365
+ total_count: number;
366
+ };
367
+ success: boolean;
368
+ errors: any[];
369
+ };
370
+
371
+ const namespaces = data.result;
372
+ const resultInfo = data.result_info;
373
+
374
+ // Look for a namespace with matching title
375
+ const match = namespaces.find((ns) => ns.title === title);
376
+ if (match) {
377
+ return {
378
+ id: match.id,
379
+ // Convert ISO string to timestamp if available, otherwise use current time
380
+ createdAt: match.created_on
381
+ ? new Date(match.created_on).getTime()
382
+ : undefined,
383
+ };
384
+ }
385
+
386
+ // Check if we've seen all pages
387
+ hasMorePages =
388
+ resultInfo.page * resultInfo.per_page < resultInfo.total_count;
389
+ page++;
267
390
  }
391
+
392
+ // No matching namespace found
393
+ return null;
268
394
  }
@@ -0,0 +1,268 @@
1
+ import type { Context } from "../context.js";
2
+ import { Resource } from "../resource.js";
3
+ import { CloudflareApiError, handleApiError } from "./api-error.js";
4
+ import {
5
+ createCloudflareApi,
6
+ type CloudflareApi,
7
+ type CloudflareApiOptions,
8
+ } from "./api.js";
9
+ import type { Worker } from "./worker.js";
10
+
11
+ /**
12
+ * Properties for creating or updating a Route
13
+ */
14
+ export interface RouteProps extends CloudflareApiOptions {
15
+ /**
16
+ * URL pattern for the route
17
+ * @example "example.com/*"
18
+ */
19
+ pattern: string;
20
+
21
+ /**
22
+ * Worker script for the route
23
+ * This can be a Worker resource or script name as a string
24
+ */
25
+ script: Worker | string;
26
+
27
+ /**
28
+ * Zone ID for the route
29
+ */
30
+ zoneId: string;
31
+ }
32
+
33
+ /**
34
+ * Output returned after Route creation/update
35
+ */
36
+ export interface Route extends Resource<"cloudflare::Route">, RouteProps {
37
+ /**
38
+ * The unique ID of the route
39
+ */
40
+ id: string;
41
+
42
+ /**
43
+ * The URL pattern for the route
44
+ */
45
+ pattern: string;
46
+
47
+ /**
48
+ * The Worker script name for the route
49
+ */
50
+ script: string;
51
+
52
+ /**
53
+ * The Zone ID for the route
54
+ */
55
+ zoneId: string;
56
+ }
57
+
58
+ /**
59
+ * Creates and manages Cloudflare Worker Routes.
60
+ *
61
+ * Routes map URL patterns to Worker scripts, allowing you to control which
62
+ * requests are handled by your Workers.
63
+ *
64
+ * @example
65
+ * // Create a route that maps all requests on a domain to a Worker
66
+ * const basicRoute = await Route("main-route", {
67
+ * pattern: "example.com/*",
68
+ * script: "my-worker",
69
+ * zoneId: "your-zone-id"
70
+ * });
71
+ *
72
+ * @example
73
+ * // Create a route using a Worker resource
74
+ * const worker = await Worker("api-worker", {
75
+ * script: `
76
+ * export default {
77
+ * fetch(request, env) {
78
+ * return new Response("Hello from API!");
79
+ * }
80
+ * }
81
+ * `
82
+ * });
83
+ *
84
+ * const apiRoute = await Route("api-route", {
85
+ * pattern: "api.example.com/*",
86
+ * script: worker,
87
+ * zoneId: "your-zone-id"
88
+ * });
89
+ *
90
+ * @see https://developers.cloudflare.com/workers/configuration/routes/
91
+ */
92
+ export const Route = Resource(
93
+ "cloudflare::Route",
94
+ async function (
95
+ this: Context<Route>,
96
+ id: string,
97
+ props: RouteProps,
98
+ ): Promise<Route> {
99
+ const api = await createCloudflareApi(props);
100
+
101
+ // Get script name from script prop (either a string or a Worker resource)
102
+ const scriptName =
103
+ typeof props.script === "string" ? props.script : props.script.name;
104
+
105
+ // Get zone ID from props
106
+ const { zoneId } = props;
107
+
108
+ if (this.phase === "delete") {
109
+ console.log("Deleting Route:", props.pattern);
110
+
111
+ // Only delete if we have an ID
112
+ if (this.output?.id) {
113
+ await deleteRoute(api, zoneId, this.output.id);
114
+ }
115
+
116
+ // Return void (a deleted route has no content)
117
+ return this.destroy();
118
+ }
119
+
120
+ let routeData: CloudflareRouteResponse;
121
+
122
+ if (this.phase === "update" && this.output?.id) {
123
+ console.log("Updating Route:", props.pattern);
124
+
125
+ // Update existing route
126
+ routeData = await updateRoute(
127
+ api,
128
+ zoneId,
129
+ this.output.id,
130
+ props.pattern,
131
+ scriptName,
132
+ );
133
+ } else {
134
+ console.log("Creating Route:", props.pattern);
135
+
136
+ // Create new route
137
+ routeData = await createRoute(api, zoneId, props.pattern, scriptName);
138
+ }
139
+
140
+ // Return the route resource
141
+ return this({
142
+ id: routeData.result.id,
143
+ pattern: routeData.result.pattern,
144
+ script: routeData.result.script,
145
+ zoneId,
146
+ });
147
+ },
148
+ );
149
+
150
+ interface CloudflareRouteResponse {
151
+ result: {
152
+ id: string;
153
+ pattern: string;
154
+ script: string;
155
+ };
156
+ success: boolean;
157
+ errors: Array<{ code: number; message: string }>;
158
+ messages: string[];
159
+ }
160
+
161
+ /**
162
+ * Create a new route
163
+ */
164
+ async function createRoute(
165
+ api: CloudflareApi,
166
+ zoneId: string,
167
+ pattern: string,
168
+ script: string,
169
+ ): Promise<CloudflareRouteResponse> {
170
+ const createResponse = await api.post(`/zones/${zoneId}/workers/routes`, {
171
+ pattern,
172
+ script,
173
+ });
174
+
175
+ if (!createResponse.ok) {
176
+ return await handleApiError(createResponse, "creating", "Route", pattern);
177
+ }
178
+
179
+ return (await createResponse.json()) as CloudflareRouteResponse;
180
+ }
181
+
182
+ /**
183
+ * Update a route
184
+ */
185
+ async function updateRoute(
186
+ api: CloudflareApi,
187
+ zoneId: string,
188
+ routeId: string,
189
+ pattern: string,
190
+ script: string,
191
+ ): Promise<CloudflareRouteResponse> {
192
+ const updateResponse = await api.put(
193
+ `/zones/${zoneId}/workers/routes/${routeId}`,
194
+ {
195
+ pattern,
196
+ script,
197
+ },
198
+ );
199
+
200
+ if (!updateResponse.ok) {
201
+ return await handleApiError(updateResponse, "updating", "Route", pattern);
202
+ }
203
+
204
+ return (await updateResponse.json()) as CloudflareRouteResponse;
205
+ }
206
+
207
+ /**
208
+ * Delete a route
209
+ */
210
+ async function deleteRoute(
211
+ api: CloudflareApi,
212
+ zoneId: string,
213
+ routeId: string,
214
+ ): Promise<void> {
215
+ const deleteResponse = await api.delete(
216
+ `/zones/${zoneId}/workers/routes/${routeId}`,
217
+ );
218
+
219
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
220
+ const errorData: any = await deleteResponse.json().catch(() => ({
221
+ errors: [{ message: deleteResponse.statusText }],
222
+ }));
223
+
224
+ throw new CloudflareApiError(
225
+ `Error deleting Route '${routeId}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
226
+ deleteResponse,
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Get a route by ID
233
+ */
234
+ export async function getRoute(
235
+ api: CloudflareApi,
236
+ zoneId: string,
237
+ routeId: string,
238
+ ): Promise<CloudflareRouteResponse> {
239
+ const response = await api.get(`/zones/${zoneId}/workers/routes/${routeId}`);
240
+
241
+ if (!response.ok) {
242
+ throw new CloudflareApiError(
243
+ `Failed to get route ${routeId}: ${response.statusText}`,
244
+ response,
245
+ );
246
+ }
247
+
248
+ return (await response.json()) as CloudflareRouteResponse;
249
+ }
250
+
251
+ /**
252
+ * List all routes for a zone
253
+ */
254
+ export async function listRoutes(
255
+ api: CloudflareApi,
256
+ zoneId: string,
257
+ ): Promise<CloudflareRouteResponse> {
258
+ const response = await api.get(`/zones/${zoneId}/workers/routes`);
259
+
260
+ if (!response.ok) {
261
+ throw new CloudflareApiError(
262
+ `Failed to list routes: ${response.statusText}`,
263
+ response,
264
+ );
265
+ }
266
+
267
+ return (await response.json()) as CloudflareRouteResponse;
268
+ }
@@ -275,6 +275,12 @@ export interface Worker<B extends Bindings = Bindings>
275
275
  * url: true
276
276
  * });
277
277
  *
278
+ * await Route("route", {
279
+ * zoneId: zone.zoneId,
280
+ * worker: api,
281
+ * pattern: "api.example.com/*",
282
+ * });
283
+ *
278
284
  * @example
279
285
  * // Create a real-time chat worker using Durable Objects
280
286
  * // for state management:
@@ -95,7 +95,7 @@ export const WranglerJson = Resource(
95
95
 
96
96
  // Process bindings if they exist
97
97
  if (worker.bindings) {
98
- processBindings(spec, worker.bindings, worker.eventSources);
98
+ processBindings(spec, worker.bindings, worker.eventSources, worker.name);
99
99
  }
100
100
 
101
101
  // Add environment variables as vars
@@ -170,6 +170,10 @@ export interface WranglerJsonSpec {
170
170
  kv_namespaces?: {
171
171
  binding: string;
172
172
  id: string;
173
+ /**
174
+ * The ID of the KV namespace used during `wrangler dev`
175
+ */
176
+ preview_id?: string;
173
177
  }[];
174
178
 
175
179
  /**
@@ -190,6 +194,10 @@ export interface WranglerJsonSpec {
190
194
  r2_buckets?: {
191
195
  binding: string;
192
196
  bucket_name: string;
197
+ /**
198
+ * The preview name of this R2 bucket at the edge.
199
+ */
200
+ preview_bucket_name?: string;
193
201
  }[];
194
202
 
195
203
  /**
@@ -246,6 +254,10 @@ export interface WranglerJsonSpec {
246
254
  database_id: string;
247
255
  database_name: string;
248
256
  migrations_dir?: string;
257
+ /**
258
+ * The ID of the D1 database used during `wrangler dev`
259
+ */
260
+ preview_database_id?: string;
249
261
  }[];
250
262
 
251
263
  /**
@@ -256,6 +268,15 @@ export interface WranglerJsonSpec {
256
268
  binding: string;
257
269
  };
258
270
 
271
+ /**
272
+ * Migrations
273
+ */
274
+ migrations?: {
275
+ tag: string;
276
+ new_sqlite_classes?: string[];
277
+ new_classes?: string[];
278
+ }[];
279
+
259
280
  /**
260
281
  * Workflow bindings
261
282
  */
@@ -279,6 +300,7 @@ function processBindings(
279
300
  spec: WranglerJsonSpec,
280
301
  bindings: Bindings,
281
302
  eventSources: EventSource[] | undefined,
303
+ workerName: string,
282
304
  ): void {
283
305
  // Arrays to collect different binding types
284
306
  const kvNamespaces: { binding: string; id: string }[] = [];
@@ -314,6 +336,9 @@ function processBindings(
314
336
  consumers: [],
315
337
  };
316
338
 
339
+ const new_sqlite_classes: string[] = [];
340
+ const new_classes: string[] = [];
341
+
317
342
  const vectorizeIndexes: { binding: string; index_name: string }[] = [];
318
343
 
319
344
  for (const eventSource of eventSources ?? []) {
@@ -344,7 +369,7 @@ function processBindings(
344
369
  // Self(service) binding
345
370
  services.push({
346
371
  binding: bindingName,
347
- service: bindingName,
372
+ service: workerName,
348
373
  });
349
374
  } else if (binding.type === "service") {
350
375
  // Service binding
@@ -370,6 +395,11 @@ function processBindings(
370
395
  script_name: doBinding.scriptName,
371
396
  environment: doBinding.environment,
372
397
  });
398
+ if (doBinding.sqlite) {
399
+ new_sqlite_classes.push(doBinding.className);
400
+ } else {
401
+ new_classes.push(doBinding.className);
402
+ }
373
403
  } else if (binding.type === "r2_bucket") {
374
404
  r2Buckets.push({
375
405
  binding: bindingName,
@@ -453,4 +483,14 @@ function processBindings(
453
483
  if (vectorizeIndexes.length > 0) {
454
484
  spec.vectorize_indexes = vectorizeIndexes;
455
485
  }
486
+
487
+ if (new_sqlite_classes.length > 0 || new_classes.length > 0) {
488
+ spec.migrations = [
489
+ {
490
+ tag: "v1",
491
+ new_sqlite_classes,
492
+ new_classes,
493
+ },
494
+ ];
495
+ }
456
496
  }
@@ -6,6 +6,14 @@ import { Document } from "../../ai/document.js";
6
6
  import { alchemy } from "../../alchemy.js";
7
7
  import { Folder } from "../../fs/folder.js";
8
8
 
9
+ function getArg(arg: string) {
10
+ const idx = process.argv.findIndex((a) => a === arg);
11
+ return idx > -1 ? process.argv[idx + 1] : undefined;
12
+ }
13
+
14
+ const onlyProviderName = getArg("--provider");
15
+ const onlyResourceName = getArg("--resource");
16
+
9
17
  export interface DocsProps {
10
18
  /**
11
19
  * The output directory for the docs.
@@ -117,7 +125,7 @@ async function generateProviderDocs({
117
125
  reasoningEffort: "high",
118
126
  },
119
127
  },
120
- freeze: false,
128
+ freeze: onlyProviderName === undefined || onlyProviderName !== providerName,
121
129
  temperature: 0.1,
122
130
  schema: type({
123
131
  groups: type({
@@ -177,7 +185,8 @@ async function generateProviderDocs({
177
185
  providerDocsDir,
178
186
  `${g.filename.replace(".ts", "").replace(".md", "")}.md`,
179
187
  ),
180
- freeze: false,
188
+ freeze:
189
+ onlyResourceName !== undefined && onlyResourceName !== g.identifier,
181
190
  model: {
182
191
  id: "claude-3-5-sonnet-latest",
183
192
  provider: "anthropic",
package/src/scope.ts CHANGED
@@ -47,10 +47,15 @@ export class Scope {
47
47
 
48
48
  private isErrored = false;
49
49
 
50
- constructor(private readonly options: ScopeOptions) {
50
+ constructor(options: ScopeOptions) {
51
51
  this.appName = options.appName;
52
52
  this.stage = options?.stage ?? DEFAULT_STAGE;
53
53
  this.scopeName = options.scopeName ?? null;
54
+ if (this.scopeName?.includes(":")) {
55
+ throw new Error(
56
+ `Scope name ${this.scopeName} cannot contain double colons`,
57
+ );
58
+ }
54
59
  this.parent = options.parent ?? Scope.get();
55
60
  this.quiet = options.quiet ?? this.parent?.quiet ?? false;
56
61
  if (this.parent && !this.scopeName) {