alchemy 0.7.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.
@@ -1,4 +1,4 @@
1
- import * as fs from "fs/promises";
1
+ import { StaticJsonFile } from "../fs";
2
2
  import { Resource } from "../resource";
3
3
  /**
4
4
  * Resource for managing wrangler.json configuration files
@@ -7,29 +7,136 @@ export const WranglerJson = Resource("cloudflare::WranglerJson", async function
7
7
  // Default path is wrangler.json in current directory
8
8
  const filePath = props.path || "wrangler.json";
9
9
  if (this.phase === "delete") {
10
- try {
11
- await fs.unlink(filePath);
12
- }
13
- catch (error) {
14
- // Ignore errors if file doesn't exist
15
- if (error.code !== "ENOENT") {
16
- throw error;
17
- }
18
- }
19
10
  return this.destroy();
20
11
  }
21
- // Create or update the file
22
- const config = {
23
- ...props,
24
- // Remove internal path property
25
- path: undefined,
12
+ if (props.worker.entrypoint === undefined) {
13
+ throw new Error("Worker must have an entrypoint to generate a wrangler.json");
14
+ }
15
+ const worker = props.worker;
16
+ const spec = {
17
+ name: worker.name,
18
+ // Use entrypoint as main if it exists
19
+ main: worker.entrypoint,
20
+ // see: https://developers.cloudflare.com/workers/configuration/compatibility-dates/
21
+ compatibility_date: "2022-04-05",
26
22
  };
27
- // Write the file
28
- await fs.writeFile(filePath, JSON.stringify(config, null, 2));
23
+ // Process bindings if they exist
24
+ if (worker.bindings) {
25
+ processBindings(spec, worker.bindings);
26
+ }
27
+ // Add environment variables as vars
28
+ if (worker.env) {
29
+ spec.vars = { ...worker.env };
30
+ }
31
+ await StaticJsonFile("wrangler.json", spec);
29
32
  // Return the resource
30
33
  return this({
31
34
  ...props,
35
+ path: filePath,
32
36
  createdAt: Date.now(),
33
37
  updatedAt: Date.now(),
34
38
  });
35
39
  });
40
+ /**
41
+ * Process worker bindings into wrangler.json format
42
+ */
43
+ function processBindings(spec, bindings) {
44
+ // Arrays to collect different binding types
45
+ const kvNamespaces = [];
46
+ const durableObjects = [];
47
+ const r2Buckets = [];
48
+ const services = [];
49
+ const secrets = [];
50
+ const workflows = [];
51
+ const d1Databases = [];
52
+ const queues = [];
53
+ // Process each binding
54
+ for (const [bindingName, binding] of Object.entries(bindings)) {
55
+ if (typeof binding === "string") {
56
+ // Plain text binding - add to vars
57
+ if (!spec.vars) {
58
+ spec.vars = {};
59
+ }
60
+ spec.vars[bindingName] = binding;
61
+ }
62
+ else if (binding.type === "kv_namespace") {
63
+ // KV Namespace binding
64
+ kvNamespaces.push({
65
+ binding: bindingName,
66
+ id: binding.namespaceId,
67
+ });
68
+ }
69
+ else if (typeof binding === "object" &&
70
+ binding.type === "durable_object_namespace") {
71
+ // Durable Object binding
72
+ const doBinding = binding;
73
+ durableObjects.push({
74
+ name: bindingName,
75
+ class_name: doBinding.className,
76
+ script_name: doBinding.scriptName,
77
+ environment: doBinding.environment,
78
+ });
79
+ }
80
+ else if (binding.type === "r2_bucket") {
81
+ r2Buckets.push({
82
+ binding: bindingName,
83
+ bucket_name: binding.name,
84
+ });
85
+ }
86
+ else if (binding.type === "service") {
87
+ // Service binding
88
+ services.push({
89
+ binding: bindingName,
90
+ service: binding.id,
91
+ });
92
+ }
93
+ else if (binding.type === "secret") {
94
+ // Secret binding
95
+ secrets.push(bindingName);
96
+ }
97
+ else if (binding.type === "assets") {
98
+ spec.assets = {
99
+ directory: binding.path,
100
+ binding: bindingName,
101
+ };
102
+ }
103
+ else if (binding.type === "workflow") {
104
+ workflows.push({
105
+ name: bindingName,
106
+ binding: binding.id,
107
+ class_name: binding.className,
108
+ });
109
+ }
110
+ else if (binding.type === "d1") {
111
+ d1Databases.push({
112
+ binding: bindingName,
113
+ database_id: binding.id,
114
+ database_name: binding.name,
115
+ });
116
+ }
117
+ else if (binding.type === "queue") {
118
+ queues.push({
119
+ binding: bindingName,
120
+ queue: binding.name,
121
+ });
122
+ }
123
+ }
124
+ // Add collected bindings to the spec
125
+ if (kvNamespaces.length > 0) {
126
+ spec.kv_namespaces = kvNamespaces;
127
+ }
128
+ if (durableObjects.length > 0) {
129
+ spec.durable_objects = {
130
+ bindings: durableObjects,
131
+ };
132
+ }
133
+ if (r2Buckets.length > 0) {
134
+ spec.r2_buckets = r2Buckets;
135
+ }
136
+ if (services.length > 0) {
137
+ spec.services = services;
138
+ }
139
+ if (queues.length > 0) {
140
+ spec.queues = queues;
141
+ }
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "module": "./lib/index.js",
6
6
  "scripts": {
@@ -6,8 +6,10 @@
6
6
  import type { Secret } from "../secret";
7
7
  import type { Assets } from "./assets";
8
8
  import type { R2Bucket } from "./bucket";
9
+ import type { D1Database } from "./d1-database";
9
10
  import type { DurableObjectNamespace } from "./durable-object-namespace";
10
11
  import type { KVNamespace } from "./kv-namespace";
12
+ import type { Queue } from "./queue";
11
13
  import type { Worker } from "./worker";
12
14
  import type { Workflow } from "./workflow";
13
15
 
@@ -19,14 +21,16 @@ export type Bindings = {
19
21
  * L2 Binding Resources.
20
22
  */
21
23
  export type Binding =
24
+ | Assets
25
+ | D1Database
22
26
  | DurableObjectNamespace
23
27
  | KVNamespace
24
- | Worker
25
- | Workflow
28
+ | Queue
26
29
  | R2Bucket
27
30
  | Secret
28
31
  | string
29
- | Assets;
32
+ | Worker
33
+ | Workflow;
30
34
 
31
35
  /**
32
36
  * Union type for all Worker binding types (API spec)
@@ -2,8 +2,10 @@ import type { Secret } from "../secret";
2
2
  import type { Assets } from "./assets";
3
3
  import type { Binding } from "./bindings";
4
4
  import type { R2Bucket as _R2Bucket } from "./bucket";
5
+ import type { D1Database as _D1Database } from "./d1-database";
5
6
  import type { DurableObjectNamespace as _DurableObjectNamespace } from "./durable-object-namespace";
6
7
  import type { KVNamespace as _KVNamespace } from "./kv-namespace";
8
+ import type { Queue as _Queue } from "./queue";
7
9
  import type { Worker as _Worker } from "./worker";
8
10
  import type { Workflow as _Workflow } from "./workflow";
9
11
 
@@ -21,4 +23,8 @@ export type Bound<T extends Binding> = T extends _DurableObjectNamespace
21
23
  ? Service
22
24
  : T extends _Workflow<infer P>
23
25
  ? Workflow<P>
24
- : Service;
26
+ : T extends _D1Database
27
+ ? D1Database
28
+ : T extends _Queue
29
+ ? Queue
30
+ : Service;
@@ -0,0 +1,354 @@
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
+ * Properties for creating or updating a D1 Database
12
+ */
13
+ export interface D1DatabaseProps extends CloudflareApiOptions {
14
+ /**
15
+ * Name of the database
16
+ */
17
+ name: string;
18
+
19
+ /**
20
+ * Optional primary location hint for the database
21
+ * Indicates the primary geographical location data will be stored
22
+ */
23
+ primaryLocationHint?:
24
+ | "wnam"
25
+ | "enam"
26
+ | "weur"
27
+ | "eeur"
28
+ | "apac"
29
+ | "auto"
30
+ | string;
31
+
32
+ /**
33
+ * Read replication configuration
34
+ * Only mutable property during updates
35
+ */
36
+ readReplication?: {
37
+ /**
38
+ * Read replication mode
39
+ * - auto: Automatic read replication
40
+ * - disabled: No read replication
41
+ */
42
+ mode: "auto" | "disabled";
43
+ };
44
+
45
+ /**
46
+ * Whether to delete the database.
47
+ * If set to false, the database will remain but the resource will be removed from state
48
+ *
49
+ * @default true
50
+ */
51
+ delete?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Output returned after D1 Database creation/update
56
+ */
57
+ export interface D1Database
58
+ extends Resource<"cloudflare::D1Database">,
59
+ D1DatabaseProps {
60
+ type: "d1";
61
+ /**
62
+ * The unique ID of the database (UUID)
63
+ */
64
+ id: string;
65
+
66
+ /**
67
+ * File size of the database
68
+ */
69
+ fileSize: number;
70
+
71
+ /**
72
+ * Number of tables in the database
73
+ */
74
+ numTables: number;
75
+
76
+ /**
77
+ * Version of the database
78
+ */
79
+ version: string;
80
+
81
+ /**
82
+ * Read replication configuration
83
+ */
84
+ readReplication?: {
85
+ /**
86
+ * Read replication mode
87
+ */
88
+ mode: "auto" | "disabled";
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Creates and manages Cloudflare D1 Databases.
94
+ *
95
+ * D1 Databases provide serverless SQL databases built on SQLite with
96
+ * automatic data replication for high availability.
97
+ *
98
+ * @example
99
+ * // Create a basic D1 database with default settings
100
+ * const basicDatabase = await D1Database("my-app-db", {
101
+ * name: "my-app-db"
102
+ * });
103
+ *
104
+ * @example
105
+ * // Create a database with location hint for optimal performance
106
+ * const westUsDatabase = await D1Database("west-us-db", {
107
+ * name: "west-us-db",
108
+ * primaryLocationHint: "wnam"
109
+ * });
110
+ *
111
+ * @see https://developers.cloudflare.com/d1/
112
+ */
113
+ export const D1Database = Resource(
114
+ "cloudflare::D1Database",
115
+ async function (
116
+ this: Context<D1Database>,
117
+ id: string,
118
+ props: D1DatabaseProps
119
+ ): Promise<D1Database> {
120
+ const api = await createCloudflareApi(props);
121
+ const databaseName = props.name || id;
122
+
123
+ if (this.phase === "delete") {
124
+ console.log("Deleting D1 database:", databaseName);
125
+ if (props.delete !== false) {
126
+ // Delete D1 database
127
+ console.log("Deleting D1 database:", databaseName);
128
+ await deleteDatabase(api, this.output?.id);
129
+ }
130
+
131
+ // Return void (a deleted database has no content)
132
+ return this.destroy();
133
+ } else {
134
+ let dbData: CloudflareD1Response;
135
+
136
+ if (this.phase === "create") {
137
+ console.log("Creating D1 database:", databaseName);
138
+ dbData = await createDatabase(api, databaseName, props);
139
+ } else {
140
+ // Update operation
141
+ if (this.output?.id) {
142
+ console.log("Updating D1 database:", databaseName);
143
+ // Update the database with new properties
144
+ dbData = await updateDatabase(api, this.output.id, props);
145
+ } else {
146
+ // If no ID exists, fall back to creating a new database
147
+ console.log(
148
+ "No existing database ID found, creating new D1 database:",
149
+ databaseName
150
+ );
151
+ dbData = await createDatabase(api, databaseName, props);
152
+ }
153
+ }
154
+
155
+ return this({
156
+ type: "d1",
157
+ id: dbData.result.uuid || "",
158
+ name: databaseName,
159
+ fileSize: dbData.result.file_size,
160
+ numTables: dbData.result.num_tables,
161
+ version: dbData.result.version,
162
+ readReplication: dbData.result.read_replication,
163
+ primaryLocationHint: props.primaryLocationHint,
164
+ accountId: api.accountId,
165
+ });
166
+ }
167
+ }
168
+ );
169
+
170
+ interface CloudflareD1Response {
171
+ result: {
172
+ uuid?: string;
173
+ name: string;
174
+ file_size: number;
175
+ num_tables: number;
176
+ version: string;
177
+ primary_location_hint?: string;
178
+ read_replication?: {
179
+ mode: "auto" | "disabled";
180
+ };
181
+ };
182
+ success: boolean;
183
+ errors: Array<{ code: number; message: string }>;
184
+ messages: string[];
185
+ }
186
+
187
+ /**
188
+ * Create a new D1 database
189
+ */
190
+ export async function createDatabase(
191
+ api: CloudflareApi,
192
+ databaseName: string,
193
+ props: D1DatabaseProps
194
+ ): Promise<CloudflareD1Response> {
195
+ // Create new D1 database
196
+ const createPayload: any = {
197
+ name: databaseName,
198
+ };
199
+
200
+ if (props.primaryLocationHint) {
201
+ createPayload.primary_location_hint = props.primaryLocationHint;
202
+ }
203
+
204
+ const createResponse = await api.post(
205
+ `/accounts/${api.accountId}/d1/database`,
206
+ createPayload
207
+ );
208
+
209
+ if (!createResponse.ok) {
210
+ return await handleApiError(
211
+ createResponse,
212
+ "creating",
213
+ "D1 database",
214
+ databaseName
215
+ );
216
+ }
217
+
218
+ return (await createResponse.json()) as CloudflareD1Response;
219
+ }
220
+
221
+ /**
222
+ * Get a D1 database
223
+ */
224
+ export async function getDatabase(
225
+ api: CloudflareApi,
226
+ databaseId?: string
227
+ ): Promise<CloudflareD1Response> {
228
+ if (!databaseId) {
229
+ throw new Error("Database ID is required");
230
+ }
231
+
232
+ const response = await api.get(
233
+ `/accounts/${api.accountId}/d1/database/${databaseId}`
234
+ );
235
+
236
+ if (!response.ok) {
237
+ return await handleApiError(response, "getting", "D1 database", databaseId);
238
+ }
239
+
240
+ return (await response.json()) as CloudflareD1Response;
241
+ }
242
+
243
+ /**
244
+ * Delete a D1 database
245
+ */
246
+ export async function deleteDatabase(
247
+ api: CloudflareApi,
248
+ databaseId?: string
249
+ ): Promise<void> {
250
+ if (!databaseId) {
251
+ console.log("No database ID provided, skipping delete");
252
+ return;
253
+ }
254
+
255
+ // Delete D1 database
256
+ const deleteResponse = await api.delete(
257
+ `/accounts/${api.accountId}/d1/database/${databaseId}`
258
+ );
259
+
260
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
261
+ const errorData: any = await deleteResponse.json().catch(() => ({
262
+ errors: [{ message: deleteResponse.statusText }],
263
+ }));
264
+ throw new CloudflareApiError(
265
+ `Error deleting D1 database '${databaseId}': ${errorData.errors?.[0]?.message || deleteResponse.statusText}`,
266
+ deleteResponse
267
+ );
268
+ }
269
+ }
270
+
271
+ /**
272
+ * List all D1 databases in an account
273
+ */
274
+ export async function listDatabases(
275
+ api: CloudflareApi
276
+ ): Promise<{ name: string; id: string }[]> {
277
+ const response = await api.get(`/accounts/${api.accountId}/d1/database`);
278
+
279
+ if (!response.ok) {
280
+ throw new CloudflareApiError(
281
+ `Failed to list databases: ${response.statusText}`,
282
+ response
283
+ );
284
+ }
285
+
286
+ const data = (await response.json()) as {
287
+ success: boolean;
288
+ errors?: Array<{ code: number; message: string }>;
289
+ result?: Array<{
290
+ name: string;
291
+ uuid: string;
292
+ }>;
293
+ };
294
+
295
+ if (!data.success) {
296
+ const errorMessage = data.errors?.[0]?.message || "Unknown error";
297
+ throw new Error(`Failed to list databases: ${errorMessage}`);
298
+ }
299
+
300
+ // Transform API response
301
+ return (data.result || []).map((db) => ({
302
+ name: db.name,
303
+ id: db.uuid,
304
+ }));
305
+ }
306
+
307
+ /**
308
+ * Update a D1 database
309
+ *
310
+ * Note: According to Cloudflare API, only read_replication.mode can be modified during updates.
311
+ */
312
+ export async function updateDatabase(
313
+ api: CloudflareApi,
314
+ databaseId: string,
315
+ props: D1DatabaseProps
316
+ ): Promise<CloudflareD1Response> {
317
+ // Get current database state to check for non-mutable changes
318
+ const currentDB = await getDatabase(api, databaseId);
319
+
320
+ // Only read_replication can be modified in update
321
+ if (
322
+ props.primaryLocationHint &&
323
+ props.primaryLocationHint !== currentDB.result.primary_location_hint
324
+ ) {
325
+ throw new Error(
326
+ "Cannot update primaryLocationHint after database creation. Only readReplication.mode can be modified."
327
+ );
328
+ }
329
+
330
+ const updatePayload: any = {};
331
+
332
+ // Only include read_replication in update payload
333
+ if (props.readReplication) {
334
+ updatePayload.read_replication = {
335
+ mode: props.readReplication.mode,
336
+ };
337
+ }
338
+
339
+ const updateResponse = await api.patch(
340
+ `/accounts/${api.accountId}/d1/database/${databaseId}`,
341
+ updatePayload
342
+ );
343
+
344
+ if (!updateResponse.ok) {
345
+ return await handleApiError(
346
+ updateResponse,
347
+ "updating",
348
+ "D1 database",
349
+ databaseId
350
+ );
351
+ }
352
+
353
+ return (await updateResponse.json()) as CloudflareD1Response;
354
+ }
@@ -1,13 +1,16 @@
1
1
  export * from "./account-api-token";
2
2
  export * from "./api";
3
+ export * from "./api-error";
3
4
  export * from "./assets";
4
5
  export * from "./bindings";
5
6
  export * from "./bucket";
6
7
  export * from "./custom-domain";
8
+ export * from "./d1-database";
7
9
  export * from "./dns-records";
8
10
  export * from "./durable-object-namespace";
9
11
  export * from "./kv-namespace";
10
12
  export * from "./permission-groups";
13
+ export * from "./queue";
11
14
  export * from "./r2-rest-state-store";
12
15
  export * from "./worker";
13
16
  export { Workflow } from "./workflow";
@@ -8,12 +8,6 @@ import {
8
8
  } from "./api";
9
9
  import { handleApiError } from "./api-error";
10
10
 
11
- export function isKVNamespace(resource: any): resource is KVNamespace {
12
- return (
13
- resource && typeof resource === "object" && resource.type === "kv_namespace"
14
- );
15
- }
16
-
17
11
  /**
18
12
  * Properties for creating or updating a KV Namespace
19
13
  */