@alchemy.run/planetscale 0.0.0-stage → 2.0.0-beta.84

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 (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +87 -2
  3. package/lib/AuthProvider.d.ts +64 -0
  4. package/lib/AuthProvider.d.ts.map +1 -0
  5. package/lib/AuthProvider.js +384 -0
  6. package/lib/AuthProvider.js.map +1 -0
  7. package/lib/Branch.d.ts +128 -0
  8. package/lib/Branch.d.ts.map +1 -0
  9. package/lib/Branch.js +420 -0
  10. package/lib/Branch.js.map +1 -0
  11. package/lib/Contributions.d.ts +7 -0
  12. package/lib/Contributions.d.ts.map +1 -0
  13. package/lib/Contributions.js +6 -0
  14. package/lib/Contributions.js.map +1 -0
  15. package/lib/Credentials.d.ts +33 -0
  16. package/lib/Credentials.d.ts.map +1 -0
  17. package/lib/Credentials.js +65 -0
  18. package/lib/Credentials.js.map +1 -0
  19. package/lib/Database.d.ts +138 -0
  20. package/lib/Database.d.ts.map +1 -0
  21. package/lib/Database.js +1 -0
  22. package/lib/Database.js.map +1 -0
  23. package/lib/MySQL/MySQLBranch.d.ts +87 -0
  24. package/lib/MySQL/MySQLBranch.d.ts.map +1 -0
  25. package/lib/MySQL/MySQLBranch.js +15 -0
  26. package/lib/MySQL/MySQLBranch.js.map +1 -0
  27. package/lib/MySQL/MySQLClusterSize.d.ts +32 -0
  28. package/lib/MySQL/MySQLClusterSize.d.ts.map +1 -0
  29. package/lib/MySQL/MySQLClusterSize.js +111 -0
  30. package/lib/MySQL/MySQLClusterSize.js.map +1 -0
  31. package/lib/MySQL/MySQLDatabase.d.ts +141 -0
  32. package/lib/MySQL/MySQLDatabase.d.ts.map +1 -0
  33. package/lib/MySQL/MySQLDatabase.js +294 -0
  34. package/lib/MySQL/MySQLDatabase.js.map +1 -0
  35. package/lib/MySQL/MySQLMigrations.d.ts +24 -0
  36. package/lib/MySQL/MySQLMigrations.d.ts.map +1 -0
  37. package/lib/MySQL/MySQLMigrations.js +106 -0
  38. package/lib/MySQL/MySQLMigrations.js.map +1 -0
  39. package/lib/MySQL/MySQLOrigin.d.ts +15 -0
  40. package/lib/MySQL/MySQLOrigin.d.ts.map +1 -0
  41. package/lib/MySQL/MySQLOrigin.js +2 -0
  42. package/lib/MySQL/MySQLOrigin.js.map +1 -0
  43. package/lib/MySQL/MySQLPassword.d.ts +116 -0
  44. package/lib/MySQL/MySQLPassword.d.ts.map +1 -0
  45. package/lib/MySQL/MySQLPassword.js +212 -0
  46. package/lib/MySQL/MySQLPassword.js.map +1 -0
  47. package/lib/MySQL/index.d.ts +6 -0
  48. package/lib/MySQL/index.d.ts.map +1 -0
  49. package/lib/MySQL/index.js +6 -0
  50. package/lib/MySQL/index.js.map +1 -0
  51. package/lib/OAuthClient.d.ts +32 -0
  52. package/lib/OAuthClient.d.ts.map +1 -0
  53. package/lib/OAuthClient.js +52 -0
  54. package/lib/OAuthClient.js.map +1 -0
  55. package/lib/Postgres/PostgresBranch.d.ts +75 -0
  56. package/lib/Postgres/PostgresBranch.d.ts.map +1 -0
  57. package/lib/Postgres/PostgresBranch.js +15 -0
  58. package/lib/Postgres/PostgresBranch.js.map +1 -0
  59. package/lib/Postgres/PostgresClusterSize.d.ts +77 -0
  60. package/lib/Postgres/PostgresClusterSize.d.ts.map +1 -0
  61. package/lib/Postgres/PostgresClusterSize.js +89 -0
  62. package/lib/Postgres/PostgresClusterSize.js.map +1 -0
  63. package/lib/Postgres/PostgresDatabase.d.ts +95 -0
  64. package/lib/Postgres/PostgresDatabase.d.ts.map +1 -0
  65. package/lib/Postgres/PostgresDatabase.js +294 -0
  66. package/lib/Postgres/PostgresDatabase.js.map +1 -0
  67. package/lib/Postgres/PostgresDefaultRole.d.ts +94 -0
  68. package/lib/Postgres/PostgresDefaultRole.d.ts.map +1 -0
  69. package/lib/Postgres/PostgresDefaultRole.js +187 -0
  70. package/lib/Postgres/PostgresDefaultRole.js.map +1 -0
  71. package/lib/Postgres/PostgresMigrations.d.ts +24 -0
  72. package/lib/Postgres/PostgresMigrations.d.ts.map +1 -0
  73. package/lib/Postgres/PostgresMigrations.js +116 -0
  74. package/lib/Postgres/PostgresMigrations.js.map +1 -0
  75. package/lib/Postgres/PostgresOrigin.d.ts +15 -0
  76. package/lib/Postgres/PostgresOrigin.d.ts.map +1 -0
  77. package/lib/Postgres/PostgresOrigin.js +2 -0
  78. package/lib/Postgres/PostgresOrigin.js.map +1 -0
  79. package/lib/Postgres/PostgresRole.d.ts +142 -0
  80. package/lib/Postgres/PostgresRole.d.ts.map +1 -0
  81. package/lib/Postgres/PostgresRole.js +268 -0
  82. package/lib/Postgres/PostgresRole.js.map +1 -0
  83. package/lib/Postgres/index.d.ts +7 -0
  84. package/lib/Postgres/index.d.ts.map +1 -0
  85. package/lib/Postgres/index.js +7 -0
  86. package/lib/Postgres/index.js.map +1 -0
  87. package/lib/Providers.d.ts +24 -0
  88. package/lib/Providers.d.ts.map +1 -0
  89. package/lib/Providers.js +40 -0
  90. package/lib/Providers.js.map +1 -0
  91. package/lib/Util.d.ts +93 -0
  92. package/lib/Util.d.ts.map +1 -0
  93. package/lib/Util.js +135 -0
  94. package/lib/Util.js.map +1 -0
  95. package/lib/index.d.ts +9 -0
  96. package/lib/index.d.ts.map +1 -0
  97. package/lib/index.js +9 -0
  98. package/lib/index.js.map +1 -0
  99. package/package.json +94 -3
  100. package/src/AuthProvider.ts +571 -0
  101. package/src/Branch.ts +657 -0
  102. package/src/Contributions.ts +9 -0
  103. package/src/Credentials.ts +116 -0
  104. package/src/Database.ts +152 -0
  105. package/src/MySQL/MySQLBranch.ts +109 -0
  106. package/src/MySQL/MySQLClusterSize.ts +185 -0
  107. package/src/MySQL/MySQLDatabase.ts +521 -0
  108. package/src/MySQL/MySQLMigrations.ts +182 -0
  109. package/src/MySQL/MySQLOrigin.ts +15 -0
  110. package/src/MySQL/MySQLPassword.ts +428 -0
  111. package/src/MySQL/index.ts +5 -0
  112. package/src/OAuthClient.ts +59 -0
  113. package/src/Postgres/PostgresBranch.ts +97 -0
  114. package/src/Postgres/PostgresClusterSize.ts +262 -0
  115. package/src/Postgres/PostgresDatabase.ts +469 -0
  116. package/src/Postgres/PostgresDefaultRole.ts +340 -0
  117. package/src/Postgres/PostgresMigrations.ts +193 -0
  118. package/src/Postgres/PostgresOrigin.ts +15 -0
  119. package/src/Postgres/PostgresRole.ts +547 -0
  120. package/src/Postgres/index.ts +6 -0
  121. package/src/Providers.ts +67 -0
  122. package/src/Util.ts +205 -0
  123. package/src/index.ts +14 -0
@@ -0,0 +1,116 @@
1
+ import { ConfigError } from "@distilled.cloud/core/errors";
2
+ import {
3
+ type Config as PlanetscaleClientConfig,
4
+ Credentials,
5
+ DEFAULT_API_BASE_URL,
6
+ } from "@distilled.cloud/planetscale/Credentials";
7
+ import * as CredentialsCache from "alchemy/Auth/CredentialsCache";
8
+ import {
9
+ deferUntilFirstUse,
10
+ orDieCredentialsUnavailable,
11
+ resolveProviderConfig,
12
+ } from "alchemy/Auth/Resolve";
13
+ import * as Config from "effect/Config";
14
+ import * as Effect from "effect/Effect";
15
+ import * as Layer from "effect/Layer";
16
+ import * as Redacted from "effect/Redacted";
17
+ import {
18
+ PLANETSCALE_AUTH_PROVIDER_NAME,
19
+ type PlanetscaleAuthConfig,
20
+ type PlanetscaleResolvedCredentials,
21
+ } from "./AuthProvider.ts";
22
+
23
+ export {
24
+ Credentials,
25
+ CredentialsFromEnv,
26
+ DEFAULT_API_BASE_URL,
27
+ } from "@distilled.cloud/planetscale/Credentials";
28
+
29
+ /**
30
+ * Build a PlanetScale `Credentials` Layer from an explicit token. Useful for
31
+ * tests or when the caller already has credentials in hand.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * Effect.provide(
36
+ * Planetscale.fromToken({
37
+ * tokenId: "abcd1234",
38
+ * token: "api-token-secret",
39
+ * organization: "my-org",
40
+ * }),
41
+ * )
42
+ * ```
43
+ */
44
+ export const fromToken = (input: {
45
+ tokenId: string | Redacted.Redacted<string>;
46
+ token: string | Redacted.Redacted<string>;
47
+ organization: string;
48
+ apiBaseUrl?: string;
49
+ }) =>
50
+ Layer.succeed(
51
+ Credentials,
52
+ Effect.succeed({
53
+ tokenId: typeof input.tokenId === "string" ? Redacted.make(input.tokenId) : input.tokenId,
54
+ token: typeof input.token === "string" ? Redacted.make(input.token) : input.token,
55
+ organization: input.organization,
56
+ apiBaseUrl: input.apiBaseUrl ?? DEFAULT_API_BASE_URL,
57
+ }),
58
+ );
59
+
60
+ /**
61
+ * Build a PlanetScale `Credentials` Layer that resolves credentials via the
62
+ * Alchemy AuthProvider using the configured profile (defaults to "default",
63
+ * selected by the current Alchemy profile).
64
+ */
65
+ export const fromAuthProvider = () =>
66
+ Layer.effect(
67
+ Credentials,
68
+ Effect.gen(function* () {
69
+ const apiBaseUrl = yield* Config.String("PLANETSCALE_API_BASE_URL").pipe(
70
+ Config.withDefault(DEFAULT_API_BASE_URL),
71
+ );
72
+ // Defer profile lookup and credential resolution until first use, so
73
+ // building the provider layers never requires a configured profile.
74
+ const resolve = yield* resolveProviderConfig<
75
+ PlanetscaleAuthConfig,
76
+ PlanetscaleResolvedCredentials
77
+ >(PLANETSCALE_AUTH_PROVIDER_NAME).pipe(
78
+ Effect.flatMap(({ profileName, resolve }) =>
79
+ Effect.mapError(resolve, (e) =>
80
+ ConfigError.make({
81
+ message: `Failed to resolve Planetscale credentials from ${profileName === undefined ? "the CI environment" : `profile '${profileName}'`}: ${e.message}`,
82
+ }),
83
+ ),
84
+ ),
85
+ deferUntilFirstUse,
86
+ );
87
+
88
+ // Cache until shortly before the OAuth token expires (service tokens
89
+ // never expire) so a long `alchemy dev` session re-resolves — and
90
+ // thereby refreshes — the token instead of keeping the first, by then
91
+ // dead, resolution forever. The cache wraps `resolve` (not the mapped
92
+ // client config) because the mapping drops the `expires` timestamp.
93
+ const cached = yield* CredentialsCache.cacheUntilExpiry(resolve, (creds) =>
94
+ creds.type === "oauth" ? creds.expires : undefined,
95
+ );
96
+ return cached.pipe(
97
+ Effect.map((creds): PlanetscaleClientConfig =>
98
+ creds.type === "oauth"
99
+ ? {
100
+ type: "oauth",
101
+ accessToken: creds.accessToken,
102
+ organization: creds.organization,
103
+ apiBaseUrl,
104
+ }
105
+ : {
106
+ type: "serviceToken",
107
+ tokenId: creds.tokenId,
108
+ token: creds.token,
109
+ organization: creds.organization,
110
+ apiBaseUrl,
111
+ },
112
+ ),
113
+ orDieCredentialsUnavailable(PLANETSCALE_AUTH_PROVIDER_NAME),
114
+ );
115
+ }),
116
+ );
@@ -0,0 +1,152 @@
1
+ import type { MigrationsInput } from "@alchemy.run/sql/Migrations";
2
+
3
+ /**
4
+ * Region selector for a PlanetScale Database.
5
+ */
6
+ export interface DatabaseRegion {
7
+ /**
8
+ * Region slug (e.g. `"us-east"`, `"eu-west"`, `"gcp-us-central1"`).
9
+ * @see https://planetscale.com/docs/concepts/regions#available-regions
10
+ */
11
+ slug: "us-east" | "eu-west" | "gcp-us-central1" | (string & {});
12
+ }
13
+
14
+ /**
15
+ * Properties shared between {@link MySQLDatabase} and
16
+ * {@link PostgresDatabase}. Engine-specific props live on the per-engine
17
+ * resource types.
18
+ */
19
+ export interface BaseDatabaseProps {
20
+ /**
21
+ * Database name. Must be lowercase. If omitted, a unique name will be
22
+ * generated from the stack/stage/logical-id. A change that replaces the
23
+ * database needs a new name, so it fails while an explicit name stays
24
+ * the same.
25
+ */
26
+ name?: string;
27
+
28
+ /**
29
+ * Region where the database will be created. Cannot be changed after
30
+ * creation. If omitted, the organization's default region is used.
31
+ */
32
+ region?: DatabaseRegion;
33
+
34
+ /**
35
+ * Number of replicas for the database. `0` for non-HA, `2+` for HA.
36
+ * Create-only for PostgreSQL databases; MySQL databases reconcile
37
+ * changes in place ({@link MySQLDatabaseProps.replicas}).
38
+ */
39
+ replicas?: number;
40
+
41
+ /**
42
+ * Whether deploy requests must be approved by a database administrator
43
+ * other than the request creator.
44
+ */
45
+ requireApprovalForDeploy?: boolean;
46
+
47
+ /**
48
+ * Whether to limit branch creation to the same region as the one
49
+ * selected during database creation.
50
+ */
51
+ restrictBranchRegion?: boolean;
52
+
53
+ /**
54
+ * Whether the web console can be used on the production branch.
55
+ */
56
+ productionBranchWebConsole?: boolean;
57
+
58
+ /**
59
+ * Whether PlanetScale refuses to delete the database. While enabled,
60
+ * destroying or replacing the database fails with a
61
+ * `PlanetscaleDeletionProtected` error; set it to `false` and deploy
62
+ * before deleting. If omitted, Alchemy leaves the database's current
63
+ * setting unchanged.
64
+ *
65
+ * A replacement (e.g. a region change) creates the new database first,
66
+ * so deleting the protected old one fails afterwards. The resource then
67
+ * manages the new database, so turn protection off on the old one in
68
+ * PlanetScale directly and deploy again.
69
+ */
70
+ deletionProtection?: boolean;
71
+
72
+ /**
73
+ * The default branch of the database.
74
+ * @default "main"
75
+ */
76
+ defaultBranch?: string;
77
+
78
+ /**
79
+ * SQL migrations to apply against the default branch. Accepts a directory
80
+ * path, a `Drizzle.Schema` resource, or `{ dir, table? }`.
81
+ *
82
+ * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A
83
+ * database previously migrated by drizzle-kit or Prisma is adopted by a
84
+ * one-way conversion on first deploy: the old tool's applied history is
85
+ * copied into Alchemy's table and the old table is left frozen. No
86
+ * baselining required.
87
+ */
88
+ migrations?: MigrationsInput;
89
+
90
+ /**
91
+ * Paths to additional `.sql` files to apply after migrations. Each file is
92
+ * hashed; only files whose contents change are re-applied on later deploys.
93
+ */
94
+ importFiles?: string[];
95
+ }
96
+
97
+ /**
98
+ * Output attributes shared by both database engines.
99
+ */
100
+ export interface BaseDatabaseAttributes {
101
+ /** The unique identifier of the database. */
102
+ id: string;
103
+ /** The name of the database. */
104
+ name: string;
105
+ /** The PlanetScale organization slug that owns the database. */
106
+ organization: string;
107
+ /** The current state of the database. */
108
+ state: string;
109
+ /** The default branch name. */
110
+ defaultBranch: string;
111
+ /** The plan tier (e.g. `"hobby"`, `"scaler"`). */
112
+ plan: string;
113
+ /** Time at which the database was created (ISO 8601). */
114
+ createdAt: string;
115
+ /** Time at which the database was last updated (ISO 8601). */
116
+ updatedAt: string;
117
+ /** HTML URL for accessing the database in the dashboard. */
118
+ htmlUrl: string;
119
+ /** The region of the database as reported by PlanetScale. */
120
+ region: DatabaseRegion;
121
+ /** The cluster size that was actually applied. */
122
+ clusterSize: string;
123
+ /** Directory containing migration files, if configured. */
124
+ migrationsDir: string | undefined;
125
+ /** Table used to track applied migrations, if configured. */
126
+ migrationsTable: string | undefined;
127
+ /** Content hashes for the last applied migration files. */
128
+ migrationsHashes: Record<string, string>;
129
+ /** Content hashes for the last applied import files. */
130
+ importHashes: Record<string, string>;
131
+ /**
132
+ * Whether deploy requests must be approved by a database administrator
133
+ * other than the request creator.
134
+ */
135
+ requireApprovalForDeploy: boolean;
136
+
137
+ /**
138
+ * Whether to limit branch creation to the same region as the one
139
+ * selected during database creation.
140
+ */
141
+ restrictBranchRegion: boolean;
142
+
143
+ /**
144
+ * Whether the web console can be used on the production branch.
145
+ */
146
+ productionBranchWebConsole: boolean;
147
+
148
+ /**
149
+ * Whether deletion protection is enabled for the database.
150
+ */
151
+ deletionProtection: boolean;
152
+ }
@@ -0,0 +1,109 @@
1
+ import { Resource } from "alchemy/Resource";
2
+ import { type BaseBranchAttributes, type BaseBranchProps, makeBranchProvider } from "../Branch.ts";
3
+ import type { Providers } from "../Providers.ts";
4
+ import type { MySQLClusterSize } from "./MySQLClusterSize.ts";
5
+ import type { MySQLDatabase } from "./MySQLDatabase.ts";
6
+ import { runMySQLImports, runMySQLMigrations } from "./MySQLMigrations.ts";
7
+
8
+ /**
9
+ * Properties for creating or updating a {@link MySQLBranch}.
10
+ */
11
+ export interface MySQLBranchProps extends BaseBranchProps {
12
+ /**
13
+ * Whether the branch should be a production branch.
14
+ */
15
+ isProduction: boolean;
16
+
17
+ /**
18
+ * The MySQL database. Either a string database name or a
19
+ * {@link MySQLDatabase} resource.
20
+ */
21
+ database: string | MySQLDatabase;
22
+
23
+ /**
24
+ * MySQL cluster size for the branch. Required if `backupId` is provided.
25
+ * For non-production branches must be `"PS_DEV"`.
26
+ */
27
+ clusterSize?: MySQLClusterSize;
28
+
29
+ /**
30
+ * Parent branch — either a string name or another {@link MySQLBranch}.
31
+ * @default "main"
32
+ */
33
+ parentBranch?: string | MySQLBranch;
34
+ }
35
+
36
+ /**
37
+ * Output attributes of a deployed {@link MySQLBranch}.
38
+ */
39
+ export interface MySQLBranchAttributes extends BaseBranchAttributes {}
40
+
41
+ /**
42
+ * A PlanetScale branch of a {@link MySQLDatabase}. For PostgreSQL branches
43
+ * use {@link PostgresBranch} instead.
44
+ *
45
+ * ### Creating a Branch
46
+ * **Example:** Branch from main
47
+ * ```typescript
48
+ * const branch = yield* Planetscale.MySQLBranch("Feature123", {
49
+ * database: "my-db",
50
+ * parentBranch: "main",
51
+ * isProduction: false,
52
+ * });
53
+ * ```
54
+ *
55
+ * **Example:** Branch from a MySQLDatabase resource
56
+ * ```typescript
57
+ * const db = yield* Planetscale.MySQLDatabase("MyDb", { clusterSize: "PS_10" });
58
+ * const branch = yield* Planetscale.MySQLBranch("Feature456", {
59
+ * database: db,
60
+ * parentBranch: "main",
61
+ * isProduction: false,
62
+ * });
63
+ * ```
64
+ *
65
+ * ### Restoring from Backup
66
+ * **Example:** Branch restored from a backup
67
+ * ```typescript
68
+ * const branch = yield* Planetscale.MySQLBranch("Restored", {
69
+ * database: "my-db",
70
+ * parentBranch: "main",
71
+ * isProduction: true,
72
+ * backupId: "backup-123",
73
+ * clusterSize: "PS_10",
74
+ * });
75
+ * ```
76
+ *
77
+ * ### Migrations and seed data
78
+ * **Example:** Apply migrations on a branch
79
+ * ```typescript
80
+ * const branch = yield* Planetscale.MySQLBranch("Feature123", {
81
+ * database: db,
82
+ * parentBranch: "main",
83
+ * isProduction: false,
84
+ * migrationsDir: "./migrations",
85
+ * importFiles: ["./seed.sql"],
86
+ * });
87
+ * ```
88
+ */
89
+ export type MySQLBranch = Resource<
90
+ "Planetscale.MySQLBranch",
91
+ MySQLBranchProps,
92
+ MySQLBranchAttributes,
93
+ never,
94
+ Providers
95
+ >;
96
+
97
+ /** @resource */
98
+ export const MySQLBranch = Resource<MySQLBranch>("Planetscale.MySQLBranch");
99
+
100
+ export const MySQLBranchProvider = () =>
101
+ makeBranchProvider({
102
+ resource: MySQLBranch,
103
+ expectedKind: "mysql",
104
+ engineLabel: "MySQLBranch",
105
+ runners: {
106
+ runMigrations: runMySQLMigrations,
107
+ runImports: runMySQLImports,
108
+ },
109
+ });
@@ -0,0 +1,185 @@
1
+ import * as ps from "@distilled.cloud/planetscale";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Schedule from "effect/Schedule";
4
+ import { PlanetscaleConflict, pollUntil } from "../Util.ts";
5
+
6
+ /**
7
+ * Available PlanetScale MySQL (Vitess) cluster sizes.
8
+ *
9
+ * `PS_*` sizes are backed by network-attached storage (NAS) and can be
10
+ * specified either as the short size (`"PS_10"`) or the API SKU
11
+ * (`"PS_10_AWS_X86"`)
12
+ *
13
+ * @see https://planetscale.com/docs/concepts/planetscale-skus
14
+ */
15
+ export type MySQLClusterSize =
16
+ | "PS_DEV"
17
+ | "PS_5"
18
+ | "PS_10"
19
+ | "PS_20"
20
+ | "PS_40"
21
+ | "PS_80"
22
+ | "PS_160"
23
+ | "PS_320"
24
+ | "PS_400"
25
+ | "PS_640"
26
+ | "PS_700"
27
+ | "PS_900"
28
+ | "PS_1280"
29
+ | "PS_1400"
30
+ | "PS_1800"
31
+ | "PS_2100"
32
+ | "PS_2560"
33
+ | "PS_2700"
34
+ | "PS_2800"
35
+ | (string & {});
36
+
37
+ /**
38
+ * Polls keyspaces in a branch until the named keyspace reports
39
+ * `resizing === false` (i.e. any in-flight resize has completed).
40
+ */
41
+ export const waitForKeyspaceReady = Effect.fn(function* (
42
+ organization: string,
43
+ database: string,
44
+ branch: string,
45
+ keyspace: string,
46
+ ) {
47
+ yield* pollUntil(
48
+ `keyspace "${keyspace}" not resizing`,
49
+ ps.listKeyspaces({ organization, database, branch }),
50
+ (page) => {
51
+ const ks = page.data.find((x) => x.name === keyspace);
52
+ // If keyspace is missing, treat as ready (caller will re-check)
53
+ return ks ? !ks.resizing : true;
54
+ },
55
+ );
56
+ });
57
+
58
+ /**
59
+ * Observes the default keyspace of a branch. The default keyspace usually
60
+ * shares the database's name, but a renamed database keeps its original
61
+ * keyspace name, so prefer the `default` flag and fall back to name
62
+ * matching.
63
+ */
64
+ const observeDefaultKeyspace = Effect.fn(function* (
65
+ organization: string,
66
+ database: string,
67
+ branch: string,
68
+ ) {
69
+ const keyspaces = yield* ps.listKeyspaces({
70
+ organization,
71
+ database,
72
+ branch,
73
+ page: 1,
74
+ per_page: 100,
75
+ });
76
+ return keyspaces.data.find((x) => x.default) ?? keyspaces.data.find((x) => x.name === database);
77
+ });
78
+
79
+ /**
80
+ * Observes the total replica count of a branch's default keyspace, or
81
+ * `undefined` when the branch or keyspace does not exist (yet).
82
+ */
83
+ export const observeDefaultKeyspaceReplicas = Effect.fn(function* (
84
+ organization: string,
85
+ database: string,
86
+ branch: string,
87
+ ) {
88
+ return yield* observeDefaultKeyspace(organization, database, branch).pipe(
89
+ Effect.map((keyspace) => keyspace?.replicas),
90
+ Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
91
+ );
92
+ });
93
+
94
+ /**
95
+ * Ensures the default keyspace of a MySQL production branch has the
96
+ * expected cluster size and (optionally) total replica count, driving
97
+ * PlanetScale's in-place keyspace resize lifecycle when it doesn't.
98
+ * Cluster sizes can only be configured on production branches. Returns
99
+ * the final observed keyspace.
100
+ */
101
+ export const ensureMySQLProductionBranchClusterSize = Effect.fn(function* (
102
+ organization: string,
103
+ database: string,
104
+ branch: string,
105
+ expectedClusterSize: MySQLClusterSize,
106
+ expectedReplicas?: number,
107
+ ) {
108
+ let keyspace = yield* observeDefaultKeyspace(organization, database, branch);
109
+ if (!keyspace) {
110
+ return yield* Effect.die(`No default keyspace found for branch ${branch}`);
111
+ }
112
+
113
+ yield* waitForKeyspaceReady(organization, database, branch, keyspace.name);
114
+
115
+ if (keyspace.cluster_name !== expectedClusterSize) {
116
+ yield* ps.updateBranchClusterConfig({
117
+ organization,
118
+ database,
119
+ branch,
120
+ cluster_size: expectedClusterSize,
121
+ });
122
+ yield* waitForKeyspaceReady(organization, database, branch, keyspace.name);
123
+ // Re-observe so the replica sync below diffs against the post-resize
124
+ // keyspace state.
125
+ keyspace = (yield* observeDefaultKeyspace(organization, database, branch)) ?? keyspace;
126
+ }
127
+
128
+ // Sync replicas — MySQL databases cannot configure replicas at
129
+ // creation time (the API rejects the `replicas` param for mysql), so
130
+ // the desired total replica count is converged in place via a keyspace
131
+ // resize request.
132
+ if (expectedReplicas !== undefined && keyspace.replicas !== expectedReplicas) {
133
+ // Each cluster size includes a fixed number of replicas (2 for
134
+ // production PS_* sizes); the resize API only accepts the count of
135
+ // additional replicas beyond that.
136
+ const includedReplicas = keyspace.replicas - keyspace.extra_replicas;
137
+ const extraReplicas = expectedReplicas - includedReplicas;
138
+ if (extraReplicas < 0) {
139
+ return yield* new PlanetscaleConflict({
140
+ message:
141
+ `Cannot set replicas to ${expectedReplicas} on keyspace "${keyspace.name}": ` +
142
+ `cluster size ${keyspace.cluster_name} always includes ${includedReplicas} replicas. ` +
143
+ `Set replicas to ${includedReplicas} or more (or omit it).`,
144
+ });
145
+ }
146
+
147
+ const resize = yield* ps
148
+ .updateKeyspaceResizeRequest({
149
+ organization,
150
+ database,
151
+ branch,
152
+ keyspace: keyspace.name,
153
+ extra_replicas: extraReplicas,
154
+ })
155
+ .pipe(
156
+ // A prior resize (e.g. the cluster-size change above) can still
157
+ // be finalizing after the keyspace reports `resizing: false`;
158
+ // PlanetScale rejects new resize requests during that window.
159
+ Effect.retry({
160
+ while: (e): boolean =>
161
+ e._tag === "UnprocessableEntity" && e.message.includes("resize in progress"),
162
+ schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(120)]),
163
+ }),
164
+ );
165
+
166
+ yield* pollUntil(
167
+ `keyspace "${keyspace.name}" resize completed`,
168
+ ps.listKeyspaceResizeRequests({
169
+ organization,
170
+ database,
171
+ branch,
172
+ keyspace: keyspace.name,
173
+ }),
174
+ (page) => {
175
+ const request = page.data.find((r) => r.id === resize.id);
176
+ // If the request is missing, treat as settled (caller re-observes).
177
+ return request ? request.state === "completed" : true;
178
+ },
179
+ );
180
+
181
+ keyspace = (yield* observeDefaultKeyspace(organization, database, branch)) ?? keyspace;
182
+ }
183
+
184
+ return keyspace;
185
+ });