alchemy 0.22.4 → 0.23.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 (160) hide show
  1. package/lib/aws/bucket.d.ts.map +1 -1
  2. package/lib/aws/bucket.js +13 -12
  3. package/lib/aws/bucket.js.map +1 -1
  4. package/lib/aws/control/client.d.ts.map +1 -1
  5. package/lib/aws/control/client.js +5 -3
  6. package/lib/aws/control/client.js.map +1 -1
  7. package/lib/aws/control/resource.d.ts.map +1 -1
  8. package/lib/aws/control/resource.js +13 -4
  9. package/lib/aws/control/resource.js.map +1 -1
  10. package/lib/aws/control/types.d.ts +85129 -0
  11. package/lib/aws/function.d.ts.map +1 -1
  12. package/lib/aws/function.js +51 -42
  13. package/lib/aws/function.js.map +1 -1
  14. package/lib/aws/index.d.ts +1 -0
  15. package/lib/aws/index.d.ts.map +1 -1
  16. package/lib/aws/index.js +1 -0
  17. package/lib/aws/index.js.map +1 -1
  18. package/lib/aws/policy-attachment.d.ts.map +1 -1
  19. package/lib/aws/policy-attachment.js +5 -4
  20. package/lib/aws/policy-attachment.js.map +1 -1
  21. package/lib/aws/policy.d.ts.map +1 -1
  22. package/lib/aws/policy.js +21 -20
  23. package/lib/aws/policy.js.map +1 -1
  24. package/lib/aws/queue.d.ts.map +1 -1
  25. package/lib/aws/queue.js +19 -15
  26. package/lib/aws/queue.js.map +1 -1
  27. package/lib/aws/retry.d.ts +5 -0
  28. package/lib/aws/retry.d.ts.map +1 -0
  29. package/lib/aws/retry.js +27 -0
  30. package/lib/aws/retry.js.map +1 -0
  31. package/lib/aws/role.d.ts.map +1 -1
  32. package/lib/aws/role.js +31 -30
  33. package/lib/aws/role.js.map +1 -1
  34. package/lib/aws/ses.d.ts.map +1 -1
  35. package/lib/aws/ses.js +27 -26
  36. package/lib/aws/ses.js.map +1 -1
  37. package/lib/aws/ssm-parameter.d.ts +150 -0
  38. package/lib/aws/ssm-parameter.d.ts.map +1 -0
  39. package/lib/aws/ssm-parameter.js +184 -0
  40. package/lib/aws/ssm-parameter.js.map +1 -0
  41. package/lib/aws/table.d.ts.map +1 -1
  42. package/lib/aws/table.js +9 -27
  43. package/lib/aws/table.js.map +1 -1
  44. package/lib/cloudflare/account-id.d.ts +1 -0
  45. package/lib/cloudflare/account-id.d.ts.map +1 -1
  46. package/lib/cloudflare/account-id.js.map +1 -1
  47. package/lib/cloudflare/api.d.ts.map +1 -1
  48. package/lib/cloudflare/api.js +22 -2
  49. package/lib/cloudflare/api.js.map +1 -1
  50. package/lib/cloudflare/index.d.ts +2 -1
  51. package/lib/cloudflare/index.d.ts.map +1 -1
  52. package/lib/cloudflare/index.js +2 -1
  53. package/lib/cloudflare/index.js.map +1 -1
  54. package/lib/cloudflare/queue.d.ts.map +1 -1
  55. package/lib/cloudflare/queue.js +1 -0
  56. package/lib/cloudflare/queue.js.map +1 -1
  57. package/lib/cloudflare/route.d.ts +7 -0
  58. package/lib/cloudflare/route.d.ts.map +1 -1
  59. package/lib/cloudflare/route.js +44 -2
  60. package/lib/cloudflare/route.js.map +1 -1
  61. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  62. package/lib/cloudflare/worker-metadata.js +1 -2
  63. package/lib/cloudflare/worker-metadata.js.map +1 -1
  64. package/lib/cloudflare/worker.d.ts +2 -1
  65. package/lib/cloudflare/worker.d.ts.map +1 -1
  66. package/lib/cloudflare/worker.js +17 -8
  67. package/lib/cloudflare/worker.js.map +1 -1
  68. package/lib/cloudflare/workflow.d.ts +7 -0
  69. package/lib/cloudflare/workflow.d.ts.map +1 -1
  70. package/lib/cloudflare/workflow.js +2 -0
  71. package/lib/cloudflare/workflow.js.map +1 -1
  72. package/lib/cloudflare/wrangler.json.d.ts +1 -0
  73. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  74. package/lib/cloudflare/wrangler.json.js +1 -0
  75. package/lib/cloudflare/wrangler.json.js.map +1 -1
  76. package/lib/destroy.js +1 -1
  77. package/lib/destroy.js.map +1 -1
  78. package/lib/dns/godaddy.d.ts.map +1 -1
  79. package/lib/dns/godaddy.js +2 -1
  80. package/lib/dns/godaddy.js.map +1 -1
  81. package/lib/fs/file-system-state-store.d.ts.map +1 -1
  82. package/lib/fs/file-system-state-store.js +14 -2
  83. package/lib/fs/file-system-state-store.js.map +1 -1
  84. package/lib/github/secret.d.ts.map +1 -1
  85. package/lib/github/secret.js +1 -0
  86. package/lib/github/secret.js.map +1 -1
  87. package/lib/neon/api.d.ts.map +1 -1
  88. package/lib/neon/api.js +2 -1
  89. package/lib/neon/api.js.map +1 -1
  90. package/lib/runtime/shims.d.ts +6 -0
  91. package/lib/runtime/shims.d.ts.map +1 -0
  92. package/lib/runtime/shims.js +27 -0
  93. package/lib/runtime/shims.js.map +1 -0
  94. package/lib/sentry/api.d.ts.map +1 -1
  95. package/lib/sentry/api.js +2 -1
  96. package/lib/sentry/api.js.map +1 -1
  97. package/lib/test/bun.d.ts.map +1 -1
  98. package/lib/test/bun.js +1 -7
  99. package/lib/test/bun.js.map +1 -1
  100. package/lib/test/prune.d.ts +2 -1
  101. package/lib/test/prune.d.ts.map +1 -1
  102. package/lib/test/prune.js +29 -5
  103. package/lib/test/prune.js.map +1 -1
  104. package/lib/test/vitest.d.ts +92 -0
  105. package/lib/test/vitest.d.ts.map +1 -0
  106. package/lib/test/vitest.js +94 -0
  107. package/lib/test/vitest.js.map +1 -0
  108. package/lib/upstash/api.d.ts.map +1 -1
  109. package/lib/upstash/api.js +2 -1
  110. package/lib/upstash/api.js.map +1 -1
  111. package/lib/util/retry.js +2 -2
  112. package/lib/util/retry.js.map +1 -1
  113. package/lib/util/safe-fetch.d.ts +2 -0
  114. package/lib/util/safe-fetch.d.ts.map +1 -0
  115. package/lib/util/safe-fetch.js +48 -0
  116. package/lib/util/safe-fetch.js.map +1 -0
  117. package/lib/vercel/api.d.ts.map +1 -1
  118. package/lib/vercel/api.js +2 -1
  119. package/lib/vercel/api.js.map +1 -1
  120. package/lib/web/vitepress/process-front-matter-files.d.ts.map +1 -1
  121. package/lib/web/vitepress/process-front-matter-files.js +11 -7
  122. package/lib/web/vitepress/process-front-matter-files.js.map +1 -1
  123. package/package.json +10 -6
  124. package/src/alchemy.ts +2 -2
  125. package/src/aws/bucket.ts +55 -36
  126. package/src/aws/control/client.ts +13 -13
  127. package/src/aws/control/resource.ts +19 -3
  128. package/src/aws/function.ts +233 -178
  129. package/src/aws/index.ts +1 -0
  130. package/src/aws/policy-attachment.ts +15 -10
  131. package/src/aws/policy.ts +75 -54
  132. package/src/aws/queue.ts +53 -36
  133. package/src/aws/retry.ts +38 -0
  134. package/src/aws/role.ts +121 -90
  135. package/src/aws/ses.ts +101 -73
  136. package/src/aws/ssm-parameter.ts +315 -0
  137. package/src/aws/table.ts +61 -90
  138. package/src/cloudflare/account-id.ts +2 -0
  139. package/src/cloudflare/api.ts +26 -3
  140. package/src/cloudflare/index.ts +2 -1
  141. package/src/cloudflare/queue.ts +2 -0
  142. package/src/cloudflare/route.ts +86 -2
  143. package/src/cloudflare/worker-metadata.ts +1 -2
  144. package/src/cloudflare/worker.ts +21 -9
  145. package/src/cloudflare/workflow.ts +8 -0
  146. package/src/cloudflare/wrangler.json.ts +8 -1
  147. package/src/destroy.ts +1 -1
  148. package/src/dns/godaddy.ts +3 -1
  149. package/src/fs/file-system-state-store.ts +16 -2
  150. package/src/github/secret.ts +2 -0
  151. package/src/neon/api.ts +2 -1
  152. package/src/sentry/api.ts +2 -1
  153. package/src/test/bun.ts +1 -7
  154. package/src/test/prune.ts +31 -4
  155. package/src/test/vitest.ts +209 -0
  156. package/src/upstash/api.ts +2 -1
  157. package/src/util/retry.ts +3 -3
  158. package/src/util/safe-fetch.ts +61 -0
  159. package/src/vercel/api.ts +2 -1
  160. package/src/web/vitepress/process-front-matter-files.ts +15 -7
