alchemy 0.63.1 → 0.65.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 (235) hide show
  1. package/bin/alchemy.js +7664 -5857
  2. package/bin/commands/init.ts +130 -9
  3. package/bin/constants.ts +4 -0
  4. package/bin/services/cdp-manager/cdp-proxy.ts +69 -0
  5. package/bin/services/cdp-manager/server.ts +173 -0
  6. package/bin/services/execute-alchemy.ts +83 -4
  7. package/bin/types.ts +1 -0
  8. package/lib/alchemy.d.ts +4 -0
  9. package/lib/alchemy.d.ts.map +1 -1
  10. package/lib/alchemy.js +10 -4
  11. package/lib/alchemy.js.map +1 -1
  12. package/lib/apply.d.ts +3 -0
  13. package/lib/apply.d.ts.map +1 -1
  14. package/lib/apply.js +11 -1
  15. package/lib/apply.js.map +1 -1
  16. package/lib/cloudflare/bindings.d.ts +3 -1
  17. package/lib/cloudflare/bindings.d.ts.map +1 -1
  18. package/lib/cloudflare/bucket.d.ts +2 -1
  19. package/lib/cloudflare/bucket.d.ts.map +1 -1
  20. package/lib/cloudflare/bucket.js.map +1 -1
  21. package/lib/cloudflare/bundle/cloudflare-internal.d.ts +6 -0
  22. package/lib/cloudflare/bundle/cloudflare-internal.d.ts.map +1 -0
  23. package/lib/cloudflare/bundle/cloudflare-internal.js +39 -0
  24. package/lib/cloudflare/bundle/cloudflare-internal.js.map +1 -0
  25. package/lib/cloudflare/bundle/nodejs-builtin-modules.d.ts +2 -0
  26. package/lib/cloudflare/bundle/nodejs-builtin-modules.d.ts.map +1 -0
  27. package/lib/cloudflare/bundle/nodejs-builtin-modules.js +9 -0
  28. package/lib/cloudflare/bundle/nodejs-builtin-modules.js.map +1 -0
  29. package/lib/cloudflare/bundle/plugin-als-external.d.ts +6 -0
  30. package/lib/cloudflare/bundle/plugin-als-external.d.ts.map +1 -0
  31. package/lib/cloudflare/bundle/plugin-als-external.js +12 -0
  32. package/lib/cloudflare/bundle/plugin-als-external.js.map +1 -0
  33. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.d.ts +4 -1
  34. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.d.ts.map +1 -1
  35. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.js +23 -23
  36. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.js.map +1 -1
  37. package/lib/cloudflare/bundle/plugin-node-compat.d.ts +11 -0
  38. package/lib/cloudflare/bundle/plugin-node-compat.d.ts.map +1 -0
  39. package/lib/cloudflare/bundle/plugin-node-compat.js +93 -0
  40. package/lib/cloudflare/bundle/plugin-node-compat.js.map +1 -0
  41. package/lib/cloudflare/bundle/plugin-wasm.d.ts.map +1 -1
  42. package/lib/cloudflare/bundle/plugin-wasm.js +20 -11
  43. package/lib/cloudflare/bundle/plugin-wasm.js.map +1 -1
  44. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  45. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  46. package/lib/cloudflare/d1-local-migrations.js.map +1 -1
  47. package/lib/cloudflare/durable-object-namespace.d.ts +1 -0
  48. package/lib/cloudflare/durable-object-namespace.d.ts.map +1 -1
  49. package/lib/cloudflare/durable-object-namespace.js.map +1 -1
  50. package/lib/cloudflare/hyperdrive.d.ts +7 -2
  51. package/lib/cloudflare/hyperdrive.d.ts.map +1 -1
  52. package/lib/cloudflare/hyperdrive.js +39 -57
  53. package/lib/cloudflare/hyperdrive.js.map +1 -1
  54. package/lib/cloudflare/index.d.ts +1 -0
  55. package/lib/cloudflare/index.d.ts.map +1 -1
  56. package/lib/cloudflare/index.js +1 -0
  57. package/lib/cloudflare/index.js.map +1 -1
  58. package/lib/cloudflare/next.d.ts +13 -0
  59. package/lib/cloudflare/next.d.ts.map +1 -0
  60. package/lib/cloudflare/next.js +91 -0
  61. package/lib/cloudflare/next.js.map +1 -0
  62. package/lib/cloudflare/website.d.ts.map +1 -1
  63. package/lib/cloudflare/website.js +80 -82
  64. package/lib/cloudflare/website.js.map +1 -1
  65. package/lib/cloudflare/worker-assets.d.ts +3 -3
  66. package/lib/cloudflare/worker-assets.d.ts.map +1 -1
  67. package/lib/cloudflare/worker-assets.js +22 -47
  68. package/lib/cloudflare/worker-assets.js.map +1 -1
  69. package/lib/cloudflare/worker-bundle.d.ts +6 -0
  70. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  71. package/lib/cloudflare/worker-bundle.js +34 -10
  72. package/lib/cloudflare/worker-bundle.js.map +1 -1
  73. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  74. package/lib/cloudflare/worker-metadata.js +1 -0
  75. package/lib/cloudflare/worker-metadata.js.map +1 -1
  76. package/lib/cloudflare/worker.d.ts.map +1 -1
  77. package/lib/cloudflare/worker.js +24 -6
  78. package/lib/cloudflare/worker.js.map +1 -1
  79. package/lib/cloudflare/wrangler.json.d.ts +4 -6
  80. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  81. package/lib/cloudflare/wrangler.json.js +20 -15
  82. package/lib/cloudflare/wrangler.json.js.map +1 -1
  83. package/lib/context.d.ts +3 -1
  84. package/lib/context.d.ts.map +1 -1
  85. package/lib/context.js +2 -2
  86. package/lib/context.js.map +1 -1
  87. package/lib/destroy.d.ts +8 -0
  88. package/lib/destroy.d.ts.map +1 -1
  89. package/lib/destroy.js +20 -4
  90. package/lib/destroy.js.map +1 -1
  91. package/lib/planetscale/api/client.gen.d.ts +1750 -0
  92. package/lib/planetscale/api/client.gen.d.ts.map +1 -0
  93. package/lib/planetscale/api/client.gen.js +2401 -0
  94. package/lib/planetscale/api/client.gen.js.map +1 -0
  95. package/lib/planetscale/api/index.d.ts +3 -0
  96. package/lib/planetscale/api/index.d.ts.map +1 -0
  97. package/lib/planetscale/api/index.js +4 -0
  98. package/lib/planetscale/api/index.js.map +1 -0
  99. package/lib/planetscale/api/types.gen.d.ts +12156 -0
  100. package/lib/planetscale/api/types.gen.d.ts.map +1 -0
  101. package/lib/planetscale/api/types.gen.js +2 -0
  102. package/lib/planetscale/api/types.gen.js.map +1 -0
  103. package/lib/planetscale/branch.d.ts +5 -8
  104. package/lib/planetscale/branch.d.ts.map +1 -1
  105. package/lib/planetscale/branch.js +122 -97
  106. package/lib/planetscale/branch.js.map +1 -1
  107. package/lib/planetscale/database.d.ts +24 -11
  108. package/lib/planetscale/database.d.ts.map +1 -1
  109. package/lib/planetscale/database.js +163 -118
  110. package/lib/planetscale/database.js.map +1 -1
  111. package/lib/planetscale/index.d.ts +2 -1
  112. package/lib/planetscale/index.d.ts.map +1 -1
  113. package/lib/planetscale/index.js +2 -1
  114. package/lib/planetscale/index.js.map +1 -1
  115. package/lib/planetscale/password.d.ts +7 -8
  116. package/lib/planetscale/password.d.ts.map +1 -1
  117. package/lib/planetscale/password.js +74 -57
  118. package/lib/planetscale/password.js.map +1 -1
  119. package/lib/planetscale/role.d.ts +126 -0
  120. package/lib/planetscale/role.d.ts.map +1 -0
  121. package/lib/planetscale/role.js +144 -0
  122. package/lib/planetscale/role.js.map +1 -0
  123. package/lib/planetscale/utils.d.ts +23 -7
  124. package/lib/planetscale/utils.d.ts.map +1 -1
  125. package/lib/planetscale/utils.js +169 -75
  126. package/lib/planetscale/utils.js.map +1 -1
  127. package/lib/scope.d.ts +5 -1
  128. package/lib/scope.d.ts.map +1 -1
  129. package/lib/scope.js +11 -3
  130. package/lib/scope.js.map +1 -1
  131. package/lib/secret.d.ts +0 -3
  132. package/lib/secret.d.ts.map +1 -1
  133. package/lib/secret.js +0 -2
  134. package/lib/secret.js.map +1 -1
  135. package/lib/state/cloudflare-state-store.js +3 -3
  136. package/lib/state/cloudflare-state-store.js.map +1 -1
  137. package/lib/test/bun.d.ts +2 -2
  138. package/lib/test/bun.d.ts.map +1 -1
  139. package/lib/test/vitest.d.ts +2 -2
  140. package/lib/test/vitest.d.ts.map +1 -1
  141. package/lib/test/vitest.js +4 -4
  142. package/lib/test/vitest.js.map +1 -1
  143. package/lib/util/content-type.js +2 -2
  144. package/lib/util/content-type.js.map +1 -1
  145. package/lib/util/dedent.d.ts +3 -14
  146. package/lib/util/dedent.d.ts.map +1 -1
  147. package/lib/util/dedent.js +33 -45
  148. package/lib/util/dedent.js.map +1 -1
  149. package/lib/util/memoize.d.ts.map +1 -1
  150. package/lib/util/memoize.js +11 -1
  151. package/lib/util/memoize.js.map +1 -1
  152. package/package.json +8 -3
  153. package/src/alchemy.ts +14 -6
  154. package/src/apply.ts +13 -1
  155. package/src/cloudflare/bindings.ts +3 -1
  156. package/src/cloudflare/bucket.ts +3 -1
  157. package/src/cloudflare/bundle/cloudflare-internal.ts +42 -0
  158. package/src/cloudflare/bundle/nodejs-builtin-modules.ts +14 -0
  159. package/src/cloudflare/bundle/plugin-als-external.ts +13 -0
  160. package/src/cloudflare/bundle/plugin-hybrid-node-compat.ts +29 -28
  161. package/src/cloudflare/bundle/plugin-node-compat.ts +114 -0
  162. package/src/cloudflare/bundle/plugin-wasm.ts +34 -14
  163. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  164. package/src/cloudflare/d1-local-migrations.ts +2 -2
  165. package/src/cloudflare/durable-object-namespace.ts +1 -0
  166. package/src/cloudflare/hyperdrive.ts +136 -95
  167. package/src/cloudflare/index.ts +1 -0
  168. package/src/cloudflare/next.ts +123 -0
  169. package/src/cloudflare/website.ts +88 -89
  170. package/src/cloudflare/worker-assets.ts +43 -105
  171. package/src/cloudflare/worker-bundle.ts +46 -9
  172. package/src/cloudflare/worker-metadata.ts +2 -0
  173. package/src/cloudflare/worker.ts +35 -7
  174. package/src/cloudflare/wrangler.json.ts +114 -113
  175. package/src/context.ts +5 -3
  176. package/src/destroy.ts +26 -8
  177. package/src/planetscale/api/client.gen.ts +2882 -0
  178. package/src/planetscale/api/index.ts +4 -0
  179. package/src/planetscale/api/types.gen.ts +13041 -0
  180. package/src/planetscale/branch.ts +170 -162
  181. package/src/planetscale/database.ts +263 -243
  182. package/src/planetscale/index.ts +2 -1
  183. package/src/planetscale/password.ts +89 -86
  184. package/src/planetscale/role.ts +278 -0
  185. package/src/planetscale/utils.ts +289 -128
  186. package/src/scope.ts +12 -3
  187. package/src/secret.ts +0 -4
  188. package/src/state/cloudflare-state-store.ts +4 -4
  189. package/src/test/bun.ts +2 -2
  190. package/src/test/vitest.ts +6 -6
  191. package/src/util/content-type.ts +2 -2
  192. package/src/util/dedent.ts +34 -50
  193. package/src/util/memoize.ts +13 -1
  194. package/templates/astro/alchemy.run.ts +1 -3
  195. package/templates/hono/alchemy.run.ts +0 -1
  196. package/templates/nextjs/README.md +85 -0
  197. package/templates/nextjs/_env +1 -0
  198. package/templates/nextjs/_env.example +1 -0
  199. package/templates/nextjs/_gitignore +46 -0
  200. package/templates/nextjs/alchemy.run.ts +16 -0
  201. package/templates/nextjs/eslint.config.mjs +16 -0
  202. package/templates/nextjs/next-env.d.ts +5 -0
  203. package/templates/nextjs/next.config.ts +10 -0
  204. package/templates/nextjs/open-next.config.ts +5 -0
  205. package/templates/nextjs/package.json +31 -0
  206. package/templates/nextjs/src/app/api/hello/route.ts +19 -0
  207. package/templates/nextjs/src/app/favicon.ico +0 -0
  208. package/templates/nextjs/src/app/globals.css +72 -0
  209. package/templates/nextjs/src/app/layout.tsx +19 -0
  210. package/templates/nextjs/src/app/page.tsx +55 -0
  211. package/templates/nextjs/tsconfig.json +33 -0
  212. package/templates/nextjs/types/env.d.ts +14 -0
  213. package/templates/nuxt/alchemy.run.ts +1 -3
  214. package/templates/react-router/alchemy.run.ts +1 -3
  215. package/templates/rwsdk/alchemy.run.ts +0 -2
  216. package/templates/sveltekit/alchemy.run.ts +1 -3
  217. package/templates/tanstack-start/alchemy.run.ts +1 -3
  218. package/templates/typescript/alchemy.run.ts +0 -1
  219. package/templates/vite/alchemy.run.ts +0 -1
  220. package/workers/cloudflare-state-store.js +58 -58
  221. package/lib/cloudflare/bundle/externals.d.ts +0 -3
  222. package/lib/cloudflare/bundle/externals.d.ts.map +0 -1
  223. package/lib/cloudflare/bundle/externals.js +0 -35
  224. package/lib/cloudflare/bundle/externals.js.map +0 -1
  225. package/lib/cloudflare/bundle/plugin-compat-warning.d.ts +0 -10
  226. package/lib/cloudflare/bundle/plugin-compat-warning.d.ts.map +0 -1
  227. package/lib/cloudflare/bundle/plugin-compat-warning.js +0 -67
  228. package/lib/cloudflare/bundle/plugin-compat-warning.js.map +0 -1
  229. package/lib/planetscale/api.d.ts +0 -53
  230. package/lib/planetscale/api.d.ts.map +0 -1
  231. package/lib/planetscale/api.js +0 -84
  232. package/lib/planetscale/api.js.map +0 -1
  233. package/src/cloudflare/bundle/externals.ts +0 -36
  234. package/src/cloudflare/bundle/plugin-compat-warning.ts +0 -94
  235. package/src/planetscale/api.ts +0 -115
