alchemy 0.63.1 → 0.64.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 (157) hide show
  1. package/bin/alchemy.js +96 -86
  2. package/lib/alchemy.d.ts +4 -0
  3. package/lib/alchemy.d.ts.map +1 -1
  4. package/lib/alchemy.js +10 -4
  5. package/lib/alchemy.js.map +1 -1
  6. package/lib/apply.d.ts +3 -0
  7. package/lib/apply.d.ts.map +1 -1
  8. package/lib/apply.js +11 -1
  9. package/lib/apply.js.map +1 -1
  10. package/lib/cloudflare/bindings.d.ts +3 -1
  11. package/lib/cloudflare/bindings.d.ts.map +1 -1
  12. package/lib/cloudflare/bucket.d.ts +2 -1
  13. package/lib/cloudflare/bucket.d.ts.map +1 -1
  14. package/lib/cloudflare/bucket.js.map +1 -1
  15. package/lib/cloudflare/bundle/cloudflare-internal.d.ts +6 -0
  16. package/lib/cloudflare/bundle/cloudflare-internal.d.ts.map +1 -0
  17. package/lib/cloudflare/bundle/cloudflare-internal.js +39 -0
  18. package/lib/cloudflare/bundle/cloudflare-internal.js.map +1 -0
  19. package/lib/cloudflare/bundle/nodejs-builtin-modules.d.ts +2 -0
  20. package/lib/cloudflare/bundle/nodejs-builtin-modules.d.ts.map +1 -0
  21. package/lib/cloudflare/bundle/nodejs-builtin-modules.js +9 -0
  22. package/lib/cloudflare/bundle/nodejs-builtin-modules.js.map +1 -0
  23. package/lib/cloudflare/bundle/plugin-als-external.d.ts +6 -0
  24. package/lib/cloudflare/bundle/plugin-als-external.d.ts.map +1 -0
  25. package/lib/cloudflare/bundle/plugin-als-external.js +12 -0
  26. package/lib/cloudflare/bundle/plugin-als-external.js.map +1 -0
  27. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.d.ts +4 -1
  28. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.d.ts.map +1 -1
  29. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.js +23 -23
  30. package/lib/cloudflare/bundle/plugin-hybrid-node-compat.js.map +1 -1
  31. package/lib/cloudflare/bundle/plugin-node-compat.d.ts +11 -0
  32. package/lib/cloudflare/bundle/plugin-node-compat.d.ts.map +1 -0
  33. package/lib/cloudflare/bundle/plugin-node-compat.js +93 -0
  34. package/lib/cloudflare/bundle/plugin-node-compat.js.map +1 -0
  35. package/lib/cloudflare/d1-local-migrations.js.map +1 -1
  36. package/lib/cloudflare/durable-object-namespace.d.ts +1 -0
  37. package/lib/cloudflare/durable-object-namespace.d.ts.map +1 -1
  38. package/lib/cloudflare/durable-object-namespace.js.map +1 -1
  39. package/lib/cloudflare/website.d.ts.map +1 -1
  40. package/lib/cloudflare/website.js +80 -82
  41. package/lib/cloudflare/website.js.map +1 -1
  42. package/lib/cloudflare/worker-bundle.d.ts +6 -0
  43. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  44. package/lib/cloudflare/worker-bundle.js +34 -10
  45. package/lib/cloudflare/worker-bundle.js.map +1 -1
  46. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  47. package/lib/cloudflare/worker-metadata.js +1 -0
  48. package/lib/cloudflare/worker-metadata.js.map +1 -1
  49. package/lib/cloudflare/worker.d.ts.map +1 -1
  50. package/lib/cloudflare/worker.js +24 -6
  51. package/lib/cloudflare/worker.js.map +1 -1
  52. package/lib/cloudflare/wrangler.json.d.ts +2 -4
  53. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  54. package/lib/cloudflare/wrangler.json.js +16 -14
  55. package/lib/cloudflare/wrangler.json.js.map +1 -1
  56. package/lib/context.d.ts +3 -1
  57. package/lib/context.d.ts.map +1 -1
  58. package/lib/context.js +2 -2
  59. package/lib/context.js.map +1 -1
  60. package/lib/destroy.d.ts +8 -0
  61. package/lib/destroy.d.ts.map +1 -1
  62. package/lib/destroy.js +20 -4
  63. package/lib/destroy.js.map +1 -1
  64. package/lib/planetscale/api/client.gen.d.ts +1750 -0
  65. package/lib/planetscale/api/client.gen.d.ts.map +1 -0
  66. package/lib/planetscale/api/client.gen.js +2401 -0
  67. package/lib/planetscale/api/client.gen.js.map +1 -0
  68. package/lib/planetscale/api/index.d.ts +3 -0
  69. package/lib/planetscale/api/index.d.ts.map +1 -0
  70. package/lib/planetscale/api/index.js +4 -0
  71. package/lib/planetscale/api/index.js.map +1 -0
  72. package/lib/planetscale/api/types.gen.d.ts +12156 -0
  73. package/lib/planetscale/api/types.gen.d.ts.map +1 -0
  74. package/lib/planetscale/api/types.gen.js +2 -0
  75. package/lib/planetscale/api/types.gen.js.map +1 -0
  76. package/lib/planetscale/branch.d.ts +5 -8
  77. package/lib/planetscale/branch.d.ts.map +1 -1
  78. package/lib/planetscale/branch.js +111 -97
  79. package/lib/planetscale/branch.js.map +1 -1
  80. package/lib/planetscale/database.d.ts +2 -6
  81. package/lib/planetscale/database.d.ts.map +1 -1
  82. package/lib/planetscale/database.js +157 -118
  83. package/lib/planetscale/database.js.map +1 -1
  84. package/lib/planetscale/index.d.ts +1 -1
  85. package/lib/planetscale/index.d.ts.map +1 -1
  86. package/lib/planetscale/index.js +1 -1
  87. package/lib/planetscale/index.js.map +1 -1
  88. package/lib/planetscale/password.d.ts +4 -7
  89. package/lib/planetscale/password.d.ts.map +1 -1
  90. package/lib/planetscale/password.js +71 -56
  91. package/lib/planetscale/password.js.map +1 -1
  92. package/lib/planetscale/utils.d.ts +8 -6
  93. package/lib/planetscale/utils.d.ts.map +1 -1
  94. package/lib/planetscale/utils.js +85 -70
  95. package/lib/planetscale/utils.js.map +1 -1
  96. package/lib/scope.d.ts +5 -1
  97. package/lib/scope.d.ts.map +1 -1
  98. package/lib/scope.js +11 -3
  99. package/lib/scope.js.map +1 -1
  100. package/lib/util/dedent.d.ts +3 -14
  101. package/lib/util/dedent.d.ts.map +1 -1
  102. package/lib/util/dedent.js +28 -52
  103. package/lib/util/dedent.js.map +1 -1
  104. package/package.json +4 -3
  105. package/src/alchemy.ts +14 -6
  106. package/src/apply.ts +13 -1
  107. package/src/cloudflare/bindings.ts +3 -1
  108. package/src/cloudflare/bucket.ts +3 -1
  109. package/src/cloudflare/bundle/cloudflare-internal.ts +42 -0
  110. package/src/cloudflare/bundle/nodejs-builtin-modules.ts +14 -0
  111. package/src/cloudflare/bundle/plugin-als-external.ts +13 -0
  112. package/src/cloudflare/bundle/plugin-hybrid-node-compat.ts +29 -28
  113. package/src/cloudflare/bundle/plugin-node-compat.ts +114 -0
  114. package/src/cloudflare/d1-local-migrations.ts +2 -2
  115. package/src/cloudflare/durable-object-namespace.ts +1 -0
  116. package/src/cloudflare/website.ts +88 -89
  117. package/src/cloudflare/worker-bundle.ts +46 -9
  118. package/src/cloudflare/worker-metadata.ts +2 -0
  119. package/src/cloudflare/worker.ts +35 -7
  120. package/src/cloudflare/wrangler.json.ts +108 -110
  121. package/src/context.ts +5 -3
  122. package/src/destroy.ts +26 -8
  123. package/src/planetscale/api/client.gen.ts +2882 -0
  124. package/src/planetscale/api/index.ts +4 -0
  125. package/src/planetscale/api/types.gen.ts +13041 -0
  126. package/src/planetscale/branch.ts +152 -169
  127. package/src/planetscale/database.ts +195 -204
  128. package/src/planetscale/index.ts +1 -1
  129. package/src/planetscale/password.ts +86 -85
  130. package/src/planetscale/utils.ts +141 -122
  131. package/src/scope.ts +12 -3
  132. package/src/util/dedent.ts +33 -61
  133. package/templates/astro/alchemy.run.ts +1 -3
  134. package/templates/hono/alchemy.run.ts +0 -1
  135. package/templates/nuxt/alchemy.run.ts +1 -3
  136. package/templates/react-router/alchemy.run.ts +1 -3
  137. package/templates/rwsdk/alchemy.run.ts +0 -2
  138. package/templates/sveltekit/alchemy.run.ts +1 -3
  139. package/templates/tanstack-start/alchemy.run.ts +1 -3
  140. package/templates/typescript/alchemy.run.ts +0 -1
  141. package/templates/vite/alchemy.run.ts +0 -1
  142. package/workers/cloudflare-state-store.js +58 -58
  143. package/lib/cloudflare/bundle/externals.d.ts +0 -3
  144. package/lib/cloudflare/bundle/externals.d.ts.map +0 -1
  145. package/lib/cloudflare/bundle/externals.js +0 -35
  146. package/lib/cloudflare/bundle/externals.js.map +0 -1
  147. package/lib/cloudflare/bundle/plugin-compat-warning.d.ts +0 -10
  148. package/lib/cloudflare/bundle/plugin-compat-warning.d.ts.map +0 -1
  149. package/lib/cloudflare/bundle/plugin-compat-warning.js +0 -67
  150. package/lib/cloudflare/bundle/plugin-compat-warning.js.map +0 -1
  151. package/lib/planetscale/api.d.ts +0 -53
  152. package/lib/planetscale/api.d.ts.map +0 -1
  153. package/lib/planetscale/api.js +0 -84
  154. package/lib/planetscale/api.js.map +0 -1
  155. package/src/cloudflare/bundle/externals.ts +0 -36
  156. package/src/cloudflare/bundle/plugin-compat-warning.ts +0 -94
  157. package/src/planetscale/api.ts +0 -115