@@ -28,6 +28,14 @@ export interface RouteProps extends CloudflareApiOptions {
28
28
  * Zone ID for the route
29
29
  */
30
30
  zoneId: string;
31
+
32
+ /**
33
+ * Whether to adopt an existing route with the same pattern if it exists
34
+ * If true and a route with the same pattern exists, it will be adopted rather than creating a new one
35
+ *
36
+ * @default false
37
+ */
38
+ adopt?: boolean;
31
39
  }
32
40
 
33
41
  /**
@@ -133,8 +141,44 @@ export const Route = Resource(
133
141
  } else {
134
142
  console.log("Creating Route:", props.pattern);
135
143
 
136
- // Create new route
137
- routeData = await createRoute(api, zoneId, props.pattern, scriptName);
144
+ try {
145
+ // Create new route
146
+ routeData = await createRoute(api, zoneId, props.pattern, scriptName);
147
+ } catch (error) {
148
+ // Check if this is a "route already exists" error and adopt is enabled
149
+ if (
150
+ props.adopt &&
151
+ error instanceof CloudflareApiError &&
152
+ error.status === 409
153
+ ) {
154
+ console.log(
155
+ `Route with pattern '${props.pattern}' already exists, adopting it`,
156
+ );
157
+ // Find the existing route by pattern
158
+ const existingRoute = await findRouteByPattern(
159
+ api,
160
+ zoneId,
161
+ props.pattern,
162
+ );
163
+
164
+ if (!existingRoute) {
165
+ throw new Error(
166
+ `Failed to find existing route with pattern '${props.pattern}' for adoption`,
167
+ );
168
+ }
169
+
170
+ // Use the existing route data
171
+ routeData = {
172
+ result: existingRoute,
173
+ success: true,
174
+ errors: [],
175
+ messages: [],
176
+ };
177
+ } else {
178
+ // Re-throw the error if adopt is false or it's not a 409 error
179
+ throw error;
180
+ }
181
+ }
138
182
  }
139
183
 
140
184
  // Return the route resource
@@ -266,3 +310,43 @@ export async function listRoutes(
266
310
 
267
311
  return (await response.json()) as CloudflareRouteResponse;
268
312
  }
313
+
314
+ /**
315
+ * Find a route by pattern
316
+ */
317
+ async function findRouteByPattern(
318
+ api: CloudflareApi,
319
+ zoneId: string,
320
+ pattern: string,
321
+ ): Promise<{ id: string; pattern: string; script: string } | null> {
322
+ const response = await api.get(`/zones/${zoneId}/workers/routes`);
323
+
324
+ if (!response.ok) {
325
+ throw new CloudflareApiError(
326
+ `Failed to list routes: ${response.statusText}`,
327
+ response,
328
+ );
329
+ }
330
+
331
+ const data = (await response.json()) as {
332
+ result: Array<{
333
+ id: string;
334
+ pattern: string;
335
+ script: string;
336
+ }>;
337
+ success: boolean;
338
+ errors: Array<{ code: number; message: string }>;
339
+ messages: string[];
340
+ };
341
+
342
+ if (!data.success) {
343
+ throw new CloudflareApiError(
344
+ `Failed to list routes: ${data.errors?.[0]?.message || "Unknown error"}`,
345
+ response,
346
+ );
347
+ }
348
+
349
+ // Look for a route with matching pattern
350
+ const match = data.result.find((route) => route.pattern === pattern);
351
+ return match || null;
352
+ }
@@ -403,8 +403,7 @@ export async function prepareWorkerMetadata<B extends Bindings>(
403
403
  name: bindingName,
404
404
  workflow_name: binding.workflowName,
405
405
  class_name: binding.className,
406
- // this should be set if the Workflow is in another script ...
407
- // script_name: ??,
406
+ script_name: binding.scriptName,
408
407
  });
