alchemy 0.64.0 → 0.65.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/bin/alchemy.js +7570 -5773
  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/cloudflare/bundle/plugin-node-compat.js +2 -2
  9. package/lib/cloudflare/bundle/plugin-node-compat.js.map +1 -1
  10. package/lib/cloudflare/bundle/plugin-wasm.d.ts.map +1 -1
  11. package/lib/cloudflare/bundle/plugin-wasm.js +20 -11
  12. package/lib/cloudflare/bundle/plugin-wasm.js.map +1 -1
  13. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  14. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  15. package/lib/cloudflare/hyperdrive.d.ts +7 -2
  16. package/lib/cloudflare/hyperdrive.d.ts.map +1 -1
  17. package/lib/cloudflare/hyperdrive.js +39 -57
  18. package/lib/cloudflare/hyperdrive.js.map +1 -1
  19. package/lib/cloudflare/index.d.ts +1 -0
  20. package/lib/cloudflare/index.d.ts.map +1 -1
  21. package/lib/cloudflare/index.js +1 -0
  22. package/lib/cloudflare/index.js.map +1 -1
  23. package/lib/cloudflare/next.d.ts +13 -0
  24. package/lib/cloudflare/next.d.ts.map +1 -0
  25. package/lib/cloudflare/next.js +91 -0
  26. package/lib/cloudflare/next.js.map +1 -0
  27. package/lib/cloudflare/worker-assets.d.ts +3 -3
  28. package/lib/cloudflare/worker-assets.d.ts.map +1 -1
  29. package/lib/cloudflare/worker-assets.js +22 -47
  30. package/lib/cloudflare/worker-assets.js.map +1 -1
  31. package/lib/cloudflare/wrangler.json.d.ts +2 -2
  32. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  33. package/lib/cloudflare/wrangler.json.js +4 -1
  34. package/lib/cloudflare/wrangler.json.js.map +1 -1
  35. package/lib/planetscale/branch.d.ts.map +1 -1
  36. package/lib/planetscale/branch.js +36 -25
  37. package/lib/planetscale/branch.js.map +1 -1
  38. package/lib/planetscale/database.d.ts +23 -6
  39. package/lib/planetscale/database.d.ts.map +1 -1
  40. package/lib/planetscale/database.js +11 -5
  41. package/lib/planetscale/database.js.map +1 -1
  42. package/lib/planetscale/index.d.ts +1 -0
  43. package/lib/planetscale/index.d.ts.map +1 -1
  44. package/lib/planetscale/index.js +1 -0
  45. package/lib/planetscale/index.js.map +1 -1
  46. package/lib/planetscale/password.d.ts +3 -1
  47. package/lib/planetscale/password.d.ts.map +1 -1
  48. package/lib/planetscale/password.js +3 -1
  49. package/lib/planetscale/password.js.map +1 -1
  50. package/lib/planetscale/role.d.ts +126 -0
  51. package/lib/planetscale/role.d.ts.map +1 -0
  52. package/lib/planetscale/role.js +144 -0
  53. package/lib/planetscale/role.js.map +1 -0
  54. package/lib/planetscale/utils.d.ts +17 -3
  55. package/lib/planetscale/utils.d.ts.map +1 -1
  56. package/lib/planetscale/utils.js +110 -31
  57. package/lib/planetscale/utils.js.map +1 -1
  58. package/lib/secret.d.ts +0 -3
  59. package/lib/secret.d.ts.map +1 -1
  60. package/lib/secret.js +0 -2
  61. package/lib/secret.js.map +1 -1
  62. package/lib/state/cloudflare-state-store.js +3 -3
  63. package/lib/state/cloudflare-state-store.js.map +1 -1
  64. package/lib/test/bun.d.ts +2 -2
  65. package/lib/test/bun.d.ts.map +1 -1
  66. package/lib/test/vitest.d.ts +2 -2
  67. package/lib/test/vitest.d.ts.map +1 -1
  68. package/lib/test/vitest.js +4 -4
  69. package/lib/test/vitest.js.map +1 -1
  70. package/lib/util/content-type.js +2 -2
  71. package/lib/util/content-type.js.map +1 -1
  72. package/lib/util/dedent.d.ts.map +1 -1
  73. package/lib/util/dedent.js +30 -18
  74. package/lib/util/dedent.js.map +1 -1
  75. package/lib/util/memoize.d.ts +1 -1
  76. package/lib/util/memoize.d.ts.map +1 -1
  77. package/lib/util/memoize.js +13 -3
  78. package/lib/util/memoize.js.map +1 -1
  79. package/package.json +5 -1
  80. package/src/cloudflare/bundle/plugin-node-compat.ts +2 -2
  81. package/src/cloudflare/bundle/plugin-wasm.ts +34 -14
  82. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  83. package/src/cloudflare/hyperdrive.ts +136 -95
  84. package/src/cloudflare/index.ts +1 -0
  85. package/src/cloudflare/next.ts +123 -0
  86. package/src/cloudflare/worker-assets.ts +43 -105
  87. package/src/cloudflare/wrangler.json.ts +6 -3
  88. package/src/planetscale/branch.ts +52 -27
  89. package/src/planetscale/database.ts +75 -46
  90. package/src/planetscale/index.ts +1 -0
  91. package/src/planetscale/password.ts +3 -1
  92. package/src/planetscale/role.ts +278 -0
  93. package/src/planetscale/utils.ts +172 -30
  94. package/src/secret.ts +0 -4
  95. package/src/state/cloudflare-state-store.ts +4 -4
  96. package/src/test/bun.ts +2 -2
  97. package/src/test/vitest.ts +6 -6
  98. package/src/util/content-type.ts +2 -2
  99. package/src/util/dedent.ts +33 -21
  100. package/src/util/memoize.ts +15 -3
  101. package/templates/nextjs/README.md +85 -0
  102. package/templates/nextjs/_env +1 -0
  103. package/templates/nextjs/_env.example +1 -0
  104. package/templates/nextjs/_gitignore +46 -0
  105. package/templates/nextjs/alchemy.run.ts +16 -0
  106. package/templates/nextjs/eslint.config.mjs +16 -0
  107. package/templates/nextjs/next-env.d.ts +5 -0
  108. package/templates/nextjs/next.config.ts +10 -0
  109. package/templates/nextjs/open-next.config.ts +5 -0
  110. package/templates/nextjs/package.json +31 -0
  111. package/templates/nextjs/src/app/api/hello/route.ts +19 -0
  112. package/templates/nextjs/src/app/favicon.ico +0 -0
  113. package/templates/nextjs/src/app/globals.css +72 -0
  114. package/templates/nextjs/src/app/layout.tsx +19 -0
  115. package/templates/nextjs/src/app/page.tsx +55 -0
  116. package/templates/nextjs/tsconfig.json +33 -0
  117. package/templates/nextjs/types/env.d.ts +14 -0