@@ -1,17 +1,17 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { alchemy } from "../alchemy.ts";
2
3
  import type { Context } from "../context.ts";
3
4
  import { Resource } from "../resource.ts";
4
5
  import type { Secret } from "../secret.ts";
5
- import { logger } from "../util/logger.ts";
6
6
  import { lowercaseId } from "../util/nanoid.ts";
7
- import { PlanetScaleApi } from "./api.ts";
7
+ import { PlanetScaleClient, type PlanetScaleProps } from "./api/client.gen.ts";
8
8
  import type { Branch } from "./branch.ts";
9
9
  import type { Database } from "./database.ts";
10
10
 
11
11
  /**
12
12
  * Properties for creating or updating a PlanetScale Branch
13
13
  */
14
- export interface PasswordProps {
14
+ export interface PasswordProps extends PlanetScaleProps {
15
15
  /**
16
16
  * The name of the password
17
17
  *
@@ -21,7 +21,7 @@ export interface PasswordProps {
21
21
 
22
22
  /**
23
23
  * The organization ID where the password will be created
24
- * Required when using string database name, optional when using Database resource
24
+ * Required when using string database name, optional when using Database or Branch resource
25
25
  */
26
26
  organizationId?: string;
27
27
 
@@ -34,15 +34,10 @@ export interface PasswordProps {
34
34
  /**
35
35
  * The branch where the password will be created
36
36
  * Can be either a branch name (string) or Branch resource
37
- * Defaults to "main" if not provided
37
+ * @default "main"
38
38
  */
39
39
  branch?: string | Branch;
40
40
 
41
- /**
42
- * PlanetScale API token (overrides environment variable)
43
- */
44
- apiKey?: Secret;
45
-
46
41
  /**
47
42
  * The password
48
43
  */
@@ -265,109 +260,115 @@ export const Password = Resource(
265
260
  id: string,
266
261
  props: PasswordProps,
267
262
  ): Promise<Password> {
268
- const apiKey =
269
- props.apiKey?.unencrypted || process.env.PLANETSCALE_API_TOKEN;
270
- if (!apiKey) {
271
- throw new Error("PLANETSCALE_API_TOKEN environment variable is required");
272
- }
273
263
  const nameSlug = this.isReplacement
274
264
  ? lowercaseId()
275
265
  : (this.output?.nameSlug ?? lowercaseId());
276
266
  const name = `${(props.name ?? this.output?.name ?? this.scope.createPhysicalName(id)).toLowerCase()}-${nameSlug}`;
277
267
 
278
- const api = new PlanetScaleApi({ apiKey });
279
- const branchName =
280
- props.branch == null
281
- ? "main"
282
- : typeof props.branch === "string"
283
- ? props.branch
284
- : props.branch.name;
285
- const databaseName =
268
+ const api = new PlanetScaleClient(props);
269
+ const database =
286
270
  typeof props.database === "string" ? props.database : props.database.name;
271
+ const branch =
272
+ typeof props.branch === "string"
273
+ ? props.branch
274
+ : (props.branch?.name ?? "main");
275
+ const organization =
276
+ props.organizationId ??
277
+ ((typeof props.branch !== "string" && props.branch?.organizationId) ||
278
+ (typeof props.database !== "string" && props.database.organizationId));
279
+
280
+ if (!organization) {
281
+ throw new Error("Organization ID is required");
282
+ }
287
283
 
288
284
  if (this.phase === "delete") {
289
- try {
290
- if (this.output?.name) {
291
- const response = await api.delete(
292
- `/organizations/${props.organizationId}/databases/${databaseName}/branches/${branchName}/passwords/${this.output.id}`,
293
- );
285
+ if (this.output?.id) {
286
+ const res = await api.organizations.databases.branches.passwords.delete(
287
+ {
288
+ path: {
289
+ organization,
290
+ database,
291
+ branch,
292
+ id: this.output.id,
293
+ },
294
+ result: "full",
295
+ },
296
+ );
294
297
 
295
- if (!response.ok && response.status !== 404) {
296
- throw new Error(
297
- `Failed to delete branch: ${response.statusText} ${await response.text()}`,
298
- );
299
- }
298
+ if (res.error && res.error.status !== 404) {
299
+ throw new Error(`Failed to delete branch "${branch}"`, {
300
+ cause: res.error,
301
+ });
300
302
  }
301
- } catch (error) {
302
- logger.error("Error deleting password:", error);
303
- throw error;
304
303
  }
305
304
  return this.destroy();
306
305
  }
307
306
  if (this.phase === "update") {
307
+ // Only name and cidrs can be updated in place; all other properties require replacement.
308
308
  if (
309
- this.output?.name === name &&
310
- ((this.output?.cidrs === undefined && props.cidrs === undefined) ||
311
- (Array.isArray(this.output?.cidrs) &&
312
- Array.isArray(props.cidrs) &&
313
- this.output.cidrs.length === props.cidrs.length &&
314
- this.output.cidrs.every((cidr, i) => cidr === props.cidrs![i])))
309
+ diff({ ...props, name }, this.output).some(
310
+ (prop) => prop !== "name" && prop !== "cidrs",
311
+ )
315
312
  ) {
316
313
  return this.replace();
317
314
  }
318
- const updateResponse = await api.patch(
319
- `/organizations/${props.organizationId}/databases/${databaseName}/branches/${branchName}/passwords/${this.output.id}`,
320
- {
315
+ await api.organizations.databases.branches.passwords.patch({
316
+ path: {
317
+ organization,
318
+ database,
319
+ branch,
320
+ id: this.output.id,
321
+ },
322
+ body: {
321
323
  name,
322
324
  cidrs: props.cidrs,
323
325
  },
324
- );
325
-
326
- if (!updateResponse.ok) {
327
- throw new Error(
328
- `Failed to update password: ${updateResponse.statusText} ${await updateResponse.text()}`,
329
- );
330
- }
326
+ });
331
327
 
332
328
  return this({
333
329
  ...this.output,
334
330
  ...props,
331
+ name,
335
332
  });
336
333
  }
337
334
 
338
- try {
339
- const createResponse = await api.post(
340
- `/organizations/${props.organizationId}/databases/${databaseName}/branches/${branchName}/passwords`,
341
- {
342
- name,
343
- role: props.role,
344
- replica: props.replica,
345
- ttl: props.ttl,
346
- cidrs: props.cidrs,
347
- },
348
- );
349
-
350
- if (!createResponse.ok) {
351
- throw new Error(
352
- `Failed to create password: ${createResponse.statusText} ${await createResponse.text()}`,
353
- );
354
- }
335
+ const data = await api.organizations.databases.branches.passwords.post({
336
+ path: {
337
+ organization,
338
+ database,
339
+ branch,
340
+ },
341
+ body: {
342
+ name,
343
+ role: props.role,
344
+ replica: props.replica,
345
+ ttl: props.ttl,
346
+ cidrs: props.cidrs,
347
+ },
348
+ });
355
349
 
356
- const data = await createResponse.json<any>();
357
-
358
- return this({
359
- id: data.id,
360
- expiresAt: data.expires_at,
361
- host: data.access_host_url,
362
- username: data.username,
363
- password: alchemy.secret(data.plain_text),
364
- nameSlug,
365
- ...props,
366
- name: `${props.name}-${nameSlug}`,
367
- });
368
- } catch (error) {
369
- logger.error("Error managing password:", error);
370
- throw error;
371
- }
350
+ return this({
351
+ id: data.id,
352
+ expiresAt: data.expires_at,
353
+ host: data.access_host_url,
354
+ username: data.username,
355
+ password: alchemy.secret(data.plain_text),
356
+ nameSlug,
357
+ ...props,
358
+ name: `${props.name}-${nameSlug}`,
359
+ });
372
360
  },
373
361
  );
362
+
363
+ /**
364
+ * Returns an array of keys in `b` that are different from `a`.
365
+ */
366
+ const diff = <T>(a: T, b: NoInfer<T>) => {
367
+ const keys: (keyof T)[] = [];
368
+ for (const key in a) {
369
+ if (!isDeepStrictEqual(a[key], b[key])) {
370
+ keys.push(key);
371
+ }
372
+ }
373
+ return keys;
374
+ };
@@ -1,4 +1,4 @@
1
- import type { PlanetScaleApi } from "./api.ts";
1
+ import type { PlanetScaleClient } from "./api/client.gen.ts";
2
2
 
3
3
  export type PlanetScaleClusterSize =
4
4
  | "PS_DEV"
@@ -25,165 +25,184 @@ export type PlanetScaleClusterSize =
25
25
  * Wait for a database to be ready with exponential backoff
26
26
  */
27
27
  export async function waitForDatabaseReady(
28
- api: PlanetScaleApi,
29
- organizationId: string,
30
- databaseName: string,
31
- branchName?: string,
28
+ api: PlanetScaleClient,
29
+ organization: string,
30
+ database: string,
31
+ branch?: string,
32
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
- }
49
-
50
- if (Date.now() - startTime >= 60000) {
51
- throw new Error(
52
- `Timeout waiting for database ${databaseName} to be ready`,
53
- );
54
- }
55
-
56
- await new Promise((resolve) => setTimeout(resolve, waitMs));
57
- waitMs = Math.min(waitMs * 2, 1000); // Cap at 1s intervals
58
- }
33
+ 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({
42
+ path: { organization, name: database },
43
+ });
44
+ },
45
+ predicate: (item) => item.ready,
46
+ });
59
47
  }