409
408
  // it's unclear whether this is needed, but it works both ways
410
409
  // configureClassMigration(binding, binding.id, binding.className);
@@ -58,7 +58,7 @@ import {
58
58
  } from "./worker-metadata.ts";
59
59
  import type { SingleStepMigration } from "./worker-migration.ts";
60
60
  import { WorkerStub, isWorkerStub } from "./worker-stub.ts";
61
- import { type Workflow, upsertWorkflow } from "./workflow.ts";
61
+ import { Workflow, isWorkflow, upsertWorkflow } from "./workflow.ts";
62
62
 
63
63
  /**
64
64
  * Configuration options for static assets
@@ -351,6 +351,8 @@ export type Worker<
351
351
  /**
352
352
  * The worker's URL if enabled
353
353
  * Format: {name}.{subdomain}.workers.dev
354
+ *
355
+ * @default true
354
356
  */
355
357
  url?: string;
356
358
 
@@ -391,7 +393,6 @@ export type Worker<
391
393
  * const api = await Worker("api", {
392
394
  * name: "api-worker",
393
395
  * entrypoint: "./src/api.ts",
394
- * routes: ["api.example.com/*"],
395
396
  * url: true
396
397
  * });
397
398
  *
@@ -802,11 +803,16 @@ export const _Worker = Resource(
802
803
  await putWorker(api, workerName, scriptBundle, scriptMetadata);
803
804
 
804
805
  for (const workflow of workflowsBindings) {
805
- await upsertWorkflow(api, {
806
- workflowName: workflow.workflowName,
807
- className: workflow.className,
808
- scriptName: workerName,
809
- });
806
+ if (
807
+ workflow.scriptName === undefined ||
808
+ workflow.scriptName === workerName
809
+ ) {
810
+ await upsertWorkflow(api, {
811
+ workflowName: workflow.workflowName,
812
+ className: workflow.className,
813
+ scriptName: workflow.scriptName ?? workerName,
814
+ });
815
+ }
810
816
  }
811
817
 
812
818
  await Promise.all(
@@ -833,7 +839,7 @@ export const _Worker = Resource(
833
839
  this,
834
840
  api,
835
841
  workerName,
836
- props.url ?? false,
842
+ props.url ?? true,
837
843
  );
838
844
 
839
845
  // Get current timestamp
@@ -922,7 +928,13 @@ export const _Worker = Resource(
922
928
  // re-export this binding mapping to the host worker (this worker)
923
929
  scriptName: workerName,
924
930
  })
925
- : binding,
931
+ : isWorkflow(binding) && binding.scriptName === undefined
932
+ ? new Workflow(binding.id, {
933
+ ...binding,
934
+ // re-export this binding mapping to the host worker (this worker)
935
+ scriptName: workerName,
936
+ })
937
+ : binding,
926
938
  ],
