alchemy 0.37.2 → 0.38.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 (255) hide show
  1. package/bin/alchemy.mjs +44306 -48055
  2. package/bin/alchemy.ts +80 -85
  3. package/bin/commands/create.ts +263 -0
  4. package/bin/constants.ts +13 -0
  5. package/bin/errors.ts +11 -0
  6. package/bin/services/dependencies.ts +47 -0
  7. package/bin/services/get-package-version.ts +11 -0
  8. package/bin/services/package-manager.ts +113 -0
  9. package/bin/services/template-manager.ts +137 -0
  10. package/bin/types.ts +83 -0
  11. package/lib/alchemy.d.ts.map +1 -1
  12. package/lib/alchemy.js +12 -3
  13. package/lib/alchemy.js.map +1 -1
  14. package/lib/apply.d.ts +2 -0
  15. package/lib/apply.d.ts.map +1 -1
  16. package/lib/apply.js +47 -12
  17. package/lib/apply.js.map +1 -1
  18. package/lib/build-date.d.ts +6 -0
  19. package/lib/build-date.d.ts.map +1 -0
  20. package/lib/build-date.js +8 -0
  21. package/lib/build-date.js.map +1 -0
  22. package/lib/cloudflare/api.d.ts.map +1 -1
  23. package/lib/cloudflare/api.js +2 -1
  24. package/lib/cloudflare/api.js.map +1 -1
  25. package/lib/cloudflare/pipeline.d.ts +1 -1
  26. package/lib/cloudflare/pipeline.d.ts.map +1 -1
  27. package/lib/cloudflare/pipeline.js +6 -1
  28. package/lib/cloudflare/pipeline.js.map +1 -1
  29. package/lib/cloudflare/worker-stub.d.ts +23 -2
  30. package/lib/cloudflare/worker-stub.d.ts.map +1 -1
  31. package/lib/cloudflare/worker-stub.js +16 -2
  32. package/lib/cloudflare/worker-stub.js.map +1 -1
  33. package/lib/cloudflare/worker.d.ts +4 -3
  34. package/lib/cloudflare/worker.d.ts.map +1 -1
  35. package/lib/cloudflare/worker.js +2 -1
  36. package/lib/cloudflare/worker.js.map +1 -1
  37. package/lib/context.d.ts +2 -2
  38. package/lib/context.d.ts.map +1 -1
  39. package/lib/destroy.d.ts +8 -2
  40. package/lib/destroy.d.ts.map +1 -1
  41. package/lib/destroy.js +19 -5
  42. package/lib/destroy.js.map +1 -1
  43. package/lib/scope.d.ts +26 -4
  44. package/lib/scope.d.ts.map +1 -1
  45. package/lib/scope.js +104 -9
  46. package/lib/scope.js.map +1 -1
  47. package/lib/sentry/project.js +1 -1
  48. package/lib/sentry/project.js.map +1 -1
  49. package/lib/util/logger.js +1 -1
  50. package/lib/util/logger.js.map +1 -1
  51. package/lib/util/mutex.d.ts +9 -0
  52. package/lib/util/mutex.d.ts.map +1 -0
  53. package/lib/util/mutex.js +35 -0
  54. package/lib/util/mutex.js.map +1 -0
  55. package/package.json +16 -6
  56. package/src/alchemy.ts +17 -5
  57. package/src/apply.ts +67 -17
  58. package/src/build-date.ts +8 -0
  59. package/src/cloudflare/api.ts +2 -1
  60. package/src/cloudflare/pipeline.ts +6 -0
  61. package/src/cloudflare/worker-stub.ts +31 -2
  62. package/src/cloudflare/worker.ts +4 -3
  63. package/src/context.ts +2 -2
  64. package/src/destroy.ts +30 -7
  65. package/src/scope.ts +145 -10
  66. package/src/sentry/project.ts +1 -1
  67. package/src/util/logger.ts +1 -1
  68. package/src/util/mutex.ts +33 -0
  69. package/templates/astro/.vscode/extensions.json +4 -0
  70. package/templates/astro/.vscode/launch.json +11 -0
  71. package/templates/astro/README.md +48 -0
  72. package/templates/astro/_env.example +1 -0
  73. package/templates/astro/_gitignore +27 -0
  74. package/templates/astro/alchemy.run.ts +16 -0
  75. package/templates/astro/astro.config.mjs +8 -0
  76. package/templates/astro/package.json +23 -0
  77. package/templates/astro/public/favicon.svg +9 -0
  78. package/templates/astro/src/assets/astro.svg +1 -0
  79. package/templates/astro/src/assets/background.svg +1 -0
  80. package/templates/astro/src/components/Welcome.astro +210 -0
  81. package/templates/astro/src/layouts/Layout.astro +22 -0
  82. package/templates/astro/src/pages/api/hello.ts +19 -0
  83. package/templates/astro/src/pages/index.astro +11 -0
  84. package/templates/astro/tsconfig.json +18 -0
  85. package/templates/astro/types/env.d.ts +16 -0
  86. package/templates/astro/wrangler.jsonc +6 -0
  87. package/templates/nuxt/README.md +75 -0
  88. package/templates/nuxt/_env +1 -0
  89. package/templates/nuxt/_env.example +1 -0
  90. package/templates/nuxt/_gitignore +27 -0
  91. package/templates/nuxt/alchemy.run.ts +16 -0
  92. package/templates/nuxt/app.vue +6 -0
  93. package/templates/nuxt/nuxt.config.ts +13 -0
  94. package/templates/nuxt/package.json +26 -0
  95. package/templates/nuxt/public/favicon.ico +0 -0
  96. package/templates/nuxt/public/robots.txt +2 -0
  97. package/templates/nuxt/server/api/hello.ts +7 -0
  98. package/templates/nuxt/server/middleware/auth.ts +5 -0
  99. package/templates/nuxt/server/middleware/hello.ts +5 -0
  100. package/templates/nuxt/server/tsconfig.json +3 -0
  101. package/templates/nuxt/tsconfig.json +15 -0
  102. package/templates/nuxt/types/env.d.ts +16 -0
  103. package/templates/react-router/.vscode/settings.json +5 -0
  104. package/templates/react-router/README.md +79 -0
  105. package/templates/react-router/_env +1 -0
  106. package/templates/react-router/_env.example +1 -0
  107. package/templates/react-router/_gitignore +15 -0
  108. package/templates/react-router/alchemy.run.ts +17 -0
  109. package/templates/react-router/app/app.css +15 -0
  110. package/templates/react-router/app/entry.server.tsx +43 -0
  111. package/templates/react-router/app/root.tsx +75 -0
  112. package/templates/react-router/app/routes/home.tsx +17 -0
  113. package/templates/react-router/app/routes.ts +3 -0
  114. package/templates/react-router/app/welcome/logo-dark.svg +23 -0
  115. package/templates/react-router/app/welcome/logo-light.svg +23 -0
  116. package/templates/react-router/app/welcome/welcome.tsx +90 -0
  117. package/templates/react-router/package.json +34 -0
  118. package/templates/react-router/public/favicon.ico +0 -0
  119. package/templates/react-router/react-router.config.ts +8 -0
  120. package/templates/react-router/tsconfig.cloudflare.json +27 -0
  121. package/templates/react-router/tsconfig.json +17 -0
  122. package/templates/react-router/tsconfig.node.json +20 -0
  123. package/templates/react-router/types/env.d.ts +16 -0
  124. package/templates/react-router/vite.config.ts +14 -0
  125. package/templates/react-router/workers/app.ts +23 -0
  126. package/templates/react-router/wrangler.jsonc +6 -0
  127. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-interruptors.mdc +247 -0
  128. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-middleware.mdc +75 -0
  129. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-react.mdc +110 -0
  130. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-request-response.mdc +213 -0
  131. package/templates/rwsdk/.devcontainer/Dockerfile +8 -0
  132. package/templates/rwsdk/.devcontainer/devcontainer.json +21 -0
  133. package/templates/rwsdk/README.md +62 -0
  134. package/templates/rwsdk/_env +5 -0
  135. package/templates/rwsdk/_env.example +5 -0
  136. package/templates/rwsdk/_gitignore +71 -0
  137. package/templates/rwsdk/alchemy.run.ts +29 -0
  138. package/templates/rwsdk/package.json +61 -0
  139. package/templates/rwsdk/prisma/schema.prisma +43 -0
  140. package/templates/rwsdk/src/app/Document.tsx +16 -0
  141. package/templates/rwsdk/src/app/headers.ts +32 -0
  142. package/templates/rwsdk/src/app/pages/Home.tsx +13 -0
  143. package/templates/rwsdk/src/app/pages/user/Login.tsx +79 -0
  144. package/templates/rwsdk/src/app/pages/user/functions.ts +171 -0
  145. package/templates/rwsdk/src/app/pages/user/routes.ts +17 -0
  146. package/templates/rwsdk/src/app/shared/links.ts +3 -0
  147. package/templates/rwsdk/src/client.tsx +3 -0
  148. package/templates/rwsdk/src/db.ts +26 -0
  149. package/templates/rwsdk/src/scripts/seed.ts +21 -0
  150. package/templates/rwsdk/src/session/durableObject.ts +63 -0
  151. package/templates/rwsdk/src/session/store.ts +13 -0
  152. package/templates/rwsdk/src/worker.tsx +63 -0
  153. package/templates/rwsdk/tsconfig.json +47 -0
  154. package/templates/rwsdk/types/env.d.ts +16 -0
  155. package/templates/rwsdk/types/rw.d.ts +5 -0
  156. package/templates/rwsdk/types/vite.d.ts +4 -0
  157. package/templates/rwsdk/vite.config.mts +6 -0
  158. package/templates/rwsdk/wrangler.jsonc +28 -0
  159. package/templates/sveltekit/README.md +38 -0
  160. package/templates/sveltekit/_env +1 -0
  161. package/templates/sveltekit/_env.example +1 -0
  162. package/templates/sveltekit/_gitignore +26 -0
  163. package/templates/sveltekit/_npmrc +1 -0
  164. package/templates/sveltekit/_prettierrc +3 -0
  165. package/templates/sveltekit/alchemy.run.ts +16 -0
  166. package/templates/sveltekit/bun.lock +1099 -0
  167. package/templates/sveltekit/package.json +33 -0
  168. package/templates/sveltekit/src/app.css +1 -0
  169. package/templates/sveltekit/src/app.d.ts +13 -0
  170. package/templates/sveltekit/src/app.html +12 -0
  171. package/templates/sveltekit/src/routes/+layout.svelte +7 -0
  172. package/templates/sveltekit/src/routes/+page.svelte +2 -0
  173. package/templates/sveltekit/static/favicon.png +0 -0
  174. package/templates/sveltekit/svelte.config.js +12 -0
  175. package/templates/sveltekit/tsconfig.json +27 -0
  176. package/templates/sveltekit/types/env.d.ts +16 -0
  177. package/templates/sveltekit/vite.config.ts +6 -0
  178. package/templates/sveltekit/wrangler.jsonc +7 -0
  179. package/templates/tanstack-start/.vscode/settings.json +11 -0
  180. package/templates/tanstack-start/README.md +72 -0
  181. package/templates/tanstack-start/_env +1 -0
  182. package/templates/tanstack-start/_env.example +1 -0
  183. package/templates/tanstack-start/_gitignore +22 -0
  184. package/templates/tanstack-start/_prettierignore +4 -0
  185. package/templates/tanstack-start/alchemy.run.ts +16 -0
  186. package/templates/tanstack-start/package.json +37 -0
  187. package/templates/tanstack-start/public/android-chrome-192x192.png +0 -0
  188. package/templates/tanstack-start/public/android-chrome-512x512.png +0 -0
  189. package/templates/tanstack-start/public/apple-touch-icon.png +0 -0
  190. package/templates/tanstack-start/public/favicon-16x16.png +0 -0
  191. package/templates/tanstack-start/public/favicon-32x32.png +0 -0
  192. package/templates/tanstack-start/public/favicon.ico +0 -0
  193. package/templates/tanstack-start/public/favicon.png +0 -0
  194. package/templates/tanstack-start/public/site.webmanifest +19 -0
  195. package/templates/tanstack-start/src/components/DefaultCatchBoundary.tsx +53 -0
  196. package/templates/tanstack-start/src/components/NotFound.tsx +25 -0
  197. package/templates/tanstack-start/src/components/PostError.tsx +5 -0
  198. package/templates/tanstack-start/src/components/UserError.tsx +5 -0
  199. package/templates/tanstack-start/src/router.tsx +22 -0
  200. package/templates/tanstack-start/src/routes/__root.tsx +146 -0
  201. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-a.tsx +10 -0
  202. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-b.tsx +10 -0
  203. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout.tsx +34 -0
  204. package/templates/tanstack-start/src/routes/_pathlessLayout.tsx +16 -0
  205. package/templates/tanstack-start/src/routes/api/users.$userId.ts +28 -0
  206. package/templates/tanstack-start/src/routes/api/users.ts +59 -0
  207. package/templates/tanstack-start/src/routes/customScript[.]js.ts +10 -0
  208. package/templates/tanstack-start/src/routes/deferred.tsx +62 -0
  209. package/templates/tanstack-start/src/routes/index.tsx +12 -0
  210. package/templates/tanstack-start/src/routes/posts.$postId.tsx +34 -0
  211. package/templates/tanstack-start/src/routes/posts.index.tsx +8 -0
  212. package/templates/tanstack-start/src/routes/posts.tsx +38 -0
  213. package/templates/tanstack-start/src/routes/posts_.$postId.deep.tsx +29 -0
  214. package/templates/tanstack-start/src/routes/redirect.tsx +9 -0
  215. package/templates/tanstack-start/src/routes/users.$userId.tsx +44 -0
  216. package/templates/tanstack-start/src/routes/users.index.tsx +18 -0
  217. package/templates/tanstack-start/src/routes/users.tsx +49 -0
  218. package/templates/tanstack-start/src/styles/app.css +12 -0
  219. package/templates/tanstack-start/src/utils/loggingMiddleware.tsx +41 -0
  220. package/templates/tanstack-start/src/utils/posts.tsx +40 -0
  221. package/templates/tanstack-start/src/utils/seo.ts +33 -0
  222. package/templates/tanstack-start/src/utils/users.tsx +5 -0
  223. package/templates/tanstack-start/tsconfig.json +31 -0
  224. package/templates/tanstack-start/types/env.d.ts +16 -0
  225. package/templates/tanstack-start/vite.config.ts +33 -0
  226. package/templates/tanstack-start/wrangler.jsonc +7 -0
  227. package/templates/typescript/README.md +15 -0
  228. package/templates/typescript/_gitignore +37 -0
  229. package/templates/typescript/alchemy.run.ts +15 -0
  230. package/templates/typescript/index.ts +1 -0
  231. package/templates/typescript/package.json +18 -0
  232. package/templates/typescript/src/worker.js +5 -0
  233. package/templates/typescript/src/worker.ts +7 -0
  234. package/templates/typescript/tsconfig.json +21 -0
  235. package/templates/typescript/types/env.d.ts +16 -0
  236. package/templates/vite/README.md +54 -0
  237. package/templates/vite/_env.example +1 -0
  238. package/templates/vite/_gitignore +27 -0
  239. package/templates/vite/alchemy.run.ts +17 -0
  240. package/templates/vite/eslint.config.js +28 -0
  241. package/templates/vite/index.html +13 -0
  242. package/templates/vite/package.json +35 -0
  243. package/templates/vite/public/vite.svg +1 -0
  244. package/templates/vite/src/App.css +42 -0
  245. package/templates/vite/src/App.tsx +35 -0
  246. package/templates/vite/src/assets/react.svg +1 -0
  247. package/templates/vite/src/index.css +68 -0
  248. package/templates/vite/src/main.tsx +10 -0
  249. package/templates/vite/src/vite-env.d.ts +1 -0
  250. package/templates/vite/tsconfig.json +34 -0
  251. package/templates/vite/types/env.d.ts +16 -0
  252. package/templates/vite/vite.config.ts +9 -0
  253. package/templates/vite/worker/index.ts +12 -0
  254. package/templates/vite/wrangler.jsonc +6 -0
  255. package/bin/create-alchemy.ts +0 -1220