@@ -102,7 +102,9 @@ export interface Password
102
102
  }
103
103
 
104
104
  /**
105
- * Create and manage database passwords for PlanetScale branches. Database passwords provide secure access to your database with specific roles and permissions.
105
+ * Create and manage database passwords for PlanetScale MySQL branches. Database passwords provide secure access to your database with specific roles and permissions.
106
+ *
107
+ * For Postgres, use [Roles](./role.ts) instead.
106
108
  *
107
109
  * @example
108
110
  * ## Basic Reader Password
@@ -0,0 +1,278 @@
1
+ import { alchemy } from "../alchemy.ts";
2
+ import type { Context } from "../context.ts";
3
+ import { Resource } from "../resource.ts";
4
+ import type { Secret } from "../secret.ts";
5
+ import { logger } from "../util/logger.ts";
6
+ import { PlanetScaleClient, type PlanetScaleProps } from "./api/client.gen.ts";
7
+ import type { CreateRoleData } from "./api/types.gen.ts";
8
+ import type { Branch } from "./branch.ts";
9
+ import type { Database } from "./database.ts";
10
+ import { waitForBranchReady } from "./utils.ts";
11
+
12
+ /**
13
+ * Properties for creating or updating a PlanetScale PostgreSQL Role
14
+ */
15
+ export interface RoleProps extends PlanetScaleProps {
16
+ /**
17
+ * The organization ID where the role will be created
18
+ * Required when using string database name, optional when using Database resource
19
+ */
20
+ organizationId?: string;
21
+
22
+ /**
23
+ * The database where the role will be created
24
+ * Can be either a database name (string) or Database resource
25
+ */
26
+ database: string | Database;
27
+
28
+ /**
29
+ * The branch where the role will be created
30
+ * Can be either a branch name (string) or Branch resource
31
+ * @default "main"
32
+ */
33
+ branch?: string | Branch;
34
+
35
+ /**
36
+ * Time to live in seconds
37
+ */
38
+ ttl?: number;
39
+
40
+ /**
41
+ * Roles to inherit from.
42
+ * The `"postgres"` role provides full administrator access to the database.
43
+ * You can also inherit from another Role resource.
44
+ */
45
+ inheritedRoles: InheritedRole[] | Role;
46
+ }
47
+
48
+ /**
49
+ * Roles that can be inherited from.
50
+ */
51
+ export type InheritedRole =
52
+ | "postgres"
53
+ | "pg_checkpoint"
54
+ | "pg_create_subscription"
55
+ | "pg_maintain"
56
+ | "pg_monitor"
57
+ | "pg_read_all_data"
58
+ | "pg_read_all_settings"
59
+ | "pg_read_all_stats"
60
+ | "pg_signal_backend"
61
+ | "pg_stat_scan_tables"
62
+ | "pg_use_reserved_connections"
63
+ | "pg_write_all_data"
64
+ | (string & {});
65
+
66
+ export interface Role
67
+ extends Resource<"planetscale::Role">,
68
+ Omit<RoleProps, "inheritedRoles"> {
69
+ /**
70
+ * The unique identifier for the role
71
+ */
72
+ id: string;
73
+
74
+ /**
75
+ * The name of the role
76
+ */
77
+ name: string;
78
+
79
+ /**
80
+ * The timestamp when the role expires (ISO 8601 format)
81
+ */
82
+ expiresAt: string;
83
+
84
+ /**
85
+ * The host URL for database connection
86
+ */
87
+ host: string;
88
+
89
+ /**
90
+ * The username for database authentication
91
+ */
92
+ username: string;
93
+
94
+ /**
95
+ * The encrypted password for database authentication
96
+ */
97
+ password: Secret<string>;
98
+
99
+ /**
100
+ * The database name
101
+ */
102
+ databaseName: string;
103
+
104
+ /**
105
+ * The direct connection URL for the database.
106
+ */
107
+ connectionUrl: Secret<string>;
108
+
109
+ /**
110
+ * The pooled connection URL for the database.
111
+ * Uses PSBouncer on port 6432. Recommended for production.
112
+ * @see https://planetscale.com/docs/postgres/connecting/psbouncer
113
+ */
114
+ connectionUrlPooled: Secret<string>;
115
+
116
+ /**
117
+ * The roles that this role inherits from.
118
+ */
119
+ inheritedRoles: InheritedRole[];
120
+ }
121
+
122
+ /**
123
+ * Create and manage database roles for PlanetScale PostgreSQL branches. Database roles provide secure access to your database with specific roles and permissions.
124
+ *
125
+ * For MySQL, use [Passwords](./password.ts) instead.
126
+ *
127
+ * @example
128
+ * ## Basic Role
129
+ *
130
+ * Create a default role with all permissions:
131
+ *
132
+ * ```ts
133
+ * const role = await Role("my-role", {
134
+ * database: "my-database",
135
+ * inheritedRoles: ["postgres"],
136
+ * });
137
+ * ```
138
+ *
139
+ * ## Role with TTL
140
+ *
141
+ * Create a role with a TTL of 1 hour:
142
+ *
143
+ * ```ts
144
+ * const role = await Role("my-role", {
145
+ * database: "my-database",
146
+ * ttl: 3600,
147
+ * });
148
+ * ```
149
+ *
150
+ * ## Role with Inherited Permissions
151
+ *
152
+ * Create a role with read-only access to all data and settings:
153
+ *
154
+ * ```ts
155
+ * const role = await Role("my-role", {
156
+ * database: "my-database",
157
+ * inheritedRoles: ["pg_read_all_data", "pg_read_all_settings"],
158
+ * });
159
+ * ```
160
+ */
161
+ export const Role = Resource(
162
+ "planetscale::Role",
163
+ async function (
164
+ this: Context<Role, RoleProps>,
165
+ id: string,
166
+ props: RoleProps,
167
+ ): Promise<Role> {
168
+ const api = new PlanetScaleClient(props);
169
+ const organization =
170
+ typeof props.branch === "object"
171
+ ? props.branch.organizationId
172
+ : typeof props.database === "object"
173
+ ? props.database.organizationId
174
+ : props.organizationId;
175
+ const database =
176
+ typeof props.database === "string" ? props.database : props.database.name;
177
+ const branch =
178
+ typeof props.branch === "string"
179
+ ? props.branch
180
+ : (props.branch?.name ?? "main");
181
+ const inheritedRoles = Array.isArray(props.inheritedRoles)
182
+ ? props.inheritedRoles
183
+ : props.inheritedRoles.inheritedRoles;
184
+ if (!organization) {
185
+ throw new Error("Organization ID is required");
186
+ }
187
+
188
+ switch (this.phase) {
189
+ case "delete": {
190
+ if (this.output?.id) {
191
+ const res = await api.organizations.databases.branches.roles.delete({
192
+ path: {
193
+ organization,
194
+ database,
195
+ branch,
196
+ id: this.output.id,
197
+ },
198
+ result: "full",
199
+ });
200
+ if (res.error) {
201
+ switch (res.error.status) {
202
+ case 404:
203
+ break;
204
+ case 422:
205
+ // This is a workaround for 422 unprocessable: Role is still referenced and cannot be dropped.
206
+ // Essentially, the role is still linked to a branch, so we can't delete it here, but it'll be deleted when the branch is deleted.
207
+ logger.warn(
208
+ [
209
+ `Failed to delete role "${id}" (name: "${this.output.name}").`,
210
+ `Error: ${res.error.message}`,
211
+ `If you are deleting database "${database}" or branch "${branch}", the role will be deleted automatically.`,
212
+ `Otherwise, consider manually deleting the role at: https://app.planetscale.com/${organization}/${database}/settings/roles`,
213
+ ].join("\n"),
214
+ );
215
+ break;
216
+ default:
217
+ throw new Error(`Failed to delete role "${id}"`, {
218
+ cause: res.error,
219
+ });
220
+ }
221
+ }
222
+ }
223
+ return this.destroy();
224
+ }
225
+ case "create": {
226
+ const { kind, ready } = await api.organizations.databases.branches.get({
227
+ path: {
228
+ organization,
229
+ database,
230
+ name: branch,
231
+ },
232
+ });
233
+ if (kind !== "postgresql") {
234
+ throw new Error(
235
+ `Cannot create a role on MySQL database "${database}". Roles are only supported on PostgreSQL databases. For MySQL databases, please use the Password resource instead.`,
236
+ );
237
+ }
238
+ // Cannot create role until branch is ready
239
+ if (!ready) {
240
+ await waitForBranchReady(api, organization, database, branch);
241
+ }
242
+ const role = await api.organizations.databases.branches.roles.post({
243
+ path: {
244
+ organization,
245
+ database,
246
+ branch,
247
+ },
248
+ body: {
249
+ ttl: props.ttl,
250
+ inherited_roles: inheritedRoles,
251
+ } as CreateRoleData["body"],
252
+ });
253
+ return this({
254
+ ...props,
255
+ id: role.id,
256
+ name: role.name,
257
+ host: role.access_host_url,
258
+ username: role.username,
259
+ password: alchemy.secret(role.password),
260
+ expiresAt: role.expires_at,
261
+ databaseName: role.database_name,
262
+ inheritedRoles,
263
+ connectionUrl: alchemy.secret(
264
+ `postgresql://${role.username}:${role.password}@${role.access_host_url}:5432/${role.database_name}?sslmode=verify-full`,
265
+ ),
266
+ connectionUrlPooled: alchemy.secret(
267
+ `postgresql://${role.username}:${role.password}@${role.access_host_url}:6432/${role.database_name}?sslmode=verify-full`,
268
+ ),
269
+ });
270
+ }
271
+ case "update": {
272
+ // According to the types, the only property that can be updated is the name.
273
+ // However, I was getting 500 errors when trying to update the name, so we'll just replace.
274
+ return this.replace();
275
+ }
276
+ }
277
+ },
278
+ );
@@ -1,4 +1,5 @@
1
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,27 +23,62 @@ 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.
27
+ */
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
+ }
47
+
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
+ }
66
+
67
+ /**
68
+ * Polls a database until it is ready.
26
69
  */