60
48
 
61
49
  /**
62
- * Wait for a keyspace to be ready
50
+ * Polls a keyspace until it is finished resizing.
63
51
  */
64
52
  export async function waitForKeyspaceReady(
65
- api: PlanetScaleApi,
66
- org: string,
67
- db: string,
53
+ api: PlanetScaleClient,
54
+ organization: string,
55
+ database: string,
68
56
  branch: string,
69
57
  keyspace: string,
70
58
  ): Promise<void> {
71
- const start = Date.now();
72
- let delay = 100;
73
-
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}`,
81
- );
82
- }
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;
94
- }
95
-
96
- if (Date.now() - start > 600_000) {
97
- throw new Error(`Timeout waiting for keyspace "${keyspace}" to be ready`);
98
- }
99
-
100
- await new Promise((r) => setTimeout(r, delay));
101
- delay = Math.min(delay * 2, 1_000);
102
- }
59
+ await poll({
60
+ description: `keyspace "${keyspace}" ready`,
61
+ fn: () =>
62
+ api.organizations.databases.branches.keyspaces.resizes.list({
63
+ path: { organization, database, branch, name: keyspace },
64
+ }),
65
+ predicate: (response) =>
66
+ response.data.every((item) => item.state !== "resizing"),
67
+ initialDelay: 100,
68
+ maxDelay: 1000,
69
+ });
103
70
  }
104
71
 
105
72
  /**
106
- * Fix cluster size for a branch
73
+ * Ensure a branch is production and has the correct cluster size.
74
+ * If a branch is not production, it will be promoted to production because
75
+ * cluster sizes can only be configured for production branches.
107
76
  */
108
- export async function fixClusterSize(
109
- api: PlanetScaleApi,
110
- organizationId: string,
111
- databaseName: string,
112
- branchName: string,
77
+ export async function ensureProductionBranchClusterSize(
78
+ api: PlanetScaleClient,
79
+ organization: string,
80
+ database: string,
81
+ branch: string,
113
82
  expectedClusterSize: PlanetScaleClusterSize,
114
83
  isDBReady: boolean,
115
84
  ): Promise<void> {
116
85
  if (!isDBReady) {
117
- await waitForDatabaseReady(api, organizationId, databaseName);
86
+ await waitForDatabaseReady(api, organization, database);
118
87
  }
119
88
 
120
89
  // 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>();
90
+ const branchData = await api.organizations.databases.branches.get({
91
+ path: {
92
+ organization,
93
+ database,
94
+ name: branch,
95
+ },
96
+ });
128
97
  if (!branchData.production) {
129
98
  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}`);