@@ -7,6 +7,7 @@ import {
7
7
  type CloudflareApi,
8
8
  type CloudflareApiOptions,
9
9
  } from "./api.ts";
10
+ import { configureURL } from "./worker.ts";
10
11
 
11
12
  /**
12
13
  * Properties for creating a Worker stub
@@ -19,6 +20,14 @@ export interface WorkerStubProps<
19
20
  */
20
21
  name: string;
21
22
 
23
+ /**
24
+ * Whether to enable a workers.dev URL for this worker
25
+ *
26
+ * If true, the worker will be available at {name}.{subdomain}.workers.dev
27
+ * @default true
28
+ */
29
+ url?: boolean;
30
+
22
31
  /**
23
32
  * The RPC class to use for the worker.
24
33
  *
@@ -39,6 +48,12 @@ export interface WorkerStub<
39
48
  */
40
49
  name: string;
41
50
 
51
+ /**
52
+ * The worker's URL if enabled
53
+ * Format: {name}.{subdomain}.workers.dev
54
+ */
55
+ url?: string;
56
+
42
57
  /**
43
58
  * Optional type branding for the worker's RPC entrypoint.
44
59
  *
@@ -59,12 +74,21 @@ export function isWorkerStub(resource: Resource): resource is WorkerStub {
59
74
  * exists and creates an empty one if needed.
60
75
  *
61
76
  * @example
62
- * // Reserve a worker name without deploying code
77
+ * // Reserve a worker name without deploying code, with URL enabled (default)
63
78
  * const workerStub = await WorkerStub("my-worker", {
64
79
  * name: "my-reserved-worker"
65
80
  * });
66
81
  *
67
- * console.log(`Worker ${workerStub.name} exists: ${!workerStub.created}`);
82
+ * console.log(`Worker ${workerStub.name} is available at: ${workerStub.url}`);
83
+ *
84
+ * @example
85
+ * // Reserve a worker name without enabling URL
86
+ * const workerStub = await WorkerStub("my-worker", {
87
+ * name: "my-reserved-worker",
88
+ * url: false
89
+ * });
90
+ *
91
+ * console.log(`Worker ${workerStub.name} created without URL`);
68
92
  */
