1import type {
2 ClusterCredentials,
3 GetClusterResponse,
4 ManagedCluster,
5} from "@distilled.cloud/fly-io/mpg";
6import * as machines from "@distilled.cloud/fly-io/machines";
7import * as mpg from "@distilled.cloud/fly-io/mpg";
8import * as Data from "effect/Data";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Redacted from "effect/Redacted";
12import * as Schedule from "effect/Schedule";
13import { Unowned } from "../AdoptPolicy.ts";
14import { isResolved } from "../Diff.ts";
15import * as Provider from "../Provider.ts";
16import { Resource } from "../Resource.ts";
17import {
18 diffMigrations,
19 migrationsAttrs,
20 migrationsInputOf,
21 stampedOf,
22 type MigrationsInput,
23} from "../SQL/Migrations/index.ts";
24import { hashImports } from "../SQL/SqlFile.ts";
25import { recordsEqual } from "../Util/equal.ts";
26import { resolveOrgSlug } from "./Environment.ts";
27import {
28 createFlyAppName,
29 matchesAlchemyPhysicalName,
30 sanitizeFlyAppName,
31} from "./Metadata.ts";
32import { runImports, runPgMigrations } from "./PostgresMigrations.ts";
33import type { Providers } from "./Providers.ts";
34
35export { stripSslQueryParams } from "./PostgresMigrations.ts";
36
37export const DEFAULT_POSTGRES_PLAN = "basic";
38export const DEFAULT_VOLUME_GB = 10;
39export const DATABASE_URL_SECRET = "DATABASE_URL";
40export const DIRECT_DATABASE_URL_SECRET = "DIRECT_DATABASE_URL";
41
42export type PostgresPlan =
43 | "basic"
44 | "starter"
45 | "launch"
46 | "scale"
47 | "performance"
48 | (string & {});
49
50export interface PostgresProps {
51 /**
52 * Region the cluster lives in (`iad`, `ord`, `sjc`, …). Required.
53 * The cluster is regional; a {@link App} is global. Changing it
54 * replaces the cluster.
55 */
56 region: string;
57 /**
58 * Hardware plan. `basic` is 2 shared vCPUs / 1 GB RAM.
59 *
60 * @default "basic"
61 */
62 plan?: PostgresPlan;
63 /**
64 * Cluster name. Unique in the organization. If omitted, a unique
65 * name is generated from the stack, stage and logical ID. Changing
66 * it replaces the cluster.
67 */
68 name?: string;
69 /**
70 * Organization slug. Defaults to the current token's org. Changing
71 * it replaces the cluster.
72 */
73 orgSlug?: string;
74 /**
75 * Initial volume size in GB. Fly defaults to 10. Create-only.
76 *
77 * @default 10
78 */
79 volumeSizeGb?: number;
80 /**
81 * Enable PostGIS. Create-only.
82 *
83 * @default false
84 */
85 postgis?: boolean;
86 /**
87 * Postgres major version (`16` or `17`). Create-only.
88 *
89 * @default 16
90 */
91 pgMajorVersion?: number | string;
92 /**
93 * SQL migrations to apply against the cluster. Accepts a directory
94 * path, a `Drizzle.Schema` resource, or `{ dir, table? }`.
95 *
96 * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A
97 * database previously migrated by drizzle-kit or Prisma is adopted by a
98 * one-way conversion on first deploy: the old tool's applied history is
99 * copied into Alchemy's table and the old table is left frozen. No
100 * baselining required.
101 *
102 * Applied over the **direct** (non-PgBouncer) URI. Fly Managed Postgres
103 * is reachable on the org private network; deploy-time apply needs a
104 * route to that network (a WireGuard peer, `fly mpg proxy`, or a
105 * machine already on 6PN).
106 */
107 migrations?: MigrationsInput;
108 /**
109 * Paths to additional `.sql` files to apply after migrations. Each file
110 * is hashed; only files whose contents change are re-applied on
111 * subsequent deploys.
112 */
113 importFiles?: string[];
114}
115
116export type Postgres = Resource<
117 "Fly.Postgres",
118 PostgresProps,
119 {
120 /** Fly Managed Postgres cluster id. */
121 clusterId: string;
122 /** Cluster name (unique in the org). */
123 name: string;
124 /** Observed status (`creating`, `ready`, `error`, `deleted`, …). */
125 status: string | undefined;
126 /** Region the cluster lives in. */
127 region: string;
128 /** Observed hardware plan. */
129 plan: string | undefined;
130 /** Organization slug. */
131 orgSlug: string | undefined;
132 /** Observed disk size in GB. */
133 disk: number | undefined;
134 /** Whether PostGIS is enabled. */
135 postgisEnabled: boolean | undefined;
136 /** Observed engine string, if the API returned one. */
137 engine: string | undefined;
138 /** Observed replica count. */
139 replicas: number | undefined;
140 /** Internal MPG cluster hash id, if the API returned one. */
141 mpgdClusterId: string | undefined;
142 /**
143 * Direct (non-PgBouncer) Postgres URI. Use this for migrations and
144 * session-scoped features. Pass to `Drizzle.Postgres` from a laptop
145 * Action; from a {@link Service} prefer {@link ConnectPostgres}.
146 */
147 connectionUri: string;
148 /** Pooled PgBouncer URI. Prefer {@link ConnectPostgres} from a Service. */
149 pooledConnectionUri: string;
150 migrationsDir: string | undefined;
151 migrationsTable: string | undefined;
152 migrationsHashes: Record<string, string>;
153 importHashes: Record<string, string>;
154 },
155 never,
156 Providers
157>;
158
159/**
160 * A Fly.Postgres is a Managed Postgres (MPG) cluster. It is billed.
161 * Do not wrap unmanaged `fly postgres`.
162 *
163 * @see https://fly.io/docs/mpg/create-and-connect/
164 *
165 * ### Create a cluster
166 * `region` is required. Alchemy generates a unique name unless you
167 * pass one. Omit `name` in tests and CI.
168 *
169 * **Example:** Generated name
170 * ```typescript
171 * const db = yield* Fly.Postgres("Db", {
172 * region: "iad",
173 * });
174 * ```
175 *
176 * :::caution[Billed]
177 * Managed Postgres is billed. Basic is about $38 per month.
178 * :::
179 *
180 * ### Plan
181 * `plan` is the hardware size: `basic`, `starter`, `launch`,
182 * `scale`, `performance`. Default is `basic`.
183 *
184 * **Example:** Starter
185 * ```typescript
186 * const db = yield* Fly.Postgres("Db", {
187 * region: "iad",
188 * plan: "starter",
189 * });
190 * ```
191 *
192 * :::note[Create-only]
193 * Changing `plan` later is ignored. Fly has no cluster update API.
194 * :::
195 *
196 * ### Region
197 * The cluster is regional. An {@link App} is global. Pass `region`
198 * on the cluster, not on the App.
199 *
200 * **Example:** Pin a region
201 * ```typescript
202 * const db = yield* Fly.Postgres("Db", {
203 * region: "lhr",
204 * });
205 * ```
206 *
207 * :::caution[Changing `region` replaces the cluster]
208 * A new cluster is created in the new region. The old cluster is
209 * deleted. Data is not copied.
210 * :::
211 *
212 * ### Volume size
213 * `volumeSizeGb` is the initial disk. Fly defaults to 10 GB.
214 *
215 * **Example:** 20 GB
216 * ```typescript
217 * const db = yield* Fly.Postgres("Db", {
218 * region: "iad",
219 * volumeSizeGb: 20,
220 * });
221 * ```
222 *
223 * :::note[Create-only]
224 * Changing `volumeSizeGb` later is ignored.
225 * :::
226 *
227 * ### PostGIS
228 * `postgis: true` enables PostGIS at create.
229 *
230 * **Example:** Enable PostGIS
231 * ```typescript
232 * const db = yield* Fly.Postgres("Db", {
233 * region: "iad",
234 * postgis: true,
235 * });
236 * ```
237 *
238 * :::note[Create-only]
239 * Flipping `postgis` later is ignored.
240 * :::
241 *
242 * ### Connect from a Service
243 * Yield `ConnectPostgres` inside init. Provide
244 * {@link ConnectPostgresHttp}. Pass `conn.connectionString` to
245 * `Drizzle.Postgres` or `SQL.Postgres`.
246 *
247 * **Example:** Bind and query
248 * ```typescript
249 * import * as Drizzle from "alchemy/Drizzle/Postgres";
250 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
251 *
252 * export default class Api extends Fly.Service<Api>()(
253 * "Api",
254 * { app: Site, main: import.meta.url, port: 3000 },
255 * Effect.gen(function* () {
256 * const conn = yield* Fly.ConnectPostgres(Db);
257 * const db = yield* Drizzle.Postgres(conn.connectionString);
258 * return {
259 * fetch: Effect.gen(function* () {
260 * const rows = yield* db.execute("select 1 as ok");
261 * return HttpServerResponse.json({ rows });
262 * }),
263 * };
264 * }).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
265 * ) {}
266 * ```
267 *
268 * ### Migrations
269 * Pass a directory, `{ dir, table? }`, or a `Drizzle.Schema` resource.
270 * Alchemy applies pending files on deploy over the direct URI.
271 *
272 * **Example:** Directory
273 * ```typescript
274 * const db = yield* Fly.Postgres("Db", {
275 * region: "iad",
276 * migrations: "./migrations",
277 * });
278 * ```
279 *
280 * **Example:** Drizzle.Schema
281 * ```typescript
282 * const schema = yield* Drizzle.Schema("app-schema", {
283 * schema: "./src/schema.ts",
284 * out: "./migrations",
285 * });
286 *
287 * const db = yield* Fly.Postgres("Db", {
288 * region: "iad",
289 * migrations: schema,
290 * });
291 * ```
292 *
293 * @resource
294 * @product Postgres
295 */
296export const Postgres = Resource<Postgres>("Fly.Postgres");
297
298export class PostgresNotCreated extends Data.TaggedError(
299 "Fly.PostgresNotCreated",
300)<{
301 name: string;
302 orgSlug: string;
303}> {}
304
305export class PostgresCreateFailed extends Data.TaggedError(
306 "Fly.PostgresCreateFailed",
307)<{
308 clusterId: string;
309 status: string;
310}> {}
311
312export class PostgresCredentialsMissing extends Data.TaggedError(
313 "Fly.PostgresCredentialsMissing",
314)<{
315 clusterId: string;
316}> {}
317
318export class PostgresAttachmentFailed extends Data.TaggedError(
319 "Fly.PostgresAttachmentFailed",
320)<{
321 clusterId: string;
322 appName: string;
323}> {}
324
325class PostgresPending extends Data.TaggedError("Fly.PostgresPending")<{
326 clusterId: string;
327 status: string;
328}> {}
329
330const backoff = Schedule.min([
331 Schedule.exponential(Duration.millis(500), 1.5),
332 Schedule.spaced(Duration.seconds(5)),
333]);
334
335export const isLiveCluster = (
336 cluster: ManagedCluster | undefined,
337): cluster is ManagedCluster =>
338 cluster !== undefined &&
339 (cluster.id ?? "").length > 0 &&
340 cluster.status !== "deleted";
341
342export const unwrapSensitive = (
343 value: string | Redacted.Redacted<string> | undefined,
344): string | undefined => {
345 if (value === undefined) return undefined;
346 return Redacted.isRedacted(value) ? Redacted.value(value) : value;
347};
348
349type AttrFallback = {
350 name?: string;
351 orgSlug?: string;
352 connectionUri?: string;
353 pooledConnectionUri?: string;
354 migrationsDir?: string | undefined;
355 migrationsTable?: string | undefined;
356 migrationsHashes?: Record<string, string>;
357 importHashes?: Record<string, string>;
358};
359
360const toAttrs = (
361 cluster: ManagedCluster,
362 fallback?: AttrFallback,
363 credentials?: ClusterCredentials,
364): Postgres["Attributes"] => ({
365 clusterId: cluster.id ?? "",
366 name: cluster.name ?? fallback?.name ?? "",
367 status: cluster.status,
368 region: cluster.region ?? "",
369 plan: cluster.plan,
370 orgSlug: cluster.organization?.slug ?? fallback?.orgSlug,
371 disk: cluster.disk,
372 postgisEnabled: cluster.postgis_enabled,
373 engine: cluster.engine,
374 replicas: cluster.replicas,
375 mpgdClusterId: cluster.mpgd_cluster_id,
376 connectionUri:
377 directUri(cluster, credentials) ?? fallback?.connectionUri ?? "",
378 pooledConnectionUri:
379 credentialsUri(credentials) ?? fallback?.pooledConnectionUri ?? "",
380 migrationsDir: fallback?.migrationsDir,
381 migrationsTable: fallback?.migrationsTable,
382 migrationsHashes: fallback?.migrationsHashes ?? {},
383 importHashes: fallback?.importHashes ?? {},
384});
385
386const resolveName = (id: string, name: string | undefined, existing?: string) =>
387 Effect.gen(function* () {
388 if (name !== undefined) return sanitizeFlyAppName(name);
389 if (existing !== undefined) return existing;
390 return yield* createFlyAppName(id);
391 });
392
393export const getLiveCluster = (clusterId: string) =>
394 mpg.getClusterById({ id: clusterId }).pipe(
395 Effect.map((res) => (isLiveCluster(res.data) ? res.data : undefined)),
396 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
397 );
398
399export const getClusterResponse = (clusterId: string) =>
400 mpg.getClusterById({ id: clusterId }).pipe(
401 Effect.map((res): GetClusterResponse | undefined =>
402 isLiveCluster(res.data) ? res : undefined,
403 ),
404 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
405 );
406
407const listOrgClusters = (orgSlug: string) =>
408 mpg.listClusters({ org_slug: orgSlug }).pipe(
409 Effect.map((res) => (res.data ?? []).filter((row) => isLiveCluster(row))),
410 Effect.catchTag(["NotFound", "Forbidden"], () =>
411 Effect.succeed([] as ManagedCluster[]),
412 ),
413 );
414
415const findByName = (orgSlug: string, name: string) =>
416 listOrgClusters(orgSlug).pipe(
417 Effect.map((rows) => rows.find((row) => row.name === name)),
418 );
419
420const waitUntilReady = (clusterId: string) =>
421 getLiveCluster(clusterId).pipe(
422 Effect.flatMap(
423 (
424 cluster,
425 ): Effect.Effect<
426 ManagedCluster,
427 PostgresPending | PostgresCreateFailed
428 > => {
429 if (cluster === undefined) {
430 return Effect.fail(
431 new PostgresPending({ clusterId, status: "missing" }),
432 );
433 }
434 if (cluster.status === "ready") return Effect.succeed(cluster);
435 if (cluster.status === "error") {
436 return Effect.fail(
437 new PostgresCreateFailed({
438 clusterId,
439 status: cluster.status,
440 }),
441 );
442 }
443 return Effect.fail(
444 new PostgresPending({
445 clusterId,
446 status: cluster.status ?? "creating",
447 }),
448 );
449 },
450 ),
451 Effect.retry({
452 while: (e) => e._tag === "Fly.PostgresPending",
453 times: 10,
454 schedule: backoff,
455 }),
456 Effect.catchTag("Fly.PostgresPending", () => getLiveCluster(clusterId)),
457 );
458
459const waitForCredentials = (clusterId: string) =>
460 getClusterResponse(clusterId).pipe(
461 Effect.flatMap((res) => {
462 const uri = credentialsUri(res?.credentials);
463 if (uri !== undefined && uri.length > 0) {
464 return Effect.succeed(res);
465 }
466 return Effect.fail(new PostgresCredentialsPending({ clusterId }));
467 }),
468 Effect.retry({
469 while: (e) => e._tag === "Fly.PostgresCredentialsPending",
470 times: 16,
471 schedule: backoff,
472 }),
473 Effect.catchTag("Fly.PostgresCredentialsPending", () =>
474 getClusterResponse(clusterId),
475 ),
476 );
477
478const waitUntilGone = (clusterId: string) =>
479 mpg.getClusterById({ id: clusterId }).pipe(
480 Effect.map(
481 (res) => res.data === undefined || res.data.status === "deleted",
482 ),
483 Effect.catchTag("NotFound", () => Effect.succeed(true)),
484 Effect.repeat({
485 schedule: Schedule.spaced("2 seconds"),
486 until: (gone) => gone,
487 times: 10,
488 }),
489 );
490
491const desiredPlan = (plan: PostgresPlan | undefined) =>
492 plan ?? DEFAULT_POSTGRES_PLAN;
493
494const desiredDisk = (size: number | undefined) =>
495 size === undefined ? DEFAULT_VOLUME_GB : Math.max(1, size);
496
497const pgMajor = (value: number | string | undefined) =>
498 value === undefined ? undefined : String(value);
499
500export const credentialsUri = (
501 credentials: ClusterCredentials | undefined,
502): string | undefined => unwrapSensitive(credentials?.pgbouncer_uri);
503
504export const directUri = (
505 _cluster: ManagedCluster | undefined,
506 credentials: ClusterCredentials | undefined,
507): string | undefined => {
508 // Derive the direct host from the server-provided PgBouncer URI:
509 // `pgbouncer.<hash>.flympg.net` → `direct.<hash>.flympg.net`. The `<hash>`
510 // is the cluster's DNS hash, which is NOT any id the cluster API returns —
511 // `mpgd_cluster_id` is the `fly-mpg-…` cluster id, and a host synthesized
512 // from it does not resolve. Rewriting the observed pooled host is the only
513 // reliable derivation.
514 const pooled = unwrapSensitive(credentials?.pgbouncer_uri);
515 if (pooled === undefined || pooled.length === 0) return undefined;
516 try {
517 const url = new URL(pooled);
518 if (!url.hostname.startsWith("pgbouncer.")) return undefined;
519 url.hostname = url.hostname.replace(/^pgbouncer\./, "direct.");
520 return url.toString();
521 } catch {
522 return undefined;
523 }
524};
525
526const secretVersion = (
527 res: { version?: number; Version?: number } | undefined,
528) => res?.version ?? res?.Version;
529
530const putSecret = (appName: string, name: string, value: string) =>
531 machines
532 .createSecret({
533 app_name: appName,
534 secret_name: name,
535 value,
536 })
537 .pipe(
538 Effect.map(secretVersion),
539 Effect.catchTag("Conflict", () =>
540 machines
541 .updateSecrets({
542 app_name: appName,
543 values: { [name]: value },
544 })
545 .pipe(Effect.map(secretVersion)),
546 ),
547 );
548
549class PostgresCredentialsPending extends Data.TaggedError(
550 "Fly.PostgresCredentialsPending",
551)<{
552 clusterId: string;
553}> {}
554
555/**
556 * Write `DATABASE_URL` (and `DIRECT_DATABASE_URL` if present) onto an
557 * App and record the MPG attachment. Called from {@link Service}
558 * reconcile when {@link ConnectPostgres} binds a cluster.
559 */
560export const attachPostgresSecrets = Effect.fn(function* (
561 appName: string,
562 clusterId: string,
563 variableName: string = DATABASE_URL_SECRET,
564) {
565 if (appName.length === 0 || clusterId.length === 0) return undefined;
566 const creds = yield* getClusterResponse(clusterId).pipe(
567 Effect.flatMap((res) => {
568 const uri = credentialsUri(res?.credentials);
569 if (uri !== undefined && uri.length > 0) {
570 return Effect.succeed({
571 uri,
572 direct: directUri(res?.data, res?.credentials),
573 });
574 }
575 return Effect.fail(new PostgresCredentialsPending({ clusterId }));
576 }),
577 Effect.retry({
578 while: (e) => e._tag === "Fly.PostgresCredentialsPending",
579 times: 10,
580 schedule: backoff,
581 }),
582 Effect.catchTag("Fly.PostgresCredentialsPending", () =>
583 Effect.succeed(undefined),
584 ),
585 );
586 if (creds === undefined) {
587 return yield* new PostgresCredentialsMissing({ clusterId });
588 }
589 const versions: number[] = [];
590 const pooledVersion = yield* putSecret(appName, variableName, creds.uri);
591 if (pooledVersion !== undefined) versions.push(pooledVersion);
592 if (creds.direct !== undefined) {
593 const directVersion = yield* putSecret(
594 appName,
595 DIRECT_DATABASE_URL_SECRET,
596 creds.direct,
597 );
598 if (directVersion !== undefined) versions.push(directVersion);
599 }
600 yield* mpg
601 .createAttachment({
602 id: clusterId,
603 app_name: appName,
604 })
605 .pipe(
606 Effect.asVoid,
607 Effect.catchTag("Conflict", () => Effect.void),
608 Effect.retry({
609 while: (e) => e._tag === "NotFound",
610 times: 8,
611 schedule: backoff,
612 }),
613 Effect.timeout("40 seconds"),
614 Effect.catchTag(
615 "NotFound",
616 () =>
617 new PostgresAttachmentFailed({
618 clusterId,
619 appName,
620 }),
621 ),
622 );
623 return versions.length > 0 ? Math.max(...versions) : undefined;
624});
625
626const rootDir = Effect.sync(() => process.cwd());
627
628export const PostgresProvider = () =>
629 Provider.succeed(Postgres, {
630 stables: ["clusterId", "name", "region", "orgSlug"],
631 nuke: { dependsOn: ["Fly.App"] },
632
633 diff: Effect.fn(function* ({ news, output }) {
634 if (news === undefined || !isResolved(news)) return undefined;
635 if (output === undefined) return undefined;
636 const desiredName =
637 news.name !== undefined ? sanitizeFlyAppName(news.name) : output.name;
638 const nameChanged = desiredName !== output.name;
639 const regionChanged = news.region !== output.region;
640 const orgChanged =
641 news.orgSlug !== undefined && news.orgSlug !== output.orgSlug;
642 if (nameChanged || regionChanged || orgChanged) {
643 return { action: "replace" as const };
644 }
645 if (yield* diffMigrations({ news, output })) {
646 return { action: "update" as const };
647 }
648 if (news.importFiles?.length) {
649 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
650 if (!recordsEqual(newHashes, output.importHashes ?? {})) {
651 return { action: "update" as const };
652 }
653 }
654 return undefined;
655 }),
656
657 read: Effect.fn(function* ({ id, olds, output }) {
658 const found =
659 output?.clusterId !== undefined && output.clusterId.length > 0
660 ? yield* getClusterResponse(output.clusterId)
661 : undefined;
662 if (found?.data !== undefined) {
663 return toAttrs(found.data, output, found.credentials);
664 }
665 const name = yield* resolveName(id, olds?.name, output?.name);
666 const orgSlug =
667 olds?.orgSlug ?? output?.orgSlug ?? (yield* resolveOrgSlug());
668 const byName = yield* findByName(orgSlug, name);
669 if (byName === undefined) return undefined;
670 const hydrated =
671 byName.id !== undefined
672 ? yield* getClusterResponse(byName.id)
673 : undefined;
674 const attrs = toAttrs(
675 hydrated?.data ?? byName,
676 { ...output, name, orgSlug },
677 hydrated?.credentials,
678 );
679 if (output !== undefined) return attrs;
680 return matchesAlchemyPhysicalName(name) ? attrs : Unowned(attrs);
681 }),
682
683 list: Effect.fn(function* () {
684 const orgSlug = yield* resolveOrgSlug();
685 const rows = yield* listOrgClusters(orgSlug);
686 return rows.flatMap((cluster) => {
687 if (!matchesAlchemyPhysicalName(cluster.name)) return [];
688 return [
689 toAttrs(cluster, {
690 orgSlug,
691 migrationsDir: undefined,
692 migrationsTable: undefined,
693 migrationsHashes: {},
694 importHashes: {},
695 }),
696 ];
697 });
698 }),
699
700 reconcile: Effect.fn(function* ({ id, news, output }) {
701 const props = news ?? ({} as PostgresProps);
702 const name = yield* resolveName(id, props.name, output?.name);
703 const orgSlug = props.orgSlug ?? (yield* resolveOrgSlug());
704 const region = props.region ?? output?.region;
705
706 let current =
707 output?.clusterId !== undefined && output.clusterId.length > 0
708 ? yield* getLiveCluster(output.clusterId)
709 : undefined;
710 if (
711 current === undefined &&
712 (output === undefined ||
713 output.name !== name ||
714 output.orgSlug !== orgSlug)
715 ) {
716 current = yield* findByName(orgSlug, name);
717 }
718
719 if (current === undefined) {
720 if (region === undefined || region.length === 0) {
721 return yield* new PostgresNotCreated({ name, orgSlug });
722 }
723 const created = yield* mpg
724 .createCluster({
725 org_slug: orgSlug,
726 name,
727 region,
728 plan: desiredPlan(props.plan),
729 storage_in_gb: desiredDisk(props.volumeSizeGb),
730 postgis_enabled: props.postgis,
731 pg_major_version: pgMajor(props.pgMajorVersion),
732 })
733 .pipe(
734 Effect.map((res) => res.data),
735 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
736 );
737 current = isLiveCluster(created)
738 ? created
739 : yield* findByName(orgSlug, name);
740 }
741
742 if (current === undefined || current.id === undefined) {
743 return yield* new PostgresNotCreated({ name, orgSlug });
744 }
745 // `current` is reassigned below; the id is stable across the waits.
746 const clusterId = current.id;
747
748 if (current.status !== "ready") {
749 current =
750 (yield* waitUntilReady(clusterId)) ??
751 (yield* getLiveCluster(clusterId)) ??
752 current;
753 }
754
755 if (current.status === "error") {
756 return yield* new PostgresCreateFailed({
757 clusterId,
758 status: current.status,
759 });
760 }
761
762 const observed = yield* waitForCredentials(clusterId);
763 const cluster = observed?.data ?? current;
764 const credentials = observed?.credentials;
765 const migrationUri =
766 directUri(cluster, credentials) ?? credentialsUri(credentials);
767
768 const migrationsInput = migrationsInputOf(props);
769 if (
770 migrationsInput &&
771 (migrationUri === undefined || migrationUri.length === 0)
772 ) {
773 return yield* new PostgresCredentialsMissing({ clusterId });
774 }
775 const connectionUri =
776 migrationUri !== undefined ? Redacted.make(migrationUri) : undefined;
777 const migrations =
778 migrationsInput && connectionUri !== undefined
779 ? yield* runPgMigrations({
780 connectionUri,
781 input: migrationsInput,
782 stamped: stampedOf(output),
783 })
784 : undefined;
785 const importHashes =
786 props.importFiles?.length && connectionUri !== undefined
787 ? yield* runImports(
788 connectionUri,
789 props.importFiles,
790 yield* rootDir,
791 output?.importHashes ?? {},
792 )
793 : (output?.importHashes ?? {});
794
795 return {
796 ...toAttrs(cluster, { name, orgSlug, ...output }, credentials),
797 ...migrationsAttrs({
798 input: migrationsInput,
799 run: migrations,
800 output,
801 }),
802 importHashes,
803 };
804 }),
805
806 delete: Effect.fn(function* ({ output }) {
807 const clusterId = output.clusterId;
808 if (clusterId.length === 0) return;
809 const orgSlug = output.orgSlug ?? (yield* resolveOrgSlug());
810 const observed = yield* getClusterResponse(clusterId);
811 const apps = observed?.data?.attached_apps ?? [];
812 yield* Effect.forEach(
813 apps,
814 (app) => {
815 const appName = app.name;
816 if (appName === undefined || appName.length === 0) {
817 return Effect.void;
818 }
819 return Effect.gen(function* () {
820 yield* mpg
821 .deleteAttachment({ id: clusterId, app_name: appName })
822 .pipe(Effect.catchTag("NotFound", () => Effect.void));
823 yield* machines
824 .deleteSecret({
825 app_name: appName,
826 secret_name: DATABASE_URL_SECRET,
827 })
828 .pipe(Effect.catchTag("NotFound", () => Effect.void));
829 yield* machines
830 .deleteSecret({
831 app_name: appName,
832 secret_name: DIRECT_DATABASE_URL_SECRET,
833 })
834 .pipe(Effect.catchTag("NotFound", () => Effect.void));
835 });
836 },
837 { concurrency: 4 },
838 );
839 yield* mpg
840 .destroyCluster({ org_slug: orgSlug, id: clusterId })
841 .pipe(Effect.catchTag("NotFound", () => Effect.void));
842 yield* waitUntilGone(clusterId);
843 }),
844 });