99
+ await waitForDatabaseReady(api, organization, database, branch);
137
100
  }
101
+ await api.organizations.databases.branches.promote.post({
102
+ path: {
103
+ organization,
104
+ database,
105
+ name: branch,
106
+ },
107
+ });
138
108
  }
139
109
  // 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}`);
110
+ const keyspaces = await api.organizations.databases.branches.keyspaces.list({
111
+ path: {
112
+ organization,
113
+ database,
114
+ branch,
115
+ },
116
+ });
117
+ const defaultKeyspace = keyspaces.data.find((x) => x.name === database); // Default keyspace is always the same name as the database
118
+ if (!defaultKeyspace) {
119
+ throw new Error(`No default keyspace found for branch ${branch}`);
153
120
  }
154
- const defaultKs = defaultKsData.name;
155
- let currentSize = defaultKsData.cluster_name as PlanetScaleClusterSize;
156
121
 
157
122
  // 3. Wait until any in-flight resize is done
158
123
  await waitForKeyspaceReady(
159
124
  api,
160
- organizationId,
161
- databaseName,
162
- branchName,
163
- defaultKs,
125
+ organization,
126
+ database,
127
+ branch,
128
+ defaultKeyspace.name,
164
129
  );
165
130
 
166
131
  // 4. If size mismatch, trigger resize and wait again