69
93
  export const WorkerStub = Resource("cloudflare::WorkerStub", async function <
70
94
  RPC extends Rpc.WorkerEntrypointBranded = Rpc.WorkerEntrypointBranded,
@@ -84,11 +108,16 @@ export const WorkerStub = Resource("cloudflare::WorkerStub", async function <
84
108
  await createEmptyWorker(api, props.name);
85
109
  }
86
110
 
111
+ // Configure URL if requested (defaults to true)
112
+ const enableUrl = props.url ?? true;
113
+ const workerUrl = await configureURL(this, api, props.name, enableUrl);
114
+
87
115
  // Return the worker stub info
88
116
  return this({
89
117
  type: "service",
90
118
  __rpc__: props.rpc as unknown as RPC,
91
119
  ...props,
120
+ url: workerUrl,
92
121
  }) as WorkerStub<RPC>;
93
122
  });
94
123
 
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { BUILD_DATE } from "../build-date.ts";
2
3
  import type { Context } from "../context.ts";
3
4
  import type { BundleProps } from "../esbuild/bundle.ts";
4
5
  import { InnerResourceScope, Resource, ResourceKind } from "../resource.ts";
@@ -178,7 +179,7 @@ export interface BaseWorkerProps<
178
179
 
179
180
  /**
180
181
  * The compatibility date for the worker
181
- * @default "2025-04-26"
182
+ * @default BUILD_DATE - automatically pinned to the package build date
182
183
  */
