1import * as planetscale from "@distilled.cloud/planetscale";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { pollUntil, waitForBranchReady } from "../Util.ts";
5
6/**
7 * Published PlanetScale Metal SKUs for Postgres (AWS).
8 *
9 * Short display names (`"M-10"`, `"M_10"`) are **not** valid SKUs. Each
10 * Metal SKU encodes CPU series, compute size, provider, architecture, and
11 * NVMe storage size.
12 *
13 * Hyphenated SKUs copied from PlanetScale's pricing docs
14 * (`"M1-10-AWS-ARM-D-METAL-10"`) are accepted and normalized to
15 * underscores by {@link toPostgresClusterSku}. GCP and custom SKUs are
16 * still valid via {@link PostgresClusterSize}'s open-string fallback.
17 *
18 * @see https://planetscale.com/docs/postgres/pricing
19 * @see https://planetscale.com/docs/metal
20 */
21export type PostgresMetalClusterSize =
22 | "M1_10_AWS_ARM_D_METAL_10"
23 | "M1_10_AWS_AMD_D_METAL_10"
24 | "M1_10_AWS_ARM_D_METAL_25"
25 | "M1_10_AWS_AMD_D_METAL_25"
26 | "M1_10_AWS_ARM_D_METAL_50"
27 | "M1_10_AWS_AMD_D_METAL_50"
28 | "M1_10_AWS_ARM_D_METAL_100"
29 | "M1_10_AWS_AMD_D_METAL_100"
30 | "M1_10_AWS_ARM_D_METAL_200"
31 | "M1_10_AWS_AMD_D_METAL_200"
32 | "M1_20_AWS_ARM_D_METAL_10"
33 | "M1_20_AWS_AMD_D_METAL_10"
34 | "M1_20_AWS_ARM_D_METAL_25"
35 | "M1_20_AWS_AMD_D_METAL_25"
36 | "M1_20_AWS_ARM_D_METAL_50"
37 | "M1_20_AWS_AMD_D_METAL_50"
38 | "M1_20_AWS_ARM_D_METAL_100"
39 | "M1_20_AWS_AMD_D_METAL_100"
40 | "M1_20_AWS_ARM_D_METAL_200"
41 | "M1_20_AWS_AMD_D_METAL_200"
42 | "M1_40_AWS_ARM_D_METAL_10"
43 | "M1_40_AWS_AMD_D_METAL_10"
44 | "M1_40_AWS_ARM_D_METAL_25"
45 | "M1_40_AWS_AMD_D_METAL_25"
46 | "M1_40_AWS_ARM_D_METAL_50"
47 | "M1_40_AWS_AMD_D_METAL_50"
48 | "M1_40_AWS_ARM_D_METAL_100"
49 | "M1_40_AWS_AMD_D_METAL_100"
50 | "M1_40_AWS_ARM_D_METAL_200"
51 | "M1_40_AWS_AMD_D_METAL_200"
52 | "M1_40_AWS_ARM_D_METAL_400"
53 | "M1_40_AWS_AMD_D_METAL_400"
54 | "M1_40_AWS_ARM_D_METAL_800"
55 | "M1_40_AWS_AMD_D_METAL_800"
56 | "M1_40_AWS_ARM_D_METAL_1200"
57 | "M1_40_AWS_AMD_D_METAL_1200"
58 | "M1_80_AWS_ARM_D_METAL_100"
59 | "M1_80_AWS_AMD_D_METAL_100"
60 | "M1_80_AWS_ARM_D_METAL_200"
61 | "M1_80_AWS_AMD_D_METAL_200"
62 | "M1_80_AWS_ARM_D_METAL_400"
63 | "M1_80_AWS_AMD_D_METAL_400"
64 | "M1_80_AWS_ARM_D_METAL_800"
65 | "M1_80_AWS_AMD_D_METAL_800"
66 | "M1_80_AWS_ARM_D_METAL_1200"
67 | "M1_80_AWS_AMD_D_METAL_1200"
68 | "M1_160_AWS_ARM_D_METAL_100"
69 | "M1_160_AWS_AMD_D_METAL_100"
70 | "M1_160_AWS_ARM_D_METAL_200"
71 | "M1_160_AWS_AMD_D_METAL_200"
72 | "M1_160_AWS_ARM_D_METAL_400"
73 | "M1_160_AWS_AMD_D_METAL_400"
74 | "M1_160_AWS_ARM_D_METAL_800"
75 | "M1_160_AWS_AMD_D_METAL_800"
76 | "M1_160_AWS_ARM_D_METAL_1200"
77 | "M1_160_AWS_AMD_D_METAL_1200"
78 | "M8_160_AWS_ARM_D_METAL_118"
79 | "M6_160_AWS_INTEL_D_METAL_118"
80 | "M7_160_AWS_INTEL_D_METAL_468"
81 | "M7_160_AWS_INTEL_D_METAL_1250"
82 | "M8_320_AWS_ARM_D_METAL_237"
83 | "M6_320_AWS_INTEL_D_METAL_237"
84 | "M7_320_AWS_INTEL_D_METAL_937"
85 | "M7_320_AWS_INTEL_D_METAL_2500"
86 | "M8_640_AWS_ARM_D_METAL_474"
87 | "M6_640_AWS_INTEL_D_METAL_474"
88 | "M7_640_AWS_INTEL_D_METAL_1875"
89 | "M7_640_AWS_INTEL_D_METAL_5000"
90 | "M7_960_AWS_INTEL_D_METAL_7500"
91 | "M8_1280_AWS_ARM_D_METAL_950"
92 | "M6_1280_AWS_INTEL_D_METAL_950"
93 | "M7_1280_AWS_INTEL_D_METAL_3750"
94 | "M7_1920_AWS_INTEL_D_METAL_15000"
95 | "M8_2560_AWS_ARM_D_METAL_1900"
96 | "M6_2560_AWS_INTEL_D_METAL_1900"
97 | "M7_2560_AWS_INTEL_D_METAL_7500"
98 | "M8_3840_AWS_ARM_D_METAL_2850"
99 | "M6_3840_AWS_INTEL_D_METAL_2850"
100 | "M7_3840_AWS_INTEL_D_METAL_11250"
101 | "M7_3840_AWS_INTEL_D_METAL_30000"
102 | "M8_5120_AWS_ARM_D_METAL_3800"
103 | "M6_5120_AWS_INTEL_D_METAL_3800"
104 | "M7_5120_AWS_INTEL_D_METAL_15000"
105 | "M7_5760_AWS_INTEL_D_METAL_45000"
106 | "M8_7680_AWS_ARM_D_METAL_5700"
107 | "M6_7680_AWS_INTEL_D_METAL_5700"
108 | "M7_7680_AWS_INTEL_D_METAL_22500"
109 | "M7_7680_AWS_INTEL_D_METAL_60000"
110 | "M6_10240_AWS_INTEL_D_METAL_7600"
111 | "M8_15360_AWS_ARM_D_METAL_11400"
112 | "M7_15360_AWS_INTEL_D_METAL_45000"
113 | "M7_15360_AWS_INTEL_D_METAL_120000";
114
115/**
116 * Available PlanetScale PostgreSQL cluster sizes.
117 *
118 * ## Network-attached storage (NAS)
119 *
120 * `PS_*` sizes are backed by network-attached storage and can be specified
121 * either as the short size (`"PS_10"`) or the API SKU (`"PS_10_AWS_X86"`).
122 * Short NAS sizes are expanded to a SKU by {@link toPostgresClusterSku}
123 * using the target region and arch.
124 *
125 * ## Metal
126 *
127 * [PlanetScale Metal](https://planetscale.com/docs/metal) sizes are backed
128 * by locally-attached NVMe. Pass a **full Metal SKU** from
129 * {@link PostgresMetalClusterSize} — short `M-*` names are not valid on
130 * their own because Metal SKUs also encode CPU series, architecture, and
131 * drive size. See [Postgres pricing](https://planetscale.com/docs/postgres/pricing).
132 *
133 * @see https://planetscale.com/docs/postgres/pricing
134 * @see https://planetscale.com/docs/metal
135 */
136export type PostgresClusterSize =
137 | "PS_DEV"
138 | "PS_5"
139 | "PS_10"
140 | "PS_20"
141 | "PS_40"
142 | "PS_80"
143 | "PS_160"
144 | "PS_320"
145 | "PS_640"
146 | "PS_1280"
147 | "PS_2560"
148 | PostgresMetalClusterSize
149 | (string & {});
150
151/**
152 * Converts a {@link PostgresClusterSize} into the SKU string expected by
153 * the PlanetScale API.
154 *
155 * Hyphens are normalized to underscores so SKUs copied from PlanetScale
156 * docs (`"PS-10"`, `"M1-10-AWS-ARM-D-METAL-10"`) match the API form.
157 *
158 * For NAS-backed clusters, the API expects a suffixed name like
159 * `PS_<size>_<provider>_<arch>`. Short `PS_*` sizes are expanded using the
160 * supplied region and arch.
161 *
162 * Metal-backed sizes (anything starting with `M`) are passed through after
163 * hyphen normalization. The short `M_*` / `M-*` form is not a valid SKU on
164 * its own — the API requires the full Metal SKU (e.g.
165 * `M1_10_AWS_ARM_D_METAL_10`), which encodes the CPU series, provider,
166 * arch, and storage size.
167 *
168 * Already-suffixed NAS sizes are also passed through unchanged.
169 */
170export function toPostgresClusterSku(input: {
171 size: PostgresClusterSize;
172 arch?: "x86" | "arm";
173 region?: string;
174}): string {
175 const size = input.size.replaceAll("-", "_");
176 if (!size.startsWith("PS_") || size.match(/(AWS|GCP)/)) return size;
177 // Not all AWS regions start with "aws-", but all GCP regions start with "gcp-".
178 const provider = input.region?.startsWith("gcp") ? "GCP" : "AWS";
179 const arch = (input.arch ?? "x86").toUpperCase();
180 return `${size}_${provider}_${arch}`;
181}
182
183/**
184 * Schedule for polling branch change requests. Postgres cluster resizes
185 * routinely take longer than the default 10-minute polling budget, so
186 * give change requests a 60-minute budget (720 × 5s).
187 */
188const changeRequestSchedule = Schedule.max([
189 Schedule.spaced("5 seconds"),
190 Schedule.recurs(720),
191]);
192
193/**
194 * Polls branch change requests until all visible changes are in a terminal
195 * state (`completed` or `canceled`), or — if `changeId` is provided — until
196 * that specific change reaches a terminal state.
197 */
198export const waitForPendingPostgresChanges = Effect.fn(function* (
199 organization: string,
200 database: string,
201 branch: string,
202 changeId?: string,
203) {
204 yield* pollUntil(
205 `changes for branch "${branch}"`,
206 planetscale.listBranchChangeRequests({
207 organization,
208 database,
209 branch,
210 page: 1,
211 per_page: 25,
212 }),
213 (page) => {
214 const isTerminal = (state: string) =>
215 state === "completed" || state === "canceled";
216
217 if (changeId) {
218 const change = page.data.find((change) => change.id === changeId);
219 return change ? isTerminal(change.state) : false;
220 }
221
222 return page.data.every((change) => isTerminal(change.state));
223 },
224 changeRequestSchedule,
225 );
226});
227
228/**
229 * Ensures a PostgreSQL production branch has the expected cluster size,
230 * queuing the change via the change-request API if it doesn't.
231 */
232export const ensurePostgresProductionBranchClusterSize = Effect.fn(function* (
233 organization: string,
234 database: string,
235 branch: string,
236 expectedClusterSize: PostgresClusterSize,
237) {
238 // A freshly-forked branch can't accept (or complete) change requests
239 // until it has finished provisioning — queueing a resize against it
240 // just leaves the change pending until the poll budget runs out.
241 const data = yield* waitForBranchReady(organization, database, branch);
242
243 const sku = toPostgresClusterSku({
244 size: expectedClusterSize,
245 arch: data.cluster_architecture === "aarch64" ? "arm" : "x86",
246 region: data.region.slug,
247 });
248
249 if (data.cluster_name === sku) {
250 return;
251 }
252 yield* waitForPendingPostgresChanges(organization, database, branch);
253 const change = yield* planetscale.updateBranchChangeRequest({
254 organization,
255 database,
256 branch,
257 cluster_size: sku,
258 });
259 yield* waitForPendingPostgresChanges(
260 organization,
261 database,
262 branch,
263 change.id,
264 );
265});