27
70
  export async function waitForDatabaseReady(
28
71
  api: PlanetScaleClient,
29
72
  organization: string,
30
73
  database: string,
31
- branch?: string,
32
74
  ): Promise<void> {
33
75
  await poll({
34
- description: `database "${database}" ${branch ? `branch "${branch}"` : ""} ready`,
35
- fn: async () => {
36
- if (branch) {
37
- return await api.organizations.databases.branches.get({
38
- path: { organization, database, name: branch },
39
- });
40
- }
41
- return await api.organizations.databases.get({
76
+ description: `database "${database}" ready`,
77
+ fn: () =>
78
+ api.organizations.databases.get({
42
79
  path: { organization, name: database },
43
- });
44
- },
45
- predicate: (item) => item.ready,
80
+ }),
81
+ predicate: (database) => database.ready,
46
82
  });
47
83
  }
48
84
 
@@ -79,34 +115,74 @@ export async function ensureProductionBranchClusterSize(
79
115
  organization: string,
80
116
  database: string,
81
117
  branch: string,
118
+ kind: "mysql" | "postgresql",
82
119
  expectedClusterSize: PlanetScaleClusterSize,
83
- isDBReady: boolean,
84
120
  ): Promise<void> {
85
- if (!isDBReady) {
86
- await waitForDatabaseReady(api, organization, database);
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,
131
+ );
132
+ break;
133
+ }
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;
145
+ }
87
146
  }
