alchemy 0.37.1 → 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 (264) 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/do-state-store/internal.d.ts.map +1 -1
  26. package/lib/cloudflare/do-state-store/internal.js +9 -1
  27. package/lib/cloudflare/do-state-store/internal.js.map +1 -1
  28. package/lib/cloudflare/pipeline.d.ts +1 -1
  29. package/lib/cloudflare/pipeline.d.ts.map +1 -1
  30. package/lib/cloudflare/pipeline.js +6 -1
  31. package/lib/cloudflare/pipeline.js.map +1 -1
  32. package/lib/cloudflare/worker-stub.d.ts +23 -2
  33. package/lib/cloudflare/worker-stub.d.ts.map +1 -1
  34. package/lib/cloudflare/worker-stub.js +16 -2
  35. package/lib/cloudflare/worker-stub.js.map +1 -1
  36. package/lib/cloudflare/worker.d.ts +4 -3
  37. package/lib/cloudflare/worker.d.ts.map +1 -1
  38. package/lib/cloudflare/worker.js +2 -1
  39. package/lib/cloudflare/worker.js.map +1 -1
  40. package/lib/context.d.ts +2 -2
  41. package/lib/context.d.ts.map +1 -1
  42. package/lib/destroy.d.ts +8 -2
  43. package/lib/destroy.d.ts.map +1 -1
  44. package/lib/destroy.js +19 -5
  45. package/lib/destroy.js.map +1 -1
  46. package/lib/scope.d.ts +26 -4
  47. package/lib/scope.d.ts.map +1 -1
  48. package/lib/scope.js +104 -9
  49. package/lib/scope.js.map +1 -1
  50. package/lib/sentry/project.js +1 -1
  51. package/lib/sentry/project.js.map +1 -1
  52. package/lib/util/exists.d.ts +2 -0
  53. package/lib/util/exists.d.ts.map +1 -0
  54. package/lib/util/exists.js +11 -0
  55. package/lib/util/exists.js.map +1 -0
  56. package/lib/util/logger.js +1 -1
  57. package/lib/util/logger.js.map +1 -1
  58. package/lib/util/mutex.d.ts +9 -0
  59. package/lib/util/mutex.d.ts.map +1 -0
  60. package/lib/util/mutex.js +35 -0
  61. package/lib/util/mutex.js.map +1 -0
  62. package/package.json +16 -6
  63. package/src/alchemy.ts +17 -5
  64. package/src/apply.ts +67 -17
  65. package/src/build-date.ts +8 -0
  66. package/src/cloudflare/api.ts +2 -1
  67. package/src/cloudflare/do-state-store/internal.ts +10 -1
  68. package/src/cloudflare/pipeline.ts +6 -0
  69. package/src/cloudflare/worker-stub.ts +31 -2
  70. package/src/cloudflare/worker.ts +4 -3
  71. package/src/context.ts +2 -2
  72. package/src/destroy.ts +30 -7
  73. package/src/scope.ts +145 -10
  74. package/src/sentry/project.ts +1 -1
  75. package/src/util/exists.ts +10 -0
  76. package/src/util/logger.ts +1 -1
  77. package/src/util/mutex.ts +33 -0
  78. package/templates/astro/.vscode/extensions.json +4 -0
  79. package/templates/astro/.vscode/launch.json +11 -0
  80. package/templates/astro/README.md +48 -0
  81. package/templates/astro/_env.example +1 -0
  82. package/templates/astro/_gitignore +27 -0
  83. package/templates/astro/alchemy.run.ts +16 -0
  84. package/templates/astro/astro.config.mjs +8 -0
  85. package/templates/astro/package.json +23 -0
  86. package/templates/astro/public/favicon.svg +9 -0
  87. package/templates/astro/src/assets/astro.svg +1 -0
  88. package/templates/astro/src/assets/background.svg +1 -0
  89. package/templates/astro/src/components/Welcome.astro +210 -0
  90. package/templates/astro/src/layouts/Layout.astro +22 -0
  91. package/templates/astro/src/pages/api/hello.ts +19 -0
  92. package/templates/astro/src/pages/index.astro +11 -0
  93. package/templates/astro/tsconfig.json +18 -0
  94. package/templates/astro/types/env.d.ts +16 -0
  95. package/templates/astro/wrangler.jsonc +6 -0
  96. package/templates/nuxt/README.md +75 -0
  97. package/templates/nuxt/_env +1 -0
  98. package/templates/nuxt/_env.example +1 -0
  99. package/templates/nuxt/_gitignore +27 -0
  100. package/templates/nuxt/alchemy.run.ts +16 -0
  101. package/templates/nuxt/app.vue +6 -0
  102. package/templates/nuxt/nuxt.config.ts +13 -0
  103. package/templates/nuxt/package.json +26 -0
  104. package/templates/nuxt/public/favicon.ico +0 -0
  105. package/templates/nuxt/public/robots.txt +2 -0
  106. package/templates/nuxt/server/api/hello.ts +7 -0
  107. package/templates/nuxt/server/middleware/auth.ts +5 -0
  108. package/templates/nuxt/server/middleware/hello.ts +5 -0
  109. package/templates/nuxt/server/tsconfig.json +3 -0
  110. package/templates/nuxt/tsconfig.json +15 -0
  111. package/templates/nuxt/types/env.d.ts +16 -0
  112. package/templates/react-router/.vscode/settings.json +5 -0
  113. package/templates/react-router/README.md +79 -0
  114. package/templates/react-router/_env +1 -0
  115. package/templates/react-router/_env.example +1 -0
  116. package/templates/react-router/_gitignore +15 -0
  117. package/templates/react-router/alchemy.run.ts +17 -0
  118. package/templates/react-router/app/app.css +15 -0
  119. package/templates/react-router/app/entry.server.tsx +43 -0
  120. package/templates/react-router/app/root.tsx +75 -0
  121. package/templates/react-router/app/routes/home.tsx +17 -0
  122. package/templates/react-router/app/routes.ts +3 -0
  123. package/templates/react-router/app/welcome/logo-dark.svg +23 -0
  124. package/templates/react-router/app/welcome/logo-light.svg +23 -0
  125. package/templates/react-router/app/welcome/welcome.tsx +90 -0
  126. package/templates/react-router/package.json +34 -0
  127. package/templates/react-router/public/favicon.ico +0 -0
  128. package/templates/react-router/react-router.config.ts +8 -0
  129. package/templates/react-router/tsconfig.cloudflare.json +27 -0
  130. package/templates/react-router/tsconfig.json +17 -0
  131. package/templates/react-router/tsconfig.node.json +20 -0
  132. package/templates/react-router/types/env.d.ts +16 -0
  133. package/templates/react-router/vite.config.ts +14 -0
  134. package/templates/react-router/workers/app.ts +23 -0
  135. package/templates/react-router/wrangler.jsonc +6 -0
  136. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-interruptors.mdc +247 -0
  137. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-middleware.mdc +75 -0
  138. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-react.mdc +110 -0
  139. package/templates/rwsdk/.cursor/rules/rwsdk_rwsdk-request-response.mdc +213 -0
  140. package/templates/rwsdk/.devcontainer/Dockerfile +8 -0
  141. package/templates/rwsdk/.devcontainer/devcontainer.json +21 -0
  142. package/templates/rwsdk/README.md +62 -0
  143. package/templates/rwsdk/_env +5 -0
  144. package/templates/rwsdk/_env.example +5 -0
  145. package/templates/rwsdk/_gitignore +71 -0
  146. package/templates/rwsdk/alchemy.run.ts +29 -0
  147. package/templates/rwsdk/package.json +61 -0
  148. package/templates/rwsdk/prisma/schema.prisma +43 -0
  149. package/templates/rwsdk/src/app/Document.tsx +16 -0
  150. package/templates/rwsdk/src/app/headers.ts +32 -0
  151. package/templates/rwsdk/src/app/pages/Home.tsx +13 -0
  152. package/templates/rwsdk/src/app/pages/user/Login.tsx +79 -0
  153. package/templates/rwsdk/src/app/pages/user/functions.ts +171 -0
  154. package/templates/rwsdk/src/app/pages/user/routes.ts +17 -0
  155. package/templates/rwsdk/src/app/shared/links.ts +3 -0
  156. package/templates/rwsdk/src/client.tsx +3 -0
  157. package/templates/rwsdk/src/db.ts +26 -0
  158. package/templates/rwsdk/src/scripts/seed.ts +21 -0
  159. package/templates/rwsdk/src/session/durableObject.ts +63 -0
  160. package/templates/rwsdk/src/session/store.ts +13 -0
  161. package/templates/rwsdk/src/worker.tsx +63 -0
  162. package/templates/rwsdk/tsconfig.json +47 -0
  163. package/templates/rwsdk/types/env.d.ts +16 -0
  164. package/templates/rwsdk/types/rw.d.ts +5 -0
  165. package/templates/rwsdk/types/vite.d.ts +4 -0
  166. package/templates/rwsdk/vite.config.mts +6 -0
  167. package/templates/rwsdk/wrangler.jsonc +28 -0
  168. package/templates/sveltekit/README.md +38 -0
  169. package/templates/sveltekit/_env +1 -0
  170. package/templates/sveltekit/_env.example +1 -0
  171. package/templates/sveltekit/_gitignore +26 -0
  172. package/templates/sveltekit/_npmrc +1 -0
  173. package/templates/sveltekit/_prettierrc +3 -0
  174. package/templates/sveltekit/alchemy.run.ts +16 -0
  175. package/templates/sveltekit/bun.lock +1099 -0
  176. package/templates/sveltekit/package.json +33 -0
  177. package/templates/sveltekit/src/app.css +1 -0
  178. package/templates/sveltekit/src/app.d.ts +13 -0
  179. package/templates/sveltekit/src/app.html +12 -0
  180. package/templates/sveltekit/src/routes/+layout.svelte +7 -0
  181. package/templates/sveltekit/src/routes/+page.svelte +2 -0
  182. package/templates/sveltekit/static/favicon.png +0 -0
  183. package/templates/sveltekit/svelte.config.js +12 -0
  184. package/templates/sveltekit/tsconfig.json +27 -0
  185. package/templates/sveltekit/types/env.d.ts +16 -0
  186. package/templates/sveltekit/vite.config.ts +6 -0
  187. package/templates/sveltekit/wrangler.jsonc +7 -0
  188. package/templates/tanstack-start/.vscode/settings.json +11 -0
  189. package/templates/tanstack-start/README.md +72 -0
  190. package/templates/tanstack-start/_env +1 -0
  191. package/templates/tanstack-start/_env.example +1 -0
  192. package/templates/tanstack-start/_gitignore +22 -0
  193. package/templates/tanstack-start/_prettierignore +4 -0
  194. package/templates/tanstack-start/alchemy.run.ts +16 -0
  195. package/templates/tanstack-start/package.json +37 -0
  196. package/templates/tanstack-start/public/android-chrome-192x192.png +0 -0
  197. package/templates/tanstack-start/public/android-chrome-512x512.png +0 -0
  198. package/templates/tanstack-start/public/apple-touch-icon.png +0 -0
  199. package/templates/tanstack-start/public/favicon-16x16.png +0 -0
  200. package/templates/tanstack-start/public/favicon-32x32.png +0 -0
  201. package/templates/tanstack-start/public/favicon.ico +0 -0
  202. package/templates/tanstack-start/public/favicon.png +0 -0
  203. package/templates/tanstack-start/public/site.webmanifest +19 -0
  204. package/templates/tanstack-start/src/components/DefaultCatchBoundary.tsx +53 -0
  205. package/templates/tanstack-start/src/components/NotFound.tsx +25 -0
  206. package/templates/tanstack-start/src/components/PostError.tsx +5 -0
  207. package/templates/tanstack-start/src/components/UserError.tsx +5 -0
  208. package/templates/tanstack-start/src/router.tsx +22 -0
  209. package/templates/tanstack-start/src/routes/__root.tsx +146 -0
  210. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-a.tsx +10 -0
  211. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-b.tsx +10 -0
  212. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout.tsx +34 -0
  213. package/templates/tanstack-start/src/routes/_pathlessLayout.tsx +16 -0
  214. package/templates/tanstack-start/src/routes/api/users.$userId.ts +28 -0
  215. package/templates/tanstack-start/src/routes/api/users.ts +59 -0
  216. package/templates/tanstack-start/src/routes/customScript[.]js.ts +10 -0
  217. package/templates/tanstack-start/src/routes/deferred.tsx +62 -0
  218. package/templates/tanstack-start/src/routes/index.tsx +12 -0
  219. package/templates/tanstack-start/src/routes/posts.$postId.tsx +34 -0
  220. package/templates/tanstack-start/src/routes/posts.index.tsx +8 -0
  221. package/templates/tanstack-start/src/routes/posts.tsx +38 -0
  222. package/templates/tanstack-start/src/routes/posts_.$postId.deep.tsx +29 -0
  223. package/templates/tanstack-start/src/routes/redirect.tsx +9 -0
  224. package/templates/tanstack-start/src/routes/users.$userId.tsx +44 -0
  225. package/templates/tanstack-start/src/routes/users.index.tsx +18 -0
  226. package/templates/tanstack-start/src/routes/users.tsx +49 -0
  227. package/templates/tanstack-start/src/styles/app.css +12 -0
  228. package/templates/tanstack-start/src/utils/loggingMiddleware.tsx +41 -0
  229. package/templates/tanstack-start/src/utils/posts.tsx +40 -0
  230. package/templates/tanstack-start/src/utils/seo.ts +33 -0
  231. package/templates/tanstack-start/src/utils/users.tsx +5 -0
  232. package/templates/tanstack-start/tsconfig.json +31 -0
  233. package/templates/tanstack-start/types/env.d.ts +16 -0
  234. package/templates/tanstack-start/vite.config.ts +33 -0
  235. package/templates/tanstack-start/wrangler.jsonc +7 -0
  236. package/templates/typescript/README.md +15 -0
  237. package/templates/typescript/_gitignore +37 -0
  238. package/templates/typescript/alchemy.run.ts +15 -0
  239. package/templates/typescript/index.ts +1 -0
  240. package/templates/typescript/package.json +18 -0
  241. package/templates/typescript/src/worker.js +5 -0
  242. package/templates/typescript/src/worker.ts +7 -0
  243. package/templates/typescript/tsconfig.json +21 -0
  244. package/templates/typescript/types/env.d.ts +16 -0
  245. package/templates/vite/README.md +54 -0
  246. package/templates/vite/_env.example +1 -0
  247. package/templates/vite/_gitignore +27 -0
  248. package/templates/vite/alchemy.run.ts +17 -0
  249. package/templates/vite/eslint.config.js +28 -0
  250. package/templates/vite/index.html +13 -0
  251. package/templates/vite/package.json +35 -0
  252. package/templates/vite/public/vite.svg +1 -0
  253. package/templates/vite/src/App.css +42 -0
  254. package/templates/vite/src/App.tsx +35 -0
  255. package/templates/vite/src/assets/react.svg +1 -0
  256. package/templates/vite/src/index.css +68 -0
  257. package/templates/vite/src/main.tsx +10 -0
  258. package/templates/vite/src/vite-env.d.ts +1 -0
  259. package/templates/vite/tsconfig.json +34 -0
  260. package/templates/vite/types/env.d.ts +16 -0
  261. package/templates/vite/vite.config.ts +9 -0
  262. package/templates/vite/worker/index.ts +12 -0
  263. package/templates/vite/wrangler.jsonc +6 -0
  264. package/bin/create-alchemy.ts +0 -1220