183
184
  compatibilityDate?: string;
184
185
 
@@ -861,7 +862,7 @@ export function Worker<const B extends Bindings>(
861
862
  });
862
863
  }
863
864
 
864
- export const DEFAULT_COMPATIBILITY_DATE = "2025-04-20";
865
+ export const DEFAULT_COMPATIBILITY_DATE = BUILD_DATE;
865
866
 
866
867
  export const _Worker = Resource(
867
868
  "cloudflare::Worker",
@@ -1467,7 +1468,7 @@ export async function assertWorkerDoesNotExist<B extends Bindings>(
1467
1468
  }
1468
1469
 
1469
1470
  export async function configureURL<B extends Bindings>(
1470
- ctx: Context<Worker<B>>,
1471
+ ctx: Context<Worker<B>> | Context<WorkerStub>,
1471
1472
  api: CloudflareApi,
1472
1473
  workerName: string,
1473
1474
  url: boolean,
package/src/context.ts CHANGED
@@ -53,7 +53,7 @@ export interface BaseContext<Out extends Resource> {
53
53
  * Indicate that this resource is being replaced.
54
54
  * This will cause the resource to be deleted at the end of the stack's CREATE phase.
55
55
  */
56
- replace(): void;
56
+ replace(): never;
57
57
  /**
58
58
  * Terminate the resource lifecycle handler and destroy the resource.
59
59
  *
@@ -97,7 +97,7 @@ export function context<
97
97
  seq: number;
98
98
  props: Props;
99
99
  state: State<Kind, Props, Out>;
100
- replace: () => void;
100
+ replace: () => never;
101
101
  }): Context<Out> {
102
102
  type InternalSymbols =
103
103
  | typeof ResourceID
package/src/destroy.ts CHANGED
@@ -6,10 +6,11 @@ import {
6
6
  ResourceFQN,
7
7
  ResourceID,
8
8
  ResourceKind,
9
+ type ResourceProps,
9
10
  ResourceScope,
10
11
  ResourceSeq,
11
12
  } from "./resource.ts";
12
- import { isScope, Scope } from "./scope.ts";
13
+ import { isScope, type PendingDeletions, Scope } from "./scope.ts";
13
14
  import { formatFQN } from "./util/cli.ts";
14
15
  import { logger } from "./util/logger.ts";
15
16
 
@@ -18,6 +19,10 @@ export class DestroyedSignal extends Error {}
18
19
  export interface DestroyOptions {
19
20
  quiet?: boolean;
20
21
  strategy?: "sequential" | "parallel";
22
+ replace?: {
23
+ props?: ResourceProps | undefined;
24
+ output?: Resource<string>;
25
+ };
21
26
  }
22
27
 
23
28
  function isScopeArgs(a: any): a is [scope: Scope, options?: DestroyOptions] {
@@ -40,7 +45,8 @@ export async function destroy<Type extends string>(
40
45
  } satisfies DestroyOptions;
41
46
 
42
47
  await scope.run(async () => {
43
- // destroy all active resources
48
+ // destroy all active and pending resources
49
+ await scope.destroyPendingDeletions();
44
50
  await destroyAll(Array.from(scope.resources.values()), options);
45
51
 
46
52
  // then detect orphans and destroy them
@@ -109,7 +115,7 @@ export async function destroy<Type extends string>(
109
115
  id: instance[ResourceID],
110
116
  fqn: instance[ResourceFQN],
111
117
  seq: instance[ResourceSeq],
112
- props: state.props,
118
+ props: options?.replace?.props ?? state.props,
113
119
  state,
114
120
  replace: () => {
115
121
  throw new Error("Cannot replace a resource that is being deleted");
@@ -127,10 +133,10 @@ export async function destroy<Type extends string>(
127
133
  parent: scope,
128
134
  },
129
135
  async (scope) => {
130
- nestedScope = scope;
136
+ nestedScope = options?.replace?.props == null ? scope : undefined;
131
137
  return await Provider.handler.bind(ctx)(
132
138
  instance[ResourceID],
133
- state.props!,
139
+ options?.replace?.props ?? state.props!,
134
140
  );
135
141
  },
136
142
  );
@@ -146,7 +152,21 @@ export async function destroy<Type extends string>(
146
152
  await destroy(nestedScope, options);
147
153
  }
148
154
 
149
- await scope.delete(instance[ResourceID]);
155
+ if (options?.replace == null) {
156
+ if (nestedScope) {
157
+ await destroy(nestedScope, options);
158
+ }
159
+ await scope.deleteResource(instance[ResourceID]);
160
+ } else {
161
+ let pendingDeletions =
162
+ await state.output[ResourceScope].get<PendingDeletions>(
163
+ "pendingDeletions",
164
+ );
165
+ pendingDeletions = pendingDeletions?.filter(
166
+ (deletion) => deletion.resource[ResourceID] !== instance[ResourceID],
167
+ );
168
+ await scope.set("pendingDeletions", pendingDeletions);
169
+ }
150
170
 
151
171
  if (!quiet) {
152
172
  logger.task(instance[ResourceFQN], {
@@ -165,11 +185,14 @@ export async function destroy<Type extends string>(
165
185
 
166
186
  export async function destroyAll(
167
187
  resources: Resource[],
168
- options?: DestroyOptions,
188
+ options?: DestroyOptions & { force?: boolean },
169
189
  ) {
170
190
  if (options?.strategy !== "parallel") {
171
191
  const sorted = resources.sort((a, b) => b[ResourceSeq] - a[ResourceSeq]);
172
192
  for (const resource of sorted) {
193
+ if (isScope(resource)) {
194
+ await resource.destroyPendingDeletions();
195
+ }
173
196
  await destroy(resource, options);
174
197
  }
175
198
  } else {
package/src/scope.ts CHANGED
@@ -1,16 +1,32 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { Phase } from "./alchemy.ts";
3
- import { destroyAll } from "./destroy.ts";
3
+ import { destroy, destroyAll } from "./destroy.ts";
4
4
  import { FileSystemStateStore } from "./fs/file-system-state-store.ts";
5
- import { ResourceID, type PendingResource } from "./resource.ts";
6
- import type { StateStore, StateStoreType } from "./state.ts";
5
+ import {
6
+ ResourceFQN,
7
+ ResourceID,
8
+ ResourceKind,
9
+ ResourceScope,
10
+ ResourceSeq,
11
+ type PendingResource,
12
+ type Resource,
13
+ type ResourceProps,
14
+ } from "./resource.ts";
15
+ import type { State, StateStore, StateStoreType } from "./state.ts";
7
16
  import {
8
17
  createDummyLogger,
9
18
  createLoggerInstance,
10
19
  type LoggerApi,
11
20
  } from "./util/cli.ts";
21
+ import { AsyncMutex } from "./util/mutex.ts";
12
22
  import type { ITelemetryClient } from "./util/telemetry/client.ts";
13
23
 
24
+ export class RootScopeStateAttemptError extends Error {
25
+ constructor() {
26
+ super("Root scope cannot contain state");
27
+ }
28
+ }
29
+
14
30
  export interface ScopeOptions {
15
31
  appName?: string;
16
32
  stage?: string;
@@ -24,6 +40,11 @@ export interface ScopeOptions {
24
40
  logger?: LoggerApi;
25
41
  }
26
42
 
43
+ export type PendingDeletions = Array<{
44
+ resource: Resource<string>;
45
+ oldProps?: ResourceProps;
46
+ }>;
47
+
27
48
  // TODO: support browser
28
49
  const DEFAULT_STAGE = process.env.ALCHEMY_STAGE ?? process.env.USER ?? "dev";
29
50
 
@@ -47,7 +68,7 @@ export class Scope {
47
68
  new AsyncLocalStorage<Scope>());
48
69
  public static globals: Scope[] = (globalThis.__ALCHEMY_GLOBALS__ ??= []);
49
70
 
50
- public static get(): Scope | undefined {
71
+ public static getScope(): Scope | undefined {
51
72
  const scope = Scope.storage.getStore();
52
73
  if (!scope) {
53
74
  if (Scope.globals.length > 0) {
@@ -63,7 +84,7 @@ export class Scope {
63
84
  }
64
85
 
65
86
  public static get current(): Scope {
66
- const scope = Scope.get();
87
+ const scope = Scope.getScope();
67
88
  if (!scope) throw new Error("Not running within an Alchemy Scope");
68
89
  return scope;
69
90
  }
@@ -81,6 +102,7 @@ export class Scope {
81
102
  public readonly phase: Phase;
82
103
  public readonly logger: LoggerApi;
83
104
  public readonly telemetryClient: ITelemetryClient;
105
+ public readonly dataMutex: AsyncMutex;
84
106
 
85
107
  private isErrored = false;
86
108
  private finalized = false;
@@ -96,7 +118,7 @@ export class Scope {
96
118
  `Scope name ${this.scopeName} cannot contain double colons`,
97
119
  );
98
120
  }
99
- this.parent = options.parent ?? Scope.get();
121
+ this.parent = options.parent ?? Scope.getScope();
100
122
  this.stage = options?.stage ?? this.parent?.stage ?? DEFAULT_STAGE;
101
123
  this.parent?.children.set(this.scopeName!, this);
102
124
  this.quiet = options.quiet ?? this.parent?.quiet ?? false;
@@ -131,6 +153,7 @@ export class Scope {
131
153
  }
132
154
  this.telemetryClient =
133
155
  options.telemetryClient ?? this.parent?.telemetryClient!;
156
+ this.dataMutex = new AsyncMutex();
134
157
  }
135
158
 
136
159
  public get root(): Scope {
@@ -141,7 +164,7 @@ export class Scope {
141
164
  return root;
142
165
  }
143
166
 
144
- public async delete(resourceID: ResourceID) {
167
+ public async deleteResource(resourceID: ResourceID) {
145
168
  await this.state.delete(resourceID);
146
169
  this.resources.delete(resourceID);
147
170
  }
@@ -158,7 +181,7 @@ export class Scope {
158
181
  if (this.parent) {
159
182
  return [...this.parent.chain, ...thisScope];
160
183
  }
161
- return [...app, this.stage, ...thisScope];
184
+ return [...app, ...thisScope];
162
185
  }
163
186
 
164
187
  public fail() {
@@ -179,6 +202,78 @@ export class Scope {
179
202
  return [...this.chain, resourceID].join("/");
180
203
  }
181
204
 
205
+ /**
206
+ * Centralizes the "lock → locate the right scope → hand the caller a live
207
+ * ScopeState instance and a persist() helper".
208
+ *
209
+ * @param fn Your operation on the scope state.
210
+ * • `state` is already resolved and, if we're at the root, created.
211
+ * • `persist` will write the (possibly-mutated) state back.
212
+ */
213
+ private async withScopeState<R>(
214
+ fn: (
215
+ state: State<string, ResourceProps | undefined, Resource<string>>, // current state for this.scopeName
216
+ persist: (
217
+ next: State<string, ResourceProps | undefined, Resource<string>>,
218
+ ) => Promise<void>, // helper to save changes
219
+ ) => Promise<R>,
220
+ ): Promise<R> {
221
+ return this.dataMutex.lock(async () => {
222
+ // 1. We must know where to look.
223
+ if (!this.parent || !this.scopeName) {
224
+ throw new RootScopeStateAttemptError();
225
+ }
226
+
227
+ // 2. Pull (or lazily create) the state bucket we care about.
228
+ const isRoot = this.parent.scopeName === this.root.scopeName;
229
+ const state =
230
+ (await this.parent.state.get(this.scopeName)) ??
231
+ (isRoot
232
+ ? {
233
+ //todo(michael): should this have a different type cause its root?
234
+ kind: "alchemy::Scope",
235
+ id: this.scopeName!,
236
+ fqn: this.root.fqn(this.scopeName!),
237
+ seq: this.seq(),
238
+ status: "created",
239
+ data: {},
240
+ output: {
241
+ [ResourceID]: this.scopeName!,
242
+ [ResourceFQN]: this.root.fqn(this.scopeName!),
243
+ [ResourceKind]: "alchemy::Scope",
244
+ [ResourceScope]: this,
245
+ [ResourceSeq]: this.seq(),
246
+ },
247
+ props: {},
248
+ }
249
+ : undefined);
250
+
251
+ if (!state) throw new RootScopeStateAttemptError();
252
+
253
+ return fn(state, (updated) =>
254
+ this.parent!.state.set(this.scopeName!, updated),
255
+ );
256
+ });
257
+ }
258
+
259
+ public async set<T>(key: string, value: T): Promise<void> {
260
+ return this.withScopeState<void>(async (state, persist) => {
261
+ state.data[key] = value;
262
+ await persist(state); // only one line to save!
263
+ });
264
+ }
265
+
266
+ public async get<T>(key: string): Promise<T> {
267
+ return this.withScopeState<T>(async (state) => state.data[key]);
268
+ }
269
+
270
+ public async delete(key: string): Promise<void> {
271
+ return this.withScopeState<void>(async (state, persist) => {
272
+ delete state.data[key];
273
+ await persist(state);
274
+ });
275
+ }
276
+
182
277
  public async run<T>(fn: (scope: Scope) => Promise<T>): Promise<T> {
183
278
  return Scope.storage.run(this, () => fn(this));
184
279
  }
@@ -198,7 +293,11 @@ export class Scope {
198
293
  return null;
199
294
  }
200
295
 
201
- public async finalize() {
296
+ public async finalize(force?: boolean) {
297
+ const shouldForce =
298
+ force ||
299
+ this.parent === undefined ||
300
+ this?.parent?.scopeName === this.root.scopeName;
202
301
  if (this.phase === "read") {
203
302
  this.rootTelemetryClient?.record({
204
303
  event: "app.success",
@@ -206,7 +305,7 @@ export class Scope {
206
305
  });
207
306
  return;
208
307
  }
209
- if (this.finalized) {
308
+ if (this.finalized && !shouldForce) {
210
309
  return;
211
310
  }
212
311
  if (this.parent === undefined && Scope.globals.length > 0) {
@@ -227,12 +326,23 @@ export class Scope {
227
326
  const orphanIds = Array.from(
228
327
  resourceIds.filter((id) => !aliveIds.has(id)),
229
328
  );
329
+
330
+ if (shouldForce) {
331
+ await this.destroyPendingDeletions();
332
+ await Promise.all(
333
+ Array.from(this.children.values()).map((child) =>
334
+ child.finalize(shouldForce),
335
+ ),
336
+ );
337
+ }
338
+
230
339
  const orphans = await Promise.all(
231
340
  orphanIds.map(async (id) => (await this.state.get(id))!.output),
232
341
  );
233
342
  await destroyAll(orphans, {
234
343
  quiet: this.quiet,
235
344
  strategy: "sequential",
345
+ force: shouldForce,
236
346
  });
237
347
  this.rootTelemetryClient?.record({
238
348
  event: "app.success",
@@ -250,6 +360,31 @@ export class Scope {
250
360
  await this.rootTelemetryClient?.finalize();
251
361
  }
252
362
 
363
+ public async destroyPendingDeletions() {
364
+ const pendingDeletions =
365
+ (await this.get<PendingDeletions>("pendingDeletions").catch((e) => {
366
+ if (e instanceof RootScopeStateAttemptError) {
367
+ return [];
368
+ }
369
+ throw e;
370
+ })) ?? [];
371
+ if (pendingDeletions) {
372
+ for (const { resource, oldProps } of pendingDeletions) {
373
+ //todo(michael): ugly hack due to the way scope is serialized
374
+ const realResource = this.resources.get(resource[ResourceID])!;
375
+ resource[ResourceScope] = realResource?.[ResourceScope] ?? this;
376
+ await destroy(resource, {
377
+ quiet: this.quiet,
378
+ strategy: "sequential",
379
+ replace: {
380
+ props: oldProps,
381
+ output: resource,
382
+ },
383
+ });
384
+ }
385
+ }
386
+ }
387
+
253
388
  /**
254
389
  * Defers execution of a function until the Alchemy application finalizes.
255
390
  */
@@ -347,7 +347,7 @@ export const Project = Resource(
347
347
  }
348
348
 
349
349
  if (!response.ok) {
350
- throw new Error(`API error: ${response.statusText}`);
350
+ throw new Error(`API error: ${await response.text()}`);
351
351
  }
352
352
 
353
353
  const data = (await response.json()) as Omit<
@@ -4,7 +4,7 @@ import type { LoggerApi } from "./cli.ts";
4
4
  export const logger = new Proxy({} as LoggerApi, {
5
5
  get: (_, prop: keyof LoggerApi) => {
6
6
  const logger =
7
- Scope.get()?.logger ??
7
+ Scope.getScope()?.logger ??
8
8
  ({
9
9
  log: console.log,
10
10
  error: console.error,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Simple async mutex that ensures only one async operation executes at a time
3
+ */
4
+ export class AsyncMutex {
5
+ private locked = false;
6
+ private queue: Array<() => void> = [];
7
+
8
+ async lock<T>(operation: () => Promise<T>): Promise<T> {
9
+ return new Promise<T>((resolve, reject) => {
10
+ const execute = async () => {
11
+ this.locked = true;
12
+ try {
13
+ const result = await operation();
14
+ resolve(result);
15
+ } catch (error) {
16
+ reject(error);
17
+ } finally {
18
+ this.locked = false;
19
+ const next = this.queue.shift();
20
+ if (next) {
21
+ next();
22
+ }
23
+ }
24
+ };
25
+
26
+ if (this.locked) {
27
+ this.queue.push(execute);
28
+ } else {
29
+ execute();
30
+ }
31
+ });
32
+ }
33
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "recommendations": ["astro-build.astro-vscode"],
3
+ "unwantedRecommendations": []
4
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "command": "./node_modules/.bin/astro dev",
6
+ "name": "Development server",
7
+ "request": "launch",
8
+ "type": "node-terminal"
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,48 @@
1
+ # Astro Starter Kit: Basics
2
+
3
+ ```sh
4
+ bun create astro@latest -- --template basics
5
+ ```
6
+
7
+ [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/basics)
8
+ [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/basics)
9
+ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/basics/devcontainer.json)
10
+
11
+ > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
12
+
13
+ ![just-the-basics](https://github.com/withastro/astro/assets/2244813/a0a5533c-a856-4198-8470-2d67b1d7c554)
14
+
15
+ ## 🚀 Project Structure
16
+
17
+ Inside of your Astro project, you'll see the following folders and files:
18
+
19
+ ```text
20
+ /
21
+ ├── public/
22
+ │ └── favicon.svg
23
+ ├── src/
24
+ │ ├── layouts/
25
+ │ │ └── Layout.astro
26
+ │ └── pages/
27
+ │ └── index.astro
28
+ └── package.json
29
+ ```
30
+
31
+ To learn more about the folder structure of an Astro project, refer to [our guide on project structure](https://docs.astro.build/en/basics/project-structure/).
32
+
33
+ ## 🧞 Commands
34
+
35
+ All commands are run from the root of the project, from a terminal:
36
+
37
+ | Command | Action |
38
+ | :------------------------ | :----------------------------------------------- |
39
+ | `bun install` | Installs dependencies |
40
+ | `bun dev` | Starts local dev server at `localhost:4321` |
41
+ | `bun build` | Build your production site to `./dist/` |
42
+ | `bun preview` | Preview your build locally, before deploying |
43
+ | `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
44
+ | `bun astro -- --help` | Get help using the Astro CLI |
45
+
46
+ ## 👀 Want to learn more?
47
+
48
+ Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
@@ -0,0 +1 @@
1
+ ALCHEMY_PASSWORD=change-me
@@ -0,0 +1,27 @@
1
+ # build output
2
+ dist/
3
+
4
+ # generated types
5
+ .astro/
6
+
7
+ # dependencies
8
+ node_modules/
9
+
10
+ # logs
11
+ npm-debug.log*
12
+ yarn-debug.log*
13
+ yarn-error.log*
14
+ pnpm-debug.log*
15
+
16
+ # environment variables
17
+ .env
18
+ .env.production
19
+
20
+ # macOS-specific files
21
+ .DS_Store
22
+
23
+ # jetbrains setting folder
24
+ .idea/
25
+
26
+ .alchemy/
27
+ .env
@@ -0,0 +1,16 @@
1
+ /// <reference types="@types/node" />
2
+
3
+ import alchemy from "alchemy";
4
+ import { Astro } from "alchemy/cloudflare";
5
+
6
+ const app = await alchemy("my-alchemy-app");
7
+
8
+ export const worker = await Astro("website", {
9
+ command: "bun run build",
10
+ });
11
+
12
+ console.log({
13
+ url: worker.url,
14
+ });
15
+
16
+ await app.finalize();
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import cloudflare from '@astrojs/cloudflare';
3
+
4
+ // https://astro.build/config
5
+ export default defineConfig({
6
+ output: 'server',
7
+ adapter: cloudflare(),
8
+ });
@@ -0,0 +1,23 @@
1
+ {
2
+ "type": "module",
3
+ "name": "astro",
4
+ "version": "0.0.1",
5
+ "scripts": {
6
+ "astro": "astro",
7
+ "build": "astro check && astro build",
8
+ "deploy": "bun --env-file=./.env ./alchemy.run.ts",
9
+ "destroy": "bun --env-file=./.env ./alchemy.run.ts --destroy",
10
+ "dev": "astro dev",
11
+ "preview": "astro preview"
12
+ },
13
+ "dependencies": {
14
+ "astro": "^5.10.0"
15
+ },
16
+ "devDependencies": {
17
+ "alchemy": "workspace:*",
18
+ "@astrojs/cloudflare": "^12.6.0",
19
+ "miniflare": "^4.20250617.3",
20
+ "@cloudflare/workers-types": "^4.20250620.0",
21
+ "typescript": "^5.8.3"
22
+ }
23
+ }