927
939
  ),
928
940
  );
@@ -19,6 +19,12 @@ export interface WorkflowProps {
19
19
  * @default - workflowName if provided, otherwise id
20
20
  */
21
21
  className?: string;
22
+ /**
23
+ * Name of the script containing the workflow implementation
24
+ *
25
+ * @default - bound worker script
26
+ */
27
+ scriptName?: string;
22
28
  }
23
29
 
24
30
  export function isWorkflow(binding: Binding): binding is Workflow {
@@ -36,6 +42,7 @@ export class Workflow<PARAMS = unknown> {
36
42
 
37
43
  public readonly workflowName: string;
38
44
  public readonly className: string;
45
+ public readonly scriptName?: string;
39
46
 
40
47
  constructor(
41
48
  public readonly id: string,
@@ -43,6 +50,7 @@ export class Workflow<PARAMS = unknown> {
43
50
  ) {
44
51
  this.workflowName = props.workflowName ?? props.className ?? id;
45
52
  this.className = props.className ?? this.workflowName;
53
+ this.scriptName = props.scriptName;
46
54
  }
47
55
  }
48
56
 
@@ -264,6 +264,7 @@ export interface WranglerJsonSpec {
264
264
  name: string;
265
265
  binding: string;
266
266
  class_name: string;
267
+ script_name?: string;
267
268
  }[];
268
269
 
269
270
  /**
@@ -369,7 +370,12 @@ function processBindings(
369
370
  const services: { binding: string; service: string; environment?: string }[] =
370
371
  [];
371
372
  const secrets: string[] = [];
372
- const workflows: { name: string; binding: string; class_name: string }[] = [];
373
+ const workflows: {
374
+ name: string;
375
+ binding: string;
376
+ class_name: string;
377
+ script_name?: string;
378
+ }[] = [];
373
379
  const d1Databases: {
374
380
  binding: string;
375
381
  database_id: string;
@@ -484,6 +490,7 @@ function processBindings(
484
490
  name: binding.workflowName,
485
491
  binding: bindingName,
486
492
  class_name: binding.className,
493
+ script_name: binding.scriptName,
487
494
  });
488
495
  } else if (binding.type === "d1") {
489
496
  d1Databases.push({
package/src/destroy.ts CHANGED
@@ -72,7 +72,7 @@ export async function destroy<Type extends string>(
72
72
  const Provider = resolveDeletionHandler(instance[ResourceKind]);
73
73
  if (!Provider) {
74
74
  throw new Error(
75
- `Cannot destroy resource "${instance[ResourceFQN]}" type ${instance[ResourceKind]} - no provider found. You may need to import the provider in your alchemy.config.ts.`,
75
+ `Cannot destroy resource "${instance[ResourceFQN]}" type ${instance[ResourceKind]} - no provider found. You may need to import the provider in your alchemy.run.ts.`,
76
76
  );
77
77
  }
78
78
 
@@ -1,3 +1,5 @@
1
+ import { safeFetch } from "../util/safe-fetch.ts";
2
+
1
3
  export type UpdateNameserversOptions = {
2
4
  domain: string;
3
5
  apiKey: string;
@@ -8,7 +10,7 @@ export type UpdateNameserversOptions = {
8
10
  export async function updateNameservers(options: UpdateNameserversOptions) {
9
11
  const url = `https://api.godaddy.com/v1/domains/${options.domain}/nameservers`;
10
12
 
11
- const response = await fetch(url, {
13
+ const response = await safeFetch(url, {
12
14
  method: "PUT",
13
15
  headers: {
14
16
  Authorization: `sso-key ${options.apiKey}:${options.apiSecret}`,
@@ -24,7 +24,19 @@ export class FileSystemStateStore implements StateStore {
24
24
  }
25
25
 
26
26
  async deinit(): Promise<void> {
27
- await ignore("ENOENT", () => fs.promises.rmdir(this.dir));
27
+ await ignore("ENOENT", async () => {
28
+ const entries = await fs.promises.readdir(this.dir, {
29
+ withFileTypes: true,
30
+ });
31
+ const files = entries.filter((entry) => entry.isFile());
32
+ if (files.length > 0) {
33
+ throw new Error(
34
+ `Cannot deinit: directory "${this.dir}" contains files and cannot be deleted.`,
35
+ );
36
+ }
37
+ // Only folders (or empty), delete recursively
38
+ await fs.promises.rm(this.dir, { recursive: true, force: true });
39
+ });
28
40
  }
29
41
 
30
42
  async count(): Promise<number> {
@@ -67,8 +79,10 @@ export class FileSystemStateStore implements StateStore {
67
79
 
68
80
  async set(key: string, value: State): Promise<void> {
69
81
  await this.init();
82
+ const file = this.getPath(key);
83
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
70
84
  await fs.promises.writeFile(
71
- this.getPath(key),
85
+ file,
72
86
  JSON.stringify(await serialize(this.scope, value), null, 2),
73
87
  );
74
88
  }
@@ -224,6 +224,8 @@ export const GitHubSecret = Resource(
224
224
  }
225
225
  }
226
226
 
227
+ await new Promise((resolve) => setTimeout(resolve, 1000));
228
+
227
229
  let publicKey;
228
230
 
229
231
  // Get the appropriate public key for encrypting secrets
package/src/neon/api.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Secret } from "../secret.ts";
2
2
  import { withExponentialBackoff } from "../util/retry.ts";
3
+ import { safeFetch } from "../util/safe-fetch.ts";
3
4
 
4
5
  /**
5
6
  * Options for Neon API requests
@@ -98,7 +99,7 @@ export class NeonApi {
98
99
  // Use withExponentialBackoff for automatic retry on network errors
99
100
  return withExponentialBackoff(
100
101
  () =>
101
- fetch(`${this.baseUrl}${path}`, {
102
+ safeFetch(`${this.baseUrl}${path}`, {
102
103
  ...init,
103
104
  headers,
104
105
  }),
package/src/sentry/api.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { alchemy } from "../alchemy.ts";
2
2
  import type { Secret } from "../secret.ts";
3
+ import { safeFetch } from "../util/safe-fetch.ts";
3
4
 
4
5
  /**
5
6
  * Options for Sentry API requests
@@ -60,7 +61,7 @@ export class SentryApi {
60
61
  delete headers["Content-Type"];
61
62
  }
62
63
 
63
- return fetch(`${this.baseUrl}${path}`, {
64
+ return safeFetch(`${this.baseUrl}${path}`, {
64
65
  ...init,
65
66
  headers,
66
67
  });
package/src/test/bun.ts CHANGED
@@ -131,13 +131,7 @@ export function test(meta: ImportMeta, defaultOptions?: TestOptions): test {
131
131
  }
132
132
 
133
133
  // Add skipIf functionality
134
- test.skipIf = (condition: boolean) => {
135
- if (condition) {
136
- // TODO: proxy through to bun:test.skipIf
137
- return (..._args: any[]) => {};
138
- }
139
- return test;
140
- };
134
+ test.skipIf = it.skipIf.bind(it);
141
135
 
142
136
  // Create local test scope based on filename
143
137
  const scope = new Scope({
package/src/test/prune.ts CHANGED
@@ -10,23 +10,50 @@ const execAsync = promisify(exec);
10
10
  * Runs only tests that have changed dependencies
11
11
  * @param directory Directory to scan for test files
12
12
  * @param baseCommit Optional base commit to compare against
13
+ * @param useVitest Whether to use vitest instead of bun test
13
14
  */
14
15
  export async function runChangedTests(
15
16
  directory: string,
16
17
  baseCommit?: string,
18
+ useVitest?: boolean,
17
19
  ): Promise<void> {
18
20
  const changedTests = await findChangedTestFiles(directory, baseCommit);
19
21
 
22
+ // Always include smoke test
23
+ const smokeTestPath = "alchemy/test/smoke.test.ts";
24
+ if (!changedTests.includes(smokeTestPath)) {
25
+ changedTests.unshift(smokeTestPath); // Add at the beginning
26
+ }
27
+
28
+ if (process.env.CI) {
29
+ // these tests are being really weird in GitHub CI.
30
+ // not the priority to test in CI
31
+ const testsToRemove = [
32
+ "alchemy/test/cloudflare/dns-records.test.ts",
33
+ "alchemy/test/cloudflare/route.test.ts",
34
+ ];
35
+ for (const test of testsToRemove) {
36
+ const idx = changedTests.indexOf(test);
37
+ if (idx !== -1) {
38
+ changedTests.splice(idx, 1);
39
+ }
40
+ }
41
+ }
42
+
20
43
  if (changedTests.length === 0) {
21
44
  console.log("No tests affected by recent changes.");
22
45
  return;
23
46
  }
24
47
 
25
- // Run the tests with bun using spawn for stdio inheritance
48
+ // Run the tests using spawn for stdio inheritance
26
49
  return new Promise<void>((resolve, reject) => {
27
- console.log(`bun test ${changedTests.join(" ")}`);
50
+ const command = useVitest ? "vitest" : "bun";
51
+ const args = useVitest
52
+ ? ["run", ...changedTests]
53
+ : ["test", ...changedTests];
54
+ console.log(`${command} ${args.join(" ")}`);
28
55
  // resolve();
29
- const child = spawn("bun", ["test", ...changedTests], { stdio: "inherit" });
56
+ const child = spawn(command, args, { stdio: "inherit" });
30
57
 
31
58
  child.on("close", (code) => {
32
59
  if (code === 0) resolve();
@@ -129,7 +156,7 @@ async function getDependencies(testFile: string): Promise<Set<string>> {
129
156
  format: "esm",
130
157
  bundle: true,
131
158
  metafile: true,
132
- external: ["@cloudflare/workers-types", "bun:test", "@swc/*"],
159
+ external: ["@cloudflare/workers-types", "bun:test", "vitest", "@swc/*"],
133
160
  logLevel: "error",
134
161
  });
135
162
 
@@ -0,0 +1,209 @@
1
+ import path from "node:path";
2
+ import { afterAll, beforeAll, it } from "vitest";
3
+ import { alchemy } from "../alchemy.ts";
4
+ import { R2RestStateStore } from "../cloudflare/r2-rest-state-store.ts";
5
+ import { Scope } from "../scope.ts";
6
+ import type { StateStoreType } from "../state.ts";
7
+
8
+ /**
9
+ * Extend the Alchemy interface to include test functionality
10
+ */
11
+ declare module "../alchemy.ts" {
12
+ interface Alchemy {
13
+ test: typeof test;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Add test functionality to alchemy instance
19
+ */
20
+ alchemy.test = test;
21
+
22
+ /**
23
+ * Options for configuring test behavior
24
+ */
25
+ export interface TestOptions {
26
+ /**
27
+ * Whether to suppress logging output.
28
+ * @default false.
29
+ */
30
+ quiet?: boolean;
31
+
32
+ /**
33
+ * Password to use for test resources.
34
+ * @default "test-password".
35
+ */
36
+ password?: string;
37
+
38
+ /**
39
+ * Override the default state store for the test.
40
+ */
41
+ stateStore?: StateStoreType;
42
+
43
+ /**
44
+ * Prefix to use for the scope to isolate tests and environments.
45
+ */
46
+ prefix?: string;
47
+ }
48
+
49
+ /**
50
+ * Test function type definition with overloads
51
+ */
52
+ type test = {
53
+ /**
54
+ * Create a test with default options
55
+ * @param name Test name
56
+ * @param fn Test function
57
+ * @param timeout Optional timeout in milliseconds
58
+ */
59
+ (name: string, fn: (scope: Scope) => Promise<any>, timeout?: number): void;
60
+
61
+ /**
62
+ * Create a test with custom options
63
+ * @param name Test name
64
+ * @param options Test configuration options
65
+ * @param fn Test function
66
+ * @param timeout Optional timeout in milliseconds
67
+ */
68
+ (
69
+ name: string,
70
+ options: TestOptions,
71
+ fn: (scope: Scope) => Promise<any>,
72
+ timeout?: number,
73
+ ): void;
74
+
75
+ /**
76
+ * Skip test conditionally
77
+ * @param condition If true, test will be skipped
78
+ */
79
+ skipIf(condition: boolean): test;
80
+
81
+ beforeAll(fn: (scope: Scope) => Promise<void>): void;
82
+
83
+ afterAll(fn: (scope: Scope) => Promise<void>): void;
84
+
85
+ /**
86
+ * Current test scope
87
+ */
88
+ scope: Scope;
89
+ };
90
+
91
+ /**
92
+ * Creates a test helper function that provides scoped resource management
93
+ *
94
+ * @param meta Import meta object from the test file
95
+ * @param defaultOptions Default options to apply to all tests
96
+ * @returns Test function with scope management
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * const test = alchemy.test(import.meta, {
101
+ prefix: BRANCH_PREFIX
102
+ });
103
+ *
104
+ * describe("My Resource", () => {
105
+ * test("create and delete", async (scope) => {
106
+ * try {
107
+ * const resource = await MyResource("test", { ... });
108
+ * expect(resource.id).toBeTruthy();
109
+ * } finally {
110
+ * await alchemy.destroy(scope);
111
+ * }
112
+ * });
113
+ * });
114
+ * ```
115
+ */
116
+ export function test(meta: ImportMeta, defaultOptions?: TestOptions): test {
117
+ defaultOptions = defaultOptions ?? {};
118
+ if (
119
+ defaultOptions.stateStore === undefined &&
120
+ process.env.ALCHEMY_STATE_STORE === "cloudflare"
121
+ ) {
122
+ defaultOptions.stateStore = (scope) =>
123
+ new R2RestStateStore(scope, {
124
+ apiKey: alchemy.secret(process.env.CLOUDFLARE_API_KEY),
125
+ email: process.env.CLOUDFLARE_EMAIL,
126
+ bucketName: process.env.CLOUDFLARE_BUCKET_NAME!,
127
+ });
128
+ }
129
+
130
+ test.skipIf = (condition: boolean) => {
131
+ if (condition) {
132
+ return (..._args: any[]) => {};
133
+ }
134
+ return test;
135
+ };
136
+
137
+ const scope = new Scope({
138
+ scopeName: `${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`,
139
+ stateStore: defaultOptions?.stateStore,
140
+ phase: "up",
141
+ });
142
+
143
+ test.beforeAll = (fn: (scope: Scope) => Promise<void>) => {
144
+ return beforeAll(() => scope.run(() => fn(scope)));
145
+ };
146
+
147
+ test.afterAll = (fn: (scope: Scope) => Promise<void>) => {
148
+ return afterAll(() => scope.run(() => fn(scope)));
149
+ };
150
+
151
+ return test as test;
152
+
153
+ function test(
154
+ ...args:
155
+ | [
156
+ name: string,
157
+ options: TestOptions,
158
+ fn: (scope: Scope) => Promise<void>,
159
+ ]
160
+ | [name: string, fn: (scope: Scope) => Promise<void>]
161
+ ) {
162
+ const testName = args[0];
163
+ const _options = typeof args[1] === "object" ? args[1] : undefined;
164
+ const timeout =
165
+ typeof args[args.length - 1] === "number"
166
+ ? (args[args.length - 1] as number)
167
+ : 120000;
168
+ const spread = (obj: any) =>
169
+ obj && typeof obj === "object"
170
+ ? Object.fromEntries(
171
+ Object.entries(obj).flatMap(([k, v]) =>
172
+ v !== undefined ? [[k, v]] : [],
173
+ ),
174
+ )
175
+ : {};
176
+
177
+ const options: TestOptions = {
178
+ quiet: false,
179
+ password: "test-password",
180
+ ...spread(defaultOptions),
181
+ ...spread(_options),
182
+ };
183
+
184
+ const fn = typeof args[1] === "function" ? args[1] : args[2]!;
185
+
186
+ return it(
187
+ testName,
188
+ (ctx) => {
189
+ // Get the current describe block name from the test context
190
+ let describeBlockName = "";
191
+ if (ctx.task.suite?.name) {
192
+ describeBlockName = `${ctx.task.suite.name}/`;
193
+ }
194
+
195
+ return alchemy.run(
196
+ `${describeBlockName}${testName}`,
197
+ {
198
+ ...options,
199
+ parent: scope,
200
+ },
201
+ async (scope) => {
202
+ await scope.run(() => fn(scope));
203
+ },
204
+ );
205
+ },
206
+ timeout,
207
+ );
208
+ }
209
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Secret } from "../secret.ts";
2
+ import { safeFetch } from "../util/safe-fetch.ts";
2
3
 
3
4
  /**
4
5
  * Options for Upstash API requests
@@ -68,7 +69,7 @@ export class UpstashApi {
68
69
  });
69
70
  }
70
71
 
71
- return fetch(`${this.baseUrl}${path}`, {
72
+ return safeFetch(`${this.baseUrl}${path}`, {
72
73
  ...init,
73
74
  headers,
74
75
  });
package/src/util/retry.ts CHANGED
@@ -21,11 +21,10 @@ export async function withExponentialBackoff<T>(
21
21
  while (true) {
22
22
  try {
23
23
  return await operation();
24
- } catch (error) {
25
- console.log("error", error);
24
+ } catch (error: any) {
25
+ console.log(error.message);
26
26
  attempt++;
27
27
  if (attempt >= maxAttempts || !isRetryable(error)) {
28
- console.warn("Failed after ", attempt, " attempts");
29
28
  throw error;
30
29
  }
31
30
 
@@ -33,6 +32,7 @@ export async function withExponentialBackoff<T>(
33
32
  const jitter = Math.random() * 0.1 * delay;
34
33
  await new Promise((resolve) => setTimeout(resolve, delay + jitter));
35
34
  delay *= 2; // Double the delay for next attempt
35
+ delay = Math.min(delay, 10000); // Cap at 10 seconds
36
36
  }
37
37
  }
38
38
  }