167
132
  // 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
- }
133
+ if (defaultKeyspace.cluster_name !== expectedClusterSize) {
134
+ await api.organizations.databases.branches.cluster.patch({
135
+ path: {
136
+ organization,
137
+ database,
138
+ name: branch,
139
+ },
140
+ body: { cluster_size: expectedClusterSize },
141
+ });
179
142
 
180
143
  // Poll until the resize completes
181
144
  await waitForKeyspaceReady(
182
145
  api,
183
- organizationId,
184
- databaseName,
185
- branchName,
186
- defaultKs,
146
+ organization,
147
+ database,
148
+ branch,
149
+ defaultKeyspace.name,
187
150
  );
188
151
  }
189
152
  }
153
+
154
+ /**
155
+ * Polls a function until it returns a result that satisfies the predicate.
156
+ */
157
+ async function poll<T>(input: {
158
+ /**
159
+ * A description of the operation being polled.
160
+ */
161
+ description: string;
162
+
163
+ /**
164
+ * The function to poll.
165
+ */
166
+ fn: () => Promise<T>;
167
+
168
+ /**
169
+ * A predicate that determines if the operation has completed.
170
+ */
171
+ predicate: (result: T) => boolean;
172
+
173
+ /**
174
+ * The initial delay between polls.
175
+ * @default 250ms
176
+ */
177
+ initialDelay?: number;
178
+
179
+ /**
180
+ * The maximum delay between polls.
181
+ * @default 2_500ms (2.5 seconds)
182
+ */
183
+ maxDelay?: number;
184
+
185
+ /**
186
+ * The timeout for the poll in milliseconds.
187
+ * @default 1_000_000 (~16 minutes)
188
+ */
189
+ timeout?: number;
190
+ }): Promise<T> {
191
+ const start = Date.now();
192
+ let delay = input.initialDelay ?? 250;
193
+ while (true) {
194
+ const result = await input.fn();
195
+ if (input.predicate(result)) {
196
+ return result;
197
+ }
198
+ if (Date.now() - start >= (input.timeout ?? 1_000_000)) {
199
+ throw new Error(
200
+ `Timed out waiting for ${input.description} after ${Math.round(
201
+ (input.timeout ?? 1_000_000) / 1000,
202
+ )}s`,
203
+ );
204
+ }
205
+ await new Promise((resolve) => setTimeout(resolve, delay));
206
+ delay = Math.min(delay * 2, input.maxDelay ?? 5_000);
207
+ }
208
+ }
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",