@@ -0,0 +1,8 @@
1
+ // This file is auto-generated during build
2
+ // Do not edit manually
3
+
4
+ /**
5
+ * The build date used as the default worker compatibility date.
6
+ * This is set to the date when the package was built.
7
+ */
8
+ export const BUILD_DATE = "2025-06-23";
@@ -196,7 +196,8 @@ export class CloudflareApi {
196
196
  (error) =>
197
197
  error instanceof InternalError ||
198
198
  error instanceof TooManyRequestsError ||
199
- error instanceof ForbiddenError,
199
+ error instanceof ForbiddenError ||
200
+ error.code === "ECONNRESET",
200
201
  10, // Maximum 10 attempts (1 initial + 9 retries)
201
202
  1000, // Start with 1s delay, will exponentially increase
202
203
  );
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
  import { bundle } from "../../esbuild/index.ts";
4
+ import { exists } from "../../util/exists.ts";
3
5
  import { withExponentialBackoff } from "../../util/retry.ts";
4
6
  import { handleApiError } from "../api-error.ts";
5
7
  import type { CloudflareApi } from "../api.ts";
@@ -217,8 +219,15 @@ export async function getAccountSubdomain(api: CloudflareApi) {
217
219
  }
218
220
 
219
221
  async function bundleWorkerScript() {
222
+ const thisDir =
223
+ typeof __dirname !== "undefined"
224
+ ? __dirname
225
+ : path.dirname(fileURLToPath(import.meta.url));
226
+ // we can be running from .ts or .js, so resolve it defensively
227
+ const workerTs = path.join(thisDir, "worker.ts");
228
+ const workerJs = path.join(thisDir, "worker.js");
220
229
  const result = await bundle({
221
- entryPoint: path.join(__dirname, "worker.ts"),
230
+ entryPoint: (await exists(workerTs)) ? workerTs : workerJs,
222
231
  bundle: true,
223
232
  format: "esm",
224
233
  target: "es2022",
@@ -522,6 +522,7 @@ export async function createPipeline(
522
522
  api: CloudflareApi,
523
523
  pipelineName: string,
524
524
  props: PipelineProps,
525
+ attempt = 0,
525
526
  ): Promise<CloudflarePipelineResponse> {
526
527
  // Prepare the create payload
527
528
  const createPayload = preparePipelinePayload(api, pipelineName, props);
@@ -532,6 +533,11 @@ export async function createPipeline(
532
533
  );
533
534
 
534
535
  if (!createResponse.ok) {
536
+ if (createResponse.status === 404 && attempt < 3) {
537
+ // bucket does not exist, this might be transient, let's retry
538
+ await new Promise((resolve) => setTimeout(resolve, 1000 * (1 + attempt)));
539
+ return await createPipeline(api, pipelineName, props, attempt + 1);
540
+ }
535
541
  return await handleApiError(
536
542
  createResponse,
537
543
  "creating",
@@ -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<
@@ -0,0 +1,10 @@
1
+ import { access } from "node:fs/promises";
2
+
3
+ export async function exists(path: string): Promise<boolean> {
4
+ try {
5
+ await access(path);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
@@ -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