147
+ }
88
148
 
89
- // 1. Ensure branch is production
90
- const branchData = await api.organizations.databases.branches.get({
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({
91
159
  path: {
92
160
  organization,
93
161
  database,
94
- name: branch,
162
+ name,
95
163
  },
96
164
  });
97
- if (!branchData.production) {
98
- if (!branchData.ready) {
99
- await waitForDatabaseReady(api, organization, database, branch);
165
+ if (!data.production) {
166
+ if (!data.ready) {
167
+ await waitForBranchReady(api, organization, database, name);
100
168
  }
101
169
  await api.organizations.databases.branches.promote.post({
102
- path: {
103
- organization,
104
- database,
105
- name: branch,
106
- },
170
+ path: { organization, database, name },
107
171
  });
108
172
  }
109
- // 2. Load default keyspace
173
+ }
174
+
175
+ /**
176
+ * Ensures that a MySQL branch has the correct cluster size.
177
+ */
178
+ async function ensureMySQLClusterSize(
179
+ api: PlanetScaleClient,
180
+ organization: string,
181
+ database: string,
182
+ branch: string,
183
+ expectedClusterSize: PlanetScaleClusterSize,
184
+ ): Promise<void> {
185
+ // 1. Load default keyspace
110
186
  const keyspaces = await api.organizations.databases.branches.keyspaces.list({
111
187
  path: {
112
188
  organization,
@@ -119,7 +195,7 @@ export async function ensureProductionBranchClusterSize(
119
195
  throw new Error(`No default keyspace found for branch ${branch}`);
120
196
  }
121
197
 
122
- // 3. Wait until any in-flight resize is done
198
+ // 2. Wait until any in-flight resize is done
123
199
  await waitForKeyspaceReady(
124
200
  api,
125
201
  organization,
@@ -128,7 +204,7 @@ export async function ensureProductionBranchClusterSize(
128
204
  defaultKeyspace.name,
129
205
  );
130
206
 
131
- // 4. If size mismatch, trigger resize and wait again
207
+ // 3. If size mismatch, trigger resize and wait again
132
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
133
209
  if (defaultKeyspace.cluster_name !== expectedClusterSize) {
134
210
  await api.organizations.databases.branches.cluster.patch({
@@ -151,6 +227,72 @@ export async function ensureProductionBranchClusterSize(
151
227
  }
152
228
  }
153
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
+
154
296
  /**
155
297
  * Polls a function until it returns a result that satisfies the predicate.
156
298
  */
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",