@@ -1,4 +1,5 @@
1
- import type { PlanetScaleApi } from "./api.ts";
1
+ import type { PlanetScaleClient } from "./api/client.gen.ts";
2
+ import type { DatabaseBranch } from "./api/types.gen.ts";
2
3
 
3
4
  export type PlanetScaleClusterSize =
4
5
  | "PS_DEV"
@@ -22,168 +23,328 @@ export type PlanetScaleClusterSize =
22
23
  | (string & {});
23
24
 
24
25
  /**
25
- * Wait for a database to be ready with exponential backoff
26
+ * Ensures that the given cluster size is in the correct format.
26
27
  */
27
- export async function waitForDatabaseReady(
28
- api: PlanetScaleApi,
29
- organizationId: string,
30
- databaseName: string,
31
- branchName?: string,
32
- ): Promise<void> {
33
- const startTime = Date.now();
34
- let waitMs = 100;
35
- const branchSuffix = branchName ? `/branches/${branchName}` : "";
36
- while (true) {
37
- const response = await api.get(
38
- `/organizations/${organizationId}/databases/${databaseName}${branchSuffix}`,
39
- );
40
-
41
- if (!response.ok) {
42
- throw new Error(`Failed to check database state: ${response.statusText}`);
43
- }
44
-
45
- const data = await response.json<any>();
46
- if (data.ready === true) {
47
- return;
48
- }
28
+ export function sanitizeClusterSize(input: {
29
+ size: PlanetScaleClusterSize;
30
+ kind?: "mysql" | "postgresql";
31
+ arch?: "x86" | "arm";
32
+ region?: string;
33
+ }): string {
34
+ // NAS-backed Postgres cluster sizes are formatted as `PS_<size>_<provider>_<arch>`,
35
+ // where <provider> is either "AWS" or "GCP", and <arch> is either "ARM" or "X86".
36
+ // Postgres clusters backed by PlanetScale Metal are more complex (e.g. "M6_160_AWS_INTEL_D_METAL_118"),
37
+ // so to avoid messing with those, we just check for AWS or GCP in the size.
38
+ if (input.kind === "postgresql" && !input.size.match(/(AWS|GCP)/)) {
39
+ // Infer the provider from the region.
40
+ // Not all AWS regions start with "aws-", but all GCP regions start with "gcp-".
41
+ const provider = input.region?.startsWith("gcp") ? "GCP" : "AWS";
42
+ const arch = (input.arch ?? "x86").toUpperCase();
43
+ return `${input.size}_${provider}_${arch}`;
44
+ }
45
+ return input.size;
46
+ }
49
47
 
50
- if (Date.now() - startTime >= 60000) {
51
- throw new Error(
52
- `Timeout waiting for database ${databaseName} to be ready`,
53
- );
54
- }
48
+ /**
49
+ * Polls a branch until it is ready.
50
+ */
51
+ export async function waitForBranchReady(
52
+ api: PlanetScaleClient,
53
+ organization: string,
54
+ database: string,
55
+ branch: string,
56
+ ): Promise<DatabaseBranch> {
57
+ return await poll({
58
+ description: `branch "${branch}" ready`,
59
+ fn: () =>
60
+ api.organizations.databases.branches.get({
61
+ path: { organization, database, name: branch },
62
+ }),
63
+ predicate: (branch) => branch.ready,
64
+ });
65
+ }
55
66
 
56
- await new Promise((resolve) => setTimeout(resolve, waitMs));
57
- waitMs = Math.min(waitMs * 2, 1000); // Cap at 1s intervals
58
- }
67
+ /**
68
+ * Polls a database until it is ready.
69
+ */
70
+ export async function waitForDatabaseReady(
71
+ api: PlanetScaleClient,
72
+ organization: string,
73
+ database: string,
74
+ ): Promise<void> {
75
+ await poll({
76
+ description: `database "${database}" ready`,
77
+ fn: () =>
78
+ api.organizations.databases.get({
79
+ path: { organization, name: database },
80
+ }),
81
+ predicate: (database) => database.ready,
82
+ });
59
83
  }
60
84
 
61
85
  /**
62
- * Wait for a keyspace to be ready
86
+ * Polls a keyspace until it is finished resizing.
63
87
  */
64
88
  export async function waitForKeyspaceReady(
65
- api: PlanetScaleApi,
66
- org: string,
67
- db: string,
89
+ api: PlanetScaleClient,
90
+ organization: string,
91
+ database: string,
68
92
  branch: string,
69
93
  keyspace: string,
70
94
  ): Promise<void> {
71
- const start = Date.now();
72
- let delay = 100;
95
+ await poll({
96
+ description: `keyspace "${keyspace}" ready`,
97
+ fn: () =>
98
+ api.organizations.databases.branches.keyspaces.resizes.list({
99
+ path: { organization, database, branch, name: keyspace },
100
+ }),
101
+ predicate: (response) =>
102
+ response.data.every((item) => item.state !== "resizing"),
103
+ initialDelay: 100,
104
+ maxDelay: 1000,
105
+ });
106
+ }
73
107
 
74
- while (true) {
75
- const res = await api.get(
76
- `/organizations/${org}/databases/${db}/branches/${branch}/keyspaces/${keyspace}/resizes`,
77
- );
78
- if (!res.ok) {
79
- throw new Error(
80
- `Error fetching keyspace "${keyspace}": ${res.statusText}`,
108
+ /**
109
+ * Ensure a branch is production and has the correct cluster size.
110
+ * If a branch is not production, it will be promoted to production because
111
+ * cluster sizes can only be configured for production branches.
112
+ */
113
+ export async function ensureProductionBranchClusterSize(
114
+ api: PlanetScaleClient,
115
+ organization: string,
116
+ database: string,
117
+ branch: string,
118
+ kind: "mysql" | "postgresql",
119
+ expectedClusterSize: PlanetScaleClusterSize,
120
+ ): Promise<void> {
121
+ switch (kind) {
122
+ case "mysql": {
123
+ // Vitess databases must be promoted before resizing
124
+ await ensureProductionBranch(api, organization, database, branch);
125
+ await ensureMySQLClusterSize(
126
+ api,
127
+ organization,
128
+ database,
129
+ branch,
130
+ expectedClusterSize,
81
131
  );
132
+ break;
82
133
  }
83
-
84
- const ks = await res.json<{
85
- data: [
86
- {
87
- state: string;
88
- },
89
- ];
90
- }>();
91
- // once it's fully ready, we can proceed
92
- if (ks.data.every((item) => item.state !== "resizing")) {
93
- return;
134
+ case "postgresql": {
135
+ // Postgres databases must be resized first before promoting, otherwise 500 error
136
+ await ensurePostgresClusterSize(
137
+ api,
138
+ organization,
139
+ database,
140
+ branch,
141
+ expectedClusterSize,
142
+ );
143
+ await ensureProductionBranch(api, organization, database, branch);
144
+ break;
94
145
  }
146
+ }
147
+ }
95
148
 
96
- if (Date.now() - start > 600_000) {
97
- throw new Error(`Timeout waiting for keyspace "${keyspace}" to be ready`);
149
+ /**
150
+ * Checks if a branch is production and promotes it if it is not.
151
+ */
152
+ async function ensureProductionBranch(
153
+ api: PlanetScaleClient,
154
+ organization: string,
155
+ database: string,
156
+ name: string,
157
+ ): Promise<void> {
158
+ const data = await api.organizations.databases.branches.get({
159
+ path: {
160
+ organization,
161
+ database,
162
+ name,
163
+ },
164
+ });
165
+ if (!data.production) {
166
+ if (!data.ready) {
167
+ await waitForBranchReady(api, organization, database, name);
98
168
  }
99
-
100
- await new Promise((r) => setTimeout(r, delay));
101
- delay = Math.min(delay * 2, 1_000);
169
+ await api.organizations.databases.branches.promote.post({
170
+ path: { organization, database, name },
171
+ });
102
172
  }
103
173
  }
104
174
 
105
175
  /**
106
- * Fix cluster size for a branch
176
+ * Ensures that a MySQL branch has the correct cluster size.
107
177
  */
108
- export async function fixClusterSize(
109
- api: PlanetScaleApi,
110
- organizationId: string,
111
- databaseName: string,
112
- branchName: string,
178
+ async function ensureMySQLClusterSize(
179
+ api: PlanetScaleClient,
180
+ organization: string,
181
+ database: string,
182
+ branch: string,
113
183
  expectedClusterSize: PlanetScaleClusterSize,
114
- isDBReady: boolean,
115
184
  ): Promise<void> {
116
- if (!isDBReady) {
117
- await waitForDatabaseReady(api, organizationId, databaseName);
118
- }
119
-
120
- // 1. Ensure branch is production
121
- let branchRes = await api.get(
122
- `/organizations/${organizationId}/databases/${databaseName}/branches/${branchName}`,
123
- );
124
- if (!branchRes.ok) {
125
- throw new Error(`Unable to get branch data: ${branchRes.statusText}`);
126
- }
127
- let branchData = await branchRes.json<any>();
128
- if (!branchData.production) {
129
- if (!branchData.ready) {
130
- await waitForDatabaseReady(api, organizationId, databaseName, branchName);
131
- }
132
- const promoteRes = await api.post(
133
- `/organizations/${organizationId}/databases/${databaseName}/branches/${branchName}/promote`,
134
- );
135
- if (!promoteRes.ok) {
136
- throw new Error(`Unable to promote branch: ${promoteRes.statusText}`);
137
- }
185
+ // 1. Load default keyspace
186
+ const keyspaces = await api.organizations.databases.branches.keyspaces.list({
187
+ path: {
188
+ organization,
189
+ database,
190
+ branch,
191
+ },
192
+ });
193
+ const defaultKeyspace = keyspaces.data.find((x) => x.name === database); // Default keyspace is always the same name as the database
194
+ if (!defaultKeyspace) {
195
+ throw new Error(`No default keyspace found for branch ${branch}`);
138
196
  }
139
- // 2. Load default keyspace
140
- const ksListRes = await api.get(
141
- `/organizations/${organizationId}/databases/${databaseName}/branches/${branchName}/keyspaces`,
142
- );
143
- if (!ksListRes.ok) {
144
- throw new Error(`Failed to list keyspaces: ${ksListRes.statusText}`);
145
- }
146
- const ksList = (await ksListRes.json<any>()).data as Array<{
147
- name: string;
148
- cluster_name: string;
149
- }>;
150
- const defaultKsData = ksList.find((x) => x.name === databaseName); // Default keypsace is always the same name as the database
151
- if (ksList.length === 0 || !defaultKsData) {
152
- throw new Error(`No default keyspace found for branch ${branchName}`);
153
- }
154
- const defaultKs = defaultKsData.name;
155
- let currentSize = defaultKsData.cluster_name as PlanetScaleClusterSize;
156
197
 
157
- // 3. Wait until any in-flight resize is done
198
+ // 2. Wait until any in-flight resize is done
158
199
  await waitForKeyspaceReady(
159
200
  api,
160
- organizationId,
161
- databaseName,
162
- branchName,
163
- defaultKs,
201
+ organization,
202
+ database,
203
+ branch,
204
+ defaultKeyspace.name,
164
205
  );
165
206
 
166
- // 4. If size mismatch, trigger resize and wait again
207
+ // 3. If size mismatch, trigger resize and wait again
167
208
  // Ideally this would use the undocumented Keyspaces API, but there seems to be a missing oauth scope that we cannot add via the console yet
168
- if (currentSize !== expectedClusterSize) {
169
- const resizeRes = await api.patch(
170
- `/organizations/${organizationId}/databases/${databaseName}/branches/${branchName}/cluster`,
171
- { cluster_size: expectedClusterSize },
172
- );
173
- if (!resizeRes.ok) {
174
- const text = await resizeRes.text();
175
- throw new Error(
176
- `Failed to start resize: ${resizeRes.statusText} ${text}`,
177
- );
178
- }
209
+ if (defaultKeyspace.cluster_name !== expectedClusterSize) {
210
+ await api.organizations.databases.branches.cluster.patch({
211
+ path: {
212
+ organization,
213
+ database,
214
+ name: branch,
215
+ },
216
+ body: { cluster_size: expectedClusterSize },
217
+ });
179
218
 
180
219
  // Poll until the resize completes
181
220
  await waitForKeyspaceReady(
182
221
  api,
183
- organizationId,
184
- databaseName,
185
- branchName,
186
- defaultKs,
222
+ organization,
223
+ database,
224
+ branch,
225
+ defaultKeyspace.name,
187
226
  );
188
227
  }
189
228
  }
229
+
230
+ /**
231
+ * Ensures that a Postgres branch has the correct cluster size.
232
+ */
233
+ async function ensurePostgresClusterSize(
234
+ api: PlanetScaleClient,
235
+ organization: string,
236
+ database: string,
237
+ branch: string,
238
+ expectedClusterSize: PlanetScaleClusterSize,
239
+ ): Promise<void> {
240
+ const data = await api.organizations.databases.branches.get({
241
+ path: {
242
+ organization,
243
+ database,
244
+ name: branch,
245
+ },
246
+ });
247
+ if (data.cluster_name === expectedClusterSize) {
248
+ return;
249
+ }
250
+ await waitForPendingPostgresChanges(api, organization, database, branch);
251
+ const change = await api.organizations.databases.branches.changes.patch({
252
+ path: {
253
+ organization,
254
+ database,
255
+ branch,
256
+ },
257
+ body: {
258
+ cluster_size: expectedClusterSize,
259
+ },
260
+ });
261
+ await waitForPendingPostgresChanges(
262
+ api,
263
+ organization,
264
+ database,
265
+ branch,
266
+ change.id,
267
+ );
268
+ }
269
+
270
+ /**
271
+ * Polls for a pending Postgres change to be completed.
272
+ */
273
+ async function waitForPendingPostgresChanges(
274
+ api: PlanetScaleClient,
275
+ organization: string,
276
+ database: string,
277
+ branch: string,
278
+ changeId?: string,
279
+ ) {
280
+ await poll({
281
+ description: `changes for branch "${branch}"`,
282
+ fn: () =>
283
+ api.organizations.databases.branches.changes.list({
284
+ path: { organization, database, branch },
285
+ }),
286
+ predicate: (response) =>
287
+ response.data.every(
288
+ (change) =>
289
+ change.state === "completed" ||
290
+ change.state === "canceled" ||
291
+ (changeId && change.id === changeId),
292
+ ),
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Polls a function until it returns a result that satisfies the predicate.
298
+ */
299
+ async function poll<T>(input: {
300
+ /**
301
+ * A description of the operation being polled.
302
+ */
303
+ description: string;
304
+
305
+ /**
306
+ * The function to poll.
307
+ */
308
+ fn: () => Promise<T>;
309
+
310
+ /**
311
+ * A predicate that determines if the operation has completed.
312
+ */
313
+ predicate: (result: T) => boolean;
314
+
315
+ /**
316
+ * The initial delay between polls.
317
+ * @default 250ms
318
+ */
319
+ initialDelay?: number;
320
+
321
+ /**
322
+ * The maximum delay between polls.
323
+ * @default 2_500ms (2.5 seconds)
324
+ */
325
+ maxDelay?: number;
326
+
327
+ /**
328
+ * The timeout for the poll in milliseconds.
329
+ * @default 1_000_000 (~16 minutes)
330
+ */
331
+ timeout?: number;
332
+ }): Promise<T> {
333
+ const start = Date.now();
334
+ let delay = input.initialDelay ?? 250;
335
+ while (true) {
336
+ const result = await input.fn();
337
+ if (input.predicate(result)) {
338
+ return result;
339
+ }
340
+ if (Date.now() - start >= (input.timeout ?? 1_000_000)) {
341
+ throw new Error(
342
+ `Timed out waiting for ${input.description} after ${Math.round(
343
+ (input.timeout ?? 1_000_000) / 1000,
344
+ )}s`,
345
+ );
346
+ }
347
+ await new Promise((resolve) => setTimeout(resolve, delay));
348
+ delay = Math.min(delay * 2, input.maxDelay ?? 5_000);
349
+ }
350
+ }
package/src/scope.ts CHANGED
@@ -288,6 +288,11 @@ export class Scope {
288
288
  this.dataMutex = new AsyncMutex();
289
289
  }
290
290
 
291
+ public async has(id: string, type?: string): Promise<boolean> {
292
+ const state = await this.state.get(id);
293
+ return state !== undefined && (type === undefined || state.kind === type);
294
+ }
295
+
291
296
  public createPhysicalName(id: string, delimiter = "-"): string {
292
297
  const app = this.appName;
293
298
  const stage = this.stage;
@@ -489,9 +494,9 @@ export class Scope {
489
494
  return null;
490
495
  }
491
496
 
492
- public async finalize(force?: boolean) {
497
+ public async finalize(options?: { force?: boolean; noop?: boolean }) {
493
498
  const shouldForce =
494
- force ||
499
+ options?.force ||
495
500
  this.parent === undefined ||
496
501
  this?.parent?.scopeName === this.root.scopeName;
497
502
  if (this.phase === "read") {
@@ -520,7 +525,10 @@ export class Scope {
520
525
  await this.destroyPendingDeletions();
521
526
  await Promise.all(
522
527
  Array.from(this.children.values()).map((child) =>
523
- child.finalize(shouldForce),
528
+ child.finalize({
529
+ force: shouldForce,
530
+ noop: options?.noop,
531
+ }),
524
532
  ),
525
533
  );
526
534
  }
@@ -541,6 +549,7 @@ export class Scope {
541
549
  quiet: this.quiet,
542
550
  strategy: this.destroyStrategy,
543
551
  force: shouldForce,
552
+ noop: options?.noop,
544
553
  });
545
554
  this.rootTelemetryClient?.record({
546
555
  event: "app.success",
package/src/secret.ts CHANGED
@@ -17,8 +17,6 @@ function nextName() {
17
17
  return `alchemy:anonymous-secret-${i++}`;
18
18
  }
19
19
 
20
- const SecretSymbol = Symbol.for("alchemy::Secret");
21
-
22
20
  /**
23
21
  * Internal wrapper for sensitive values like API keys and credentials.
24
22
  * When stored in alchemy state files, the value is automatically encrypted
@@ -55,8 +53,6 @@ const SecretSymbol = Symbol.for("alchemy::Secret");
55
53
  * }
56
54
  */
57
55
  export class Secret<T = string> {
58
- [SecretSymbol] = true;
59
-
60
56
  /**
61
57
  * @internal
62
58
  */
@@ -47,13 +47,13 @@ export class CloudflareStateStore extends StateStoreProxy {
47
47
  options: CloudflareStateStoreOptions & { stateToken: Secret<string> };
48
48
  constructor(scope: Scope, options: CloudflareStateStoreOptions = {}) {
49
49
  super(scope);
50
- const stateToken =
51
- options.stateToken ?? alchemy.secret(process.env.ALCHEMY_STATE_TOKEN);
52
- if (!stateToken) {
50
+ if (!options.stateToken && !process.env.ALCHEMY_STATE_TOKEN) {
53
51
  throw new Error(
54
- "Missing token for DOStateStore. Please set ALCHEMY_STATE_TOKEN in the environment or set the `stateToken` option in the DOStateStore constructor.",
52
+ "Missing token for CloudflareStateStore. Please set ALCHEMY_STATE_TOKEN in the environment or set the `stateToken` option in the CloudflareStateStore constructor. See https://alchemy.run/guides/cloudflare-state-store/",
55
53
  );
56
54
  }
55
+ const stateToken =
56
+ options.stateToken ?? alchemy.secret(process.env.ALCHEMY_STATE_TOKEN);
57
57
  this.options = {
58
58
  ...options,
59
59
  stateToken: stateToken,
package/src/test/bun.ts CHANGED
@@ -53,9 +53,9 @@ type test = {
53
53
  */
54
54
  skipIf(condition: boolean): test;
55
55
 
56
- beforeAll(fn: (scope: Scope) => Promise<void>): void;
56
+ beforeAll(fn: (scope: Scope) => Promise<void>, timeout?: number): void;
57
57
 
58
- afterAll(fn: (scope: Scope) => Promise<void>): void;
58
+ afterAll(fn: (scope: Scope) => Promise<void>, timeout?: number): void;
59
59
 
60
60
  /**
61
61
  * Current test scope
@@ -57,9 +57,9 @@ type test = {
57
57
  */
58
58
  skipIf(condition: boolean): test;
59
59
 
60
- beforeAll(fn: (scope: Scope) => Promise<void>): void;
60
+ beforeAll(fn: (scope: Scope) => Promise<void>, timeout?: number): void;
61
61
 
62
- afterAll(fn: (scope: Scope) => Promise<void>): void;
62
+ afterAll(fn: (scope: Scope) => Promise<void>, timeout?: number): void;
63
63
 
64
64
  /**
65
65
  * Current test scope
@@ -134,12 +134,12 @@ export function test(
134
134
  password: process.env.ALCHEMY_PASSWORD,
135
135
  });
136
136
 
137
- test.beforeAll = (fn: (scope: Scope) => Promise<void>) => {
138
- return beforeAll(() => scope.run(() => fn(scope)));
137
+ test.beforeAll = (fn: (scope: Scope) => Promise<void>, timeout?: number) => {
138
+ return beforeAll(() => scope.run(() => fn(scope)), timeout);
139
139
  };
140
140
 
141
- test.afterAll = (fn: (scope: Scope) => Promise<void>) => {
142
- return afterAll(() => scope.run(() => fn(scope)));
141
+ test.afterAll = (fn: (scope: Scope) => Promise<void>, timeout?: number) => {
142
+ return afterAll(() => scope.run(() => fn(scope)), timeout);
143
143
  };
144
144
 
145
145
  return test as test;
@@ -11,10 +11,10 @@ const mimeTypes: Record<string, string> = {
11
11
  ".jpeg": "image/jpeg",
12
12
  ".jpg": "image/jpeg",
13
13
  ".js.map": "application/source-map",
14
- ".js": "application/javascript",
14
+ ".js": "text/javascript",
15
15
  ".json": "application/json",
16
16
  ".md": "text/markdown",
17
- ".mjs": "application/javascript+module",
17
+ ".mjs": "text/javascript",
18
18
  ".mp3": "audio/mpeg",
19
19
  ".mp4": "video/mp4",
20
20
  ".otf": "font/otf",