1import { Credentials } from "@distilled.cloud/planetscale/Credentials";
2import * as planetscale from "@distilled.cloud/planetscale";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { havePropsChanged, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { hashImports, hashMigrations } from "../../SQL/SqlFile.ts";
10import {
11 diffMigrations,
12 migrationsAttrs,
13 migrationsInputOf,
14 stampedOf,
15} from "../../SQL/Migrations/index.ts";
16import { recordsEqual } from "../../Util/equal.ts";
17import type { BaseDatabaseAttributes, BaseDatabaseProps } from "../Database.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 PlanetscaleConflict,
21 waitForBranchReady,
22 waitForDatabaseReady,
23} from "../Util.ts";
24import {
25 ensurePostgresProductionBranchClusterSize,
26 toPostgresClusterSku,
27 type PostgresClusterSize,
28} from "./PostgresClusterSize.ts";
29import {
30 runPostgresImports,
31 runPostgresMigrations,
32} from "./PostgresMigrations.ts";
33
34/**
35 * Properties for creating or updating a PostgreSQL PlanetScale database.
36 */
37export interface PostgresDatabaseProps extends BaseDatabaseProps {
38 /**
39 * The PostgreSQL database cluster size. Required.
40 *
41 * Short NAS sizes (`"PS_10"`) are expanded using the target region and
42 * architecture. [PlanetScale Metal](https://planetscale.com/docs/metal)
43 * requires the full SKU (e.g. `"M1_10_AWS_ARM_D_METAL_10"`) because
44 * Metal also encodes CPU series, architecture, and NVMe drive size.
45 *
46 * @see https://planetscale.com/docs/postgres/pricing
47 * @see {@link PostgresClusterSize}
48 */
49 clusterSize: PostgresClusterSize;
50
51 /**
52 * PostgreSQL major version. Defaults to the latest available major
53 * version.
54 */
55 majorVersion?: string;
56
57 /**
58 * CPU architecture. Cannot be changed after creation.
59 * @default "x86"
60 */
61 arch?: "x86" | "arm";
62}
63
64/**
65 * Output attributes of a deployed PostgreSQL PlanetScale database.
66 */
67export interface PostgresDatabaseAttributes extends BaseDatabaseAttributes {
68 /** PostgreSQL CPU architecture. */
69 arch: "x86" | "arm";
70}
71
72/**
73 * A PostgreSQL PlanetScale database. For MySQL, use {@link MySQLDatabase}
74 * instead.
75 *
76 * ### Creating a PostgreSQL Database
77 * **Example:** Basic PostgreSQL database
78 * ```typescript
79 * const db = yield* Planetscale.PostgresDatabase("MyDb", {
80 * clusterSize: "PS_10",
81 * });
82 * ```
83 *
84 * **Example:** PlanetScale Metal (NVMe)
85 * ```typescript
86 * const db = yield* Planetscale.PostgresDatabase("MyDb", {
87 * clusterSize: "M1_10_AWS_ARM_D_METAL_10",
88 * arch: "arm",
89 * });
90 * ```
91 *
92 * ### Migrations and seed data
93 * **Example:** Apply migrations and seed files
94 * ```typescript
95 * const db = yield* Planetscale.PostgresDatabase("MyDb", {
96 * clusterSize: "PS_10",
97 * migrationsDir: "./migrations/postgres",
98 * importFiles: ["./seed/postgres.sql"],
99 * });
100 * ```
101 *
102 * ### Adoption
103 * **Example:** Adopting an existing database
104 * ```typescript
105 * import { adopt } from "alchemy/AdoptPolicy";
106 *
107 * const db = yield* Planetscale.PostgresDatabase("Existing", {
108 * name: "existing-db",
109 * clusterSize: "PS_10",
110 * }).pipe(adopt());
111 * ```
112 */
113export type PostgresDatabase = Resource<
114 "Planetscale.PostgresDatabase",
115 PostgresDatabaseProps,
116 PostgresDatabaseAttributes,
117 never,
118 Providers
119>;
120
121/** @resource */
122export const PostgresDatabase = Resource<PostgresDatabase>(
123 "Planetscale.PostgresDatabase",
124);
125
126export const PostgresDatabaseProvider = () =>
127 Provider.succeed(PostgresDatabase, {
128 stables: ["id", "organization", "region"],
129 diff: Effect.fn(function* ({ news, olds, output }) {
130 if (!isResolved(news)) return undefined;
131
132 // Database names are rename-mutable (reconcile folds `new_name`
133 // into the settings sync), so `name` cannot live in the
134 // provider-level stables. Almost no update is a rename though —
135 // for those, advertise `name` as stable on the update so
136 // downstream consumers (branches, roles) still resolve
137 // `database.name` at plan time instead of seeing `undefined` and
138 // falsely planning a replacement. The name only changes when the
139 // `name` prop itself changes: an explicit name renames iff it
140 // differs from the observed name, and an omitted name is
141 // engine-generated deterministically (stable across updates).
142 const nameIsStable =
143 output?.name !== undefined &&
144 (news.name !== undefined
145 ? news.name === output.name
146 : olds?.name === undefined);
147 const stables = nameIsStable
148 ? ["id", "organization", "region", "name"]
149 : undefined;
150
151 if (
152 news.region?.slug &&
153 output?.region?.slug &&
154 news.region.slug !== output.region.slug
155 ) {
156 return { action: "replace" } as const;
157 }
158
159 if (news.replicas !== olds.replicas) {
160 return { action: "replace" } as const;
161 }
162
163 const oldArch = output?.arch ?? olds.arch ?? "x86";
164 if (news.arch && news.arch !== oldArch) {
165 return { action: "replace" } as const;
166 }
167
168 if (yield* diffMigrations({ news, output })) {
169 return { action: "update", stables } as const;
170 }
171 if (news.importFiles?.length) {
172 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
173 if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
174 return { action: "update", stables } as const;
175 }
176 }
177
178 // Remaining prop changes (rename, settings, clusterSize, …) are
179 // in-place updates. Decide them here instead of falling back to
180 // the engine's default deep-compare so the conditional `name`
181 // stable above is attached — the default path uses the
182 // provider-level stables, which strip `name` from downstream plan
183 // resolution.
184 if (havePropsChanged(olds, news)) {
185 return { action: "update", stables } as const;
186 }
187
188 return undefined;
189 }),
190
191 read: Effect.fn(function* ({ id, output, olds }) {
192 const { organization } = yield* yield* Credentials;
193 const databaseName =
194 output?.name ?? (yield* createDatabaseName(id, olds?.name));
195
196 const data = yield* planetscale
197 .getDatabase({
198 organization,
199 database: databaseName,
200 })
201 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
202
203 if (!data) return undefined;
204
205 if (data.kind !== "postgresql") {
206 return yield* Effect.fail(
207 new PlanetscaleConflict({
208 message:
209 `Planetscale database "${data.name}" has kind "${data.kind}" but this resource ` +
210 `is a PostgresDatabase. Use Planetscale.${data.kind === "mysql" ? "MySQLDatabase" : data.kind}() instead, ` +
211 `or delete the existing database and retry.`,
212 }),
213 );
214 }
215
216 const defaultBranch = data.default_branch ?? "main";
217
218 // Observe `arch` and `clusterSize` from the default branch rather
219 // than echoing input.
220 const branch = yield* planetscale
221 .getBranch({
222 organization,
223 database: data.name,
224 branch: defaultBranch,
225 })
226 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
227
228 const arch: "x86" | "arm" =
229 branch!.cluster_architecture === "aarch64" ? "arm" : "x86";
230 const clusterSize = branch!.cluster_name;
231
232 return {
233 id: data.id,
234 name: data.name,
235 organization,
236 state: data.state,
237 defaultBranch,
238 plan: data.plan ?? "hobby",
239 createdAt: data.created_at,
240 updatedAt: data.updated_at,
241 htmlUrl: data.html_url,
242 region: { slug: data.region.slug },
243 migrationsDir:
244 output?.migrationsDir ?? (olds && migrationsInputOf(olds))?.dir,
245 migrationsTable:
246 output?.migrationsTable ?? (olds && migrationsInputOf(olds))?.table,
247 migrationsHashes: output?.migrationsHashes ?? {},
248 importHashes: output?.importHashes ?? {},
249 clusterSize,
250 arch,
251 requireApprovalForDeploy: data.require_approval_for_deploy ?? false,
252 restrictBranchRegion: data.restrict_branch_region ?? false,
253 productionBranchWebConsole: data.production_branch_web_console ?? false,
254 };
255 }),
256
257 reconcile: Effect.fn(function* ({ id, news, output, session }) {
258 const { organization } = yield* yield* Credentials;
259 const newName = yield* createDatabaseName(id, news.name);
260 const clusterSize = toPostgresClusterSku({
261 size: news.clusterSize,
262 arch: news.arch,
263 region: news.region?.slug,
264 });
265
266 // Observe — read live state under either the cached name (rename
267 // / refresh) or the freshly-derived name (greenfield). Observing
268 // unconditionally absorbs the race between `read` and `reconcile`
269 // — if a foreign actor (or a previously-failed-to-persist
270 // create) produced a database under the desired name in the
271 // meantime, we'll find it here and skip the duplicate create.
272 // Adoption routing has already been gated by `read` returning
273 // `Unowned`, so by the time we reach reconcile any observed
274 // database is fair game.
275 const observedName = output?.name ?? newName;
276 let observed = yield* planetscale
277 .getDatabase({
278 organization,
279 database: observedName,
280 })
281 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
282
283 // Ensure — if missing, create.
284 if (!observed) {
285 yield* session.note("Creating database...");
286 observed = yield* planetscale.createDatabase({
287 organization,
288 name: newName,
289 region: news.region?.slug,
290 kind: "postgresql",
291 cluster_size: clusterSize,
292 replicas: news.replicas,
293 major_version: news.majorVersion,
294 });
295 }
296
297 // Wait for the database to finish provisioning before any
298 // downstream sync runs. PlanetScale reports `state: "pending"`
299 // for a while after createDatabase returns, during which branch
300 // operations on this database (including our own settings PATCH)
301 // race against the provisioning fiber.
302 yield* waitForDatabaseReady(organization, observed.name, session);
303
304 if (observed.kind !== "postgresql") {
305 return yield* Effect.fail(
306 new PlanetscaleConflict({
307 message:
308 `Planetscale database "${observed.name}" has kind "${observed.kind}" but this resource ` +
309 `is a PostgresDatabase. Use Planetscale.${observed.kind === "mysql" ? "MySQLDatabase" : observed.kind}() instead, ` +
310 `or delete the existing database and retry.`,
311 }),
312 );
313 }
314
315 // Sync — ensure a non-`main` default branch exists before
316 // referencing it via `default_branch`. The branch is created
317 // empty (no cluster size yet); we'll size it below.
318 if (news.defaultBranch && news.defaultBranch !== "main") {
319 yield* waitForDatabaseReady(organization, observed.name, session);
320 const branchExists = yield* planetscale
321 .getBranch({
322 organization,
323 database: observed.name,
324 branch: news.defaultBranch,
325 })
326 .pipe(
327 Effect.map(() => true),
328 Effect.catchTag("NotFound", () => Effect.succeed(false)),
329 );
330 if (!branchExists) {
331 yield* planetscale.createBranch({
332 organization,
333 database: observed.name,
334 name: news.defaultBranch,
335 parent_branch: "main",
336 });
337 }
338 }
339
340 // Sync settings — `updateSettings` is upsert-shaped, so we can
341 // call it with the full desired payload on every reconcile.
342 // Folds the rename in via `new_name` so we don't pay for a
343 // separate PATCH.
344 const updated = yield* planetscale.updateDatabaseSettings({
345 organization,
346 database: observed.name,
347 new_name: newName !== observed.name ? newName : undefined,
348 require_approval_for_deploy: news.requireApprovalForDeploy,
349 restrict_branch_region: news.restrictBranchRegion,
350 production_branch_web_console: news.productionBranchWebConsole,
351 default_branch: news.defaultBranch,
352 });
353
354 // Sync cluster size on the active default branch.
355 const branch = news.defaultBranch ?? updated.default_branch ?? "main";
356 yield* ensurePostgresProductionBranchClusterSize(
357 organization,
358 updated.name,
359 branch,
360 news.clusterSize,
361 );
362
363 const migrationTarget = {
364 organization,
365 database: updated.name,
366 branch,
367 };
368 const migrationsInput = migrationsInputOf(news);
369 if (migrationsInput || news.importFiles?.length) {
370 yield* waitForBranchReady(organization, updated.name, branch, session);
371 }
372 const migrations = migrationsInput
373 ? yield* runPostgresMigrations(
374 migrationTarget,
375 migrationsInput,
376 stampedOf(output),
377 )
378 : undefined;
379 const importHashes = news.importFiles?.length
380 ? yield* runPostgresImports(
381 migrationTarget,
382 news.importFiles,
383 yield* rootDir,
384 output?.importHashes ?? {},
385 )
386 : {};
387
388 return {
389 id: updated.id,
390 name: updated.name,
391 organization,
392 state: updated.state,
393 defaultBranch: updated.default_branch ?? branch,
394 plan: updated.plan ?? output?.plan ?? "hobby",
395 createdAt: updated.created_at,
396 updatedAt: updated.updated_at,
397 htmlUrl: updated.html_url,
398 region: { slug: updated.region.slug },
399 clusterSize: clusterSize,
400 ...migrationsAttrs({ input: migrationsInput, run: migrations, output }),
401 importHashes,
402 arch: news.arch ?? output?.arch ?? "x86",
403 requireApprovalForDeploy: updated.require_approval_for_deploy ?? false,
404 restrictBranchRegion: updated.restrict_branch_region ?? false,
405 productionBranchWebConsole:
406 updated.production_branch_web_console ?? false,
407 } satisfies PostgresDatabaseAttributes;
408 }),
409
410 delete: Effect.fn(function* ({ output }) {
411 yield* planetscale
412 .deleteDatabase({
413 organization: output.organization,
414 database: output.name,
415 })
416 .pipe(Effect.catchTag("NotFound", () => Effect.void));
417 }),
418
419 list: Effect.fn(function* () {
420 const { organization } = yield* yield* Credentials;
421
422 const databases = yield* planetscale.listDatabases
423 .pages({ organization })
424 .pipe(
425 Stream.runCollect,
426 Effect.map((chunk) =>
427 Array.from(chunk).flatMap((page) =>
428 page.data.filter((db) => db.kind === "postgresql"),
429 ),
430 ),
431 );
432
433 const rows = yield* Effect.forEach(
434 databases,
435 (data) =>
436 Effect.gen(function* () {
437 const defaultBranch = data.default_branch ?? "main";
438 const branch = yield* planetscale
439 .getBranch({
440 organization,
441 database: data.name,
442 branch: defaultBranch,
443 })
444 .pipe(
445 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
446 );
447
448 const arch: "x86" | "arm" =
449 branch?.cluster_architecture === "aarch64" ? "arm" : "x86";
450 const clusterSize = branch?.cluster_name ?? "";
451
452 const attrs: PostgresDatabase["Attributes"] = {
453 id: data.id,
454 name: data.name,
455 organization,
456 state: data.state,
457 defaultBranch,
458 plan: data.plan ?? "hobby",
459 createdAt: data.created_at,
460 updatedAt: data.updated_at,
461 htmlUrl: data.html_url,
462 region: { slug: data.region.slug },
463 clusterSize,
464 migrationsDir: undefined,
465 migrationsTable: undefined,
466 migrationsHashes: {},
467 importHashes: {},
468 arch,
469 requireApprovalForDeploy:
470 data.require_approval_for_deploy ?? false,
471 restrictBranchRegion: data.restrict_branch_region ?? false,
472 productionBranchWebConsole:
473 data.production_branch_web_console ?? false,
474 };
475 return attrs;
476 }),
477 { concurrency: 10 },
478 );
479
480 return rows;
481 }),
482 });
483
484const createDatabaseName = (id: string, name: string | undefined) =>
485 Effect.gen(function* () {
486 return (
487 name ??
488 (yield* createPhysicalName({ id, lowercase: true, maxLength: 63 }))
489 );
490 });
491
492const rootDir = Effect.sync(() => process.cwd());