1import * as sqladmin from "@distilled.cloud/gcp/sqladmin_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitForSqlOperation } from "./operations.ts";
22
23const DEFAULT_DATABASE_VERSION = "MYSQL_8_0";
24const DEFAULT_TIER = "db-f1-micro";
25const DEFAULT_EDITION = "ENTERPRISE";
26const DEFAULT_AVAILABILITY = "ZONAL";
27const DEFAULT_ACTIVATION = "ALWAYS";
28const DEFAULT_DISK_TYPE = "PD_SSD";
29const DEFAULT_DISK_SIZE_GB = 10;
30const MAX_NAME_LENGTH = 63;
31/**
32 * Generated names stay short enough that the connection name
33 * `{project}:{region}:{instance}` fits Cloud Run's 97-character limit for
34 * Cloud SQL volumes (30-character project ids, 23-character regions).
35 */
36const GENERATED_NAME_MAX_LENGTH = 42;
37
38export type DatabaseFlag = {
39 /** Flag name (underscores, not hyphens). */
40 name: string;
41 /**
42 * Flag value. Boolean flags use `on` / `off`. Omit for flags that take
43 * no value.
44 */
45 value?: string;
46};
47
48export type InstanceProps = {
49 /**
50 * Cloud SQL instance id (the `{instance}` segment of
51 * `projects/{project}/instances/{instance}`). If omitted, a unique name
52 * is generated from the stack, stage, and logical id. Must start with a
53 * letter, contain only lowercase letters, numbers, and hyphens, and not
54 * end with a hyphen (1–98 characters). Immutable — changing it replaces
55 * the instance.
56 */
57 instanceName?: string;
58 /**
59 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
60 * replaces the instance. `US-CENTRAL1` is accepted and normalized to
61 * `us-central1`.
62 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
63 */
64 region?: string;
65 /**
66 * Database engine and major version (`MYSQL_8_0`, `POSTGRES_15`, …).
67 * Immutable — changing it replaces the instance.
68 * @default "MYSQL_8_0"
69 */
70 databaseVersion?:
71 | sqladmin.DatabaseInstanceDatabaseVersionEnum
72 | (string & {});
73 /**
74 * Machine type (`db-f1-micro`, `db-custom-1-3840`, …). Changing it
75 * restarts the instance.
76 * @default "db-f1-micro"
77 */
78 tier?: string;
79 /**
80 * Edition (`ENTERPRISE`, `ENTERPRISE_PLUS`, `DEVELOPER`).
81 * @default "ENTERPRISE"
82 */
83 edition?: sqladmin.SettingsEditionEnum | (string & {});
84 /**
85 * User labels. Alchemy ownership labels are merged in automatically
86 * onto `settings.userLabels`.
87 */
88 labels?: Record<string, string>;
89 /**
90 * Preferred Compute Engine zone (`us-central1-a`). Changing it may
91 * restart the instance.
92 */
93 zone?: string;
94 /**
95 * High-availability type. `REGIONAL` requires backups.
96 * @default "ZONAL"
97 */
98 availabilityType?: sqladmin.SettingsAvailabilityTypeEnum | (string & {});
99 /**
100 * When the instance is activated. `NEVER` stops it.
101 * @default "ALWAYS"
102 */
103 activationPolicy?: sqladmin.SettingsActivationPolicyEnum | (string & {});
104 /**
105 * Data disk size in GiB. Minimum 10. May only increase after create.
106 * @default 10
107 */
108 dataDiskSizeGb?: number;
109 /**
110 * Data disk type (`PD_SSD`, `PD_HDD`).
111 * @default "PD_SSD"
112 */
113 dataDiskType?: sqladmin.SettingsDataDiskTypeEnum | (string & {});
114 /**
115 * Assign a public IPv4 address.
116 * @default true
117 */
118 ipv4Enabled?: boolean;
119 /**
120 * Enable automated backups. MySQL also enables binary logs when true.
121 * @default false
122 */
123 backupEnabled?: boolean;
124 /**
125 * Protect against accidental instance deletion. Disabled automatically
126 * on destroy.
127 * @default false
128 */
129 deletionProtectionEnabled?: boolean;
130 /**
131 * Database flags passed at instance startup.
132 */
133 databaseFlags?: DatabaseFlag[];
134 /**
135 * Allow Cloud SQL `instances.executeSql`. Required for `ExecuteSql`.
136 * @default false
137 */
138 dataApiAccess?: boolean;
139 /**
140 * Initial root password. Create-only. Required to connect to
141 * PostgreSQL.
142 */
143 rootPassword?: string;
144};
145
146export type Instance = Resource<
147 "GCP.SQL.Instance",
148 InstanceProps,
149 {
150 /** Cloud SQL instance id. */
151 instanceName: string;
152 /** Project id. */
153 project: string;
154 /** Region (`us-central1`, …). */
155 region: string;
156 /** Database engine and version. */
157 databaseVersion: string | undefined;
158 /** Installed version including minor, if reported. */
159 databaseInstalledVersion: string | undefined;
160 /** Machine type. */
161 tier: string | undefined;
162 /** Edition (`ENTERPRISE`, …). */
163 edition: string | undefined;
164 /** Serving state (`RUNNABLE`, `PENDING_CREATE`, …). */
165 state: string | undefined;
166 /** User labels (Alchemy ownership labels stripped). */
167 labels: Record<string, string>;
168 /** Connection name `project:region:instance`. */
169 connectionName: string | undefined;
170 /** Preferred / current GCE zone. */
171 gceZone: string | undefined;
172 /** Public IPv4, if any. */
173 ipAddress: string | undefined;
174 /** Whether public IPv4 is enabled. */
175 ipv4Enabled: boolean;
176 /** Whether deletion protection is enabled. */
177 deletionProtectionEnabled: boolean;
178 /** Activation policy (`ALWAYS`, `NEVER`, …). */
179 activationPolicy: string | undefined;
180 /** Availability type (`ZONAL`, `REGIONAL`). */
181 availabilityType: string | undefined;
182 /** Data disk size in GiB. */
183 dataDiskSizeGb: number | undefined;
184 /** Whether automated backups are enabled. */
185 backupEnabled: boolean;
186 /** Service account email used by the instance. */
187 serviceAccountEmailAddress: string | undefined;
188 /** SQL Admin self-link. */
189 selfLink: string | undefined;
190 /** RFC3339 creation timestamp. */
191 createTime: string | undefined;
192 },
193 never,
194 Providers
195>;
196
197/**
198 * A Cloud SQL database instance.
199 *
200 * Changing `instanceName`, `region`, or `databaseVersion` replaces the
201 * instance. Provisioning typically takes several minutes (often 5–15).
202 *
203 * ### Creating an Instance
204 * **Example:** Generated name, MySQL 8.0 shared-core
205 * ```typescript
206 * const db = yield* GCP.SQL.Instance("AppDb", {});
207 * ```
208 *
209 * **Example:** Explicit name, labels, and backups off
210 * ```typescript
211 * const db = yield* GCP.SQL.Instance("AppDb", {
212 * instanceName: "app-db",
213 * region: "us-central1",
214 * databaseVersion: "MYSQL_8_0",
215 * tier: "db-f1-micro",
216 * labels: { env: "prod" },
217 * backupEnabled: false,
218 * deletionProtectionEnabled: false,
219 * });
220 * ```
221 *
222 * ### PostgreSQL
223 * **Example:** Postgres 15 with a root password
224 * ```typescript
225 * const db = yield* GCP.SQL.Instance("AppDb", {
226 * databaseVersion: "POSTGRES_15",
227 * rootPassword: "change-me",
228 * tier: "db-f1-micro",
229 * });
230 * ```
231 *
232 * @resource
233 * @category SQL
234 */
235export const Instance = Resource<Instance>("GCP.SQL.Instance");
236
237export class InstanceNotResolved extends Data.TaggedError(
238 "GCP.SQL.InstanceNotResolved",
239)<{
240 instanceName: string;
241 project: string;
242}> {}
243
244export class InstanceNotReady extends Data.TaggedError(
245 "GCP.SQL.InstanceNotReady",
246)<{
247 instanceName: string;
248 state: string;
249}> {}
250
251export class InstanceFailed extends Data.TaggedError("GCP.SQL.InstanceFailed")<{
252 instanceName: string;
253 state: string;
254}> {}
255
256export class InstanceStillExists extends Data.TaggedError(
257 "GCP.SQL.InstanceStillExists",
258)<{
259 instanceName: string;
260}> {}
261
262const lastSegment = (value: string | undefined): string => {
263 if (value === undefined || value.length === 0) return "";
264 const trimmed = value.replace(/\/+$/, "");
265 const parts = trimmed.split("/");
266 return parts[parts.length - 1] ?? value;
267};
268
269const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
270 lastSegment(region ?? defaultRegion).toLowerCase();
271
272const normalizeVersion = (version: string | undefined) =>
273 (version ?? DEFAULT_DATABASE_VERSION).toUpperCase();
274
275const normalizeTier = (tier: string | undefined) => tier ?? DEFAULT_TIER;
276
277const normalizeEdition = (edition: string | undefined) =>
278 (edition ?? DEFAULT_EDITION).toUpperCase();
279
280const normalizeAvailability = (value: string | undefined) =>
281 (value ?? DEFAULT_AVAILABILITY).toUpperCase();
282
283const normalizeActivation = (value: string | undefined) =>
284 (value ?? DEFAULT_ACTIVATION).toUpperCase();
285
286const normalizeDiskType = (value: string | undefined) =>
287 (value ?? DEFAULT_DISK_TYPE).toUpperCase();
288
289const isMysql = (version: string) => version.toUpperCase().startsWith("MYSQL_");
290
291const isPostgres = (version: string) =>
292 version.toUpperCase().startsWith("POSTGRES_");
293
294const rfc1035 = (name: string): string => {
295 let next = name
296 .toLowerCase()
297 .replace(/[^a-z0-9-]/g, "-")
298 .replace(/-+/g, "-")
299 .replace(/^-+|-+$/g, "");
300 if (!/^[a-z]/.test(next)) next = `s${next}`;
301 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
302 if (next.length === 0) return "instance";
303 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
304 return next.slice(0, MAX_NAME_LENGTH);
305};
306
307const userLabels = (
308 labels: Record<string, string | undefined> | null | undefined,
309): Record<string, string> => stripInternalLabels(tagRecord(labels));
310
311const toName = (id: string, name: string | undefined, existing?: string) =>
312 Effect.gen(function* () {
313 return (
314 name ??
315 existing ??
316 rfc1035(
317 yield* createPhysicalName({
318 id,
319 maxLength: GENERATED_NAME_MAX_LENGTH,
320 lowercase: true,
321 }),
322 )
323 );
324 });
325
326const flagsKey = (flags: sqladmin.DatabaseFlagsList | undefined) =>
327 JSON.stringify(
328 [...(flags ?? [])]
329 .map((flag) => `${flag.name ?? ""}=${flag.value ?? ""}`)
330 .sort(),
331 );
332
333const diskSizeOf = (value: string | number | undefined) => {
334 if (value === undefined) return undefined;
335 const parsed = typeof value === "number" ? value : Number(value);
336 return Number.isFinite(parsed) ? parsed : undefined;
337};
338
339const primaryIp = (instance: sqladmin.DatabaseInstance) =>
340 instance.ipAddresses?.find((item) => item.type === "PRIMARY")?.ipAddress ??
341 instance.ipAddresses?.[0]?.ipAddress;
342
343const toAttrs = (
344 instance: sqladmin.DatabaseInstance,
345 project: string,
346 defaultRegion: string,
347) => {
348 const settings = instance.settings;
349 return {
350 instanceName: instance.name ?? "",
351 project: instance.project ?? project,
352 region: normalizeRegion(instance.region, defaultRegion),
353 databaseVersion: instance.databaseVersion,
354 databaseInstalledVersion: instance.databaseInstalledVersion,
355 tier: settings?.tier,
356 edition: settings?.edition,
357 state: instance.state,
358 labels: userLabels(settings?.userLabels),
359 connectionName: instance.connectionName,
360 gceZone: instance.gceZone ?? settings?.locationPreference?.zone,
361 ipAddress: primaryIp(instance),
362 ipv4Enabled: settings?.ipConfiguration?.ipv4Enabled !== false,
363 deletionProtectionEnabled: settings?.deletionProtectionEnabled === true,
364 activationPolicy: settings?.activationPolicy,
365 availabilityType: settings?.availabilityType,
366 dataDiskSizeGb: diskSizeOf(settings?.dataDiskSizeGb),
367 backupEnabled: settings?.backupConfiguration?.enabled === true,
368 serviceAccountEmailAddress: instance.serviceAccountEmailAddress,
369 selfLink: instance.selfLink,
370 createTime: instance.createTime,
371 };
372};
373
374const getByName = (project: string, instance: string) =>
375 sqladmin
376 .getInstances({ project, instance })
377 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
378
379/**
380 * Instance create, patch, and delete operations routinely run 5–15
381 * minutes; poll every 10s for up to 20 minutes.
382 */
383const LONG_OPERATION_POLL = {
384 times: 120,
385 schedule: Schedule.spaced("10 seconds"),
386};
387
388const waitForOperation = (
389 project: string,
390 operation: sqladmin.Operation,
391 options?: { notFoundOk?: boolean },
392) =>
393 waitForSqlOperation(project, operation, {
394 budget: "30 minutes",
395 notFoundOk: true,
396 });
397
398const waitUntilExists = (project: string, instanceName: string) =>
399 getByName(project, instanceName).pipe(
400 Effect.flatMap((instance) =>
401 instance
402 ? Effect.succeed(instance)
403 : Effect.fail(new InstanceNotResolved({ instanceName, project })),
404 ),
405 Effect.retry({
406 while: (error) => error._tag === "GCP.SQL.InstanceNotResolved",
407 times: 8,
408 schedule: Schedule.spaced("2 seconds"),
409 }),
410 );
411
412const waitUntilRunnable = (project: string, instanceName: string) =>
413 getByName(project, instanceName).pipe(
414 Effect.filterOrFail(
415 (instance): instance is sqladmin.DatabaseInstance =>
416 instance !== undefined,
417 () => new InstanceNotResolved({ instanceName, project }),
418 ),
419 Effect.filterOrFail(
420 (instance) => {
421 const state = instance.state ?? "SQL_INSTANCE_STATE_UNSPECIFIED";
422 return state === "RUNNABLE" || state === "SUSPENDED";
423 },
424 (instance) => {
425 const state = instance.state ?? "SQL_INSTANCE_STATE_UNSPECIFIED";
426 return state === "FAILED"
427 ? new InstanceFailed({ instanceName, state })
428 : new InstanceNotReady({ instanceName, state });
429 },
430 ),
431 Effect.retry({
432 while: (error) =>
433 error._tag === "GCP.SQL.InstanceNotReady" ||
434 error._tag === "GCP.SQL.InstanceNotResolved",
435 ...LONG_OPERATION_POLL,
436 }),
437 );
438
439const waitUntilGone = (project: string, instanceName: string) =>
440 getByName(project, instanceName).pipe(
441 Effect.flatMap((instance) =>
442 instance === undefined
443 ? Effect.void
444 : Effect.fail(new InstanceStillExists({ instanceName })),
445 ),
446 Effect.retry({
447 while: (error) => error._tag === "GCP.SQL.InstanceStillExists",
448 ...LONG_OPERATION_POLL,
449 }),
450 );
451
452const backupConfiguration = (
453 enabled: boolean,
454 version: string,
455): sqladmin.BackupConfiguration => ({
456 enabled,
457 binaryLogEnabled: enabled && isMysql(version),
458 pointInTimeRecoveryEnabled: enabled && isPostgres(version),
459});
460
461const dataApiAccessOf = (enabled: boolean | undefined) => {
462 if (enabled === true) return "ALLOW_DATA_API";
463 if (enabled === false) return "DISALLOW_DATA_API";
464 return undefined;
465};
466
467const insertBody = (
468 news: InstanceProps,
469 instanceName: string,
470 region: string,
471 version: string,
472 desiredLabels: Record<string, string>,
473): sqladmin.DatabaseInstance => {
474 const backupEnabled = news.backupEnabled === true;
475 return {
476 name: instanceName,
477 region,
478 databaseVersion: version,
479 rootPassword: news.rootPassword,
480 settings: {
481 tier: normalizeTier(news.tier),
482 edition: normalizeEdition(news.edition),
483 activationPolicy: normalizeActivation(news.activationPolicy),
484 availabilityType: normalizeAvailability(news.availabilityType),
485 dataDiskSizeGb: String(news.dataDiskSizeGb ?? DEFAULT_DISK_SIZE_GB),
486 dataDiskType: normalizeDiskType(news.dataDiskType),
487 deletionProtectionEnabled: news.deletionProtectionEnabled === true,
488 userLabels: desiredLabels,
489 storageAutoResize: false,
490 ipConfiguration: {
491 ipv4Enabled: news.ipv4Enabled !== false,
492 },
493 backupConfiguration: backupConfiguration(backupEnabled, version),
494 locationPreference: news.zone ? { zone: news.zone } : undefined,
495 databaseFlags: news.databaseFlags,
496 dataApiAccess: dataApiAccessOf(news.dataApiAccess),
497 },
498 };
499};
500
501const applyPatch = (
502 project: string,
503 instanceName: string,
504 body: sqladmin.DatabaseInstance,
505) =>
506 sqladmin
507 .patchInstances({
508 project,
509 instance: instanceName,
510 body,
511 })
512 .pipe(
513 Effect.retry({
514 while: (error) => error._tag === "Conflict",
515 times: 8,
516 schedule: Schedule.spaced("5 seconds"),
517 }),
518 );
519
520export const InstanceProvider = () =>
521 Provider.succeed(Instance, {
522 stables: [
523 "instanceName",
524 "project",
525 "region",
526 "databaseVersion",
527 "connectionName",
528 "selfLink",
529 "createTime",
530 ],
531
532 diff: Effect.fn(function* ({ news, olds, output }) {
533 if (!isResolved(news)) return undefined;
534 const env = yield* GcpEnvironment.current;
535
536 const previousName = olds?.instanceName ?? output?.instanceName;
537 const nextName = news.instanceName ?? previousName;
538 const previousRegion = normalizeRegion(
539 olds?.region ?? output?.region,
540 env.region,
541 );
542 const nextRegion = normalizeRegion(
543 news.region ?? output?.region,
544 env.region,
545 );
546 const previousVersion = normalizeVersion(
547 olds?.databaseVersion ?? output?.databaseVersion,
548 );
549 const nextVersion = normalizeVersion(
550 news.databaseVersion ?? output?.databaseVersion,
551 );
552
553 const nameChanged =
554 previousName !== undefined &&
555 nextName !== undefined &&
556 previousName !== nextName;
557 const regionChanged = previousRegion !== nextRegion;
558 const versionChanged = previousVersion !== nextVersion;
559
560 if (!nameChanged && !regionChanged && !versionChanged) {
561 return undefined;
562 }
563 return {
564 action: "replace" as const,
565 deleteFirst: !nameChanged,
566 };
567 }),
568
569 read: Effect.fn(function* ({ id, olds, output }) {
570 const env = yield* GcpEnvironment.current;
571 const instanceName = yield* toName(
572 id,
573 olds?.instanceName,
574 output?.instanceName,
575 );
576 const existing = yield* getByName(env.project, instanceName);
577 if (existing === undefined) return undefined;
578 const attrs = toAttrs(existing, env.project, env.region);
579 return (yield* hasAlchemyLabels(
580 id,
581 tagRecord(existing.settings?.userLabels),
582 ))
583 ? attrs
584 : Unowned(attrs);
585 }),
586
587 list: () =>
588 Effect.gen(function* () {
589 const env = yield* GcpEnvironment.current;
590 return yield* sqladmin.listInstances
591 .items({
592 project: env.project,
593 maxResults: 1000,
594 })
595 .pipe(
596 Stream.filter((instance) =>
597 Object.keys(instance.settings?.userLabels ?? {}).some((key) =>
598 key.startsWith("alchemy-"),
599 ),
600 ),
601 Stream.map((instance) =>
602 toAttrs(instance, env.project, env.region),
603 ),
604 Stream.runCollect,
605 Effect.map((chunk) => Array.from(chunk)),
606 Effect.catchTag("NotFound", () => Effect.succeed([])),
607 );
608 }),
609
610 reconcile: Effect.fn(function* ({ id, news, output }) {
611 const env = yield* GcpEnvironment.current;
612 const instanceName = yield* toName(
613 id,
614 news.instanceName,
615 output?.instanceName,
616 );
617 const region = normalizeRegion(news.region ?? output?.region, env.region);
618 const version = normalizeVersion(
619 news.databaseVersion ?? output?.databaseVersion,
620 );
621 const desiredLabels = {
622 ...toLabels(news.labels),
623 ...(yield* createInternalLabels(id)),
624 };
625
626 let current = yield* getByName(
627 env.project,
628 output?.instanceName ?? instanceName,
629 );
630
631 if (current === undefined) {
632 const created = yield* sqladmin
633 .insertInstances({
634 project: env.project,
635 body: insertBody(
636 news,
637 instanceName,
638 region,
639 version,
640 desiredLabels,
641 ),
642 })
643 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
644 if (created !== undefined) {
645 yield* waitForOperation(env.project, created);
646 }
647 current = yield* waitUntilExists(env.project, instanceName);
648 }
649
650 if (current === undefined) {
651 return yield* new InstanceNotResolved({
652 instanceName,
653 project: env.project,
654 });
655 }
656
657 const state = current.state ?? "";
658 if (state !== "RUNNABLE" && state !== "SUSPENDED") {
659 current = yield* waitUntilRunnable(env.project, instanceName);
660 }
661
662 const settings = current.settings;
663 const observedLabels = tagRecord(settings?.userLabels);
664 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
665 const labelsChanged = upsert.length > 0 || removed.length > 0;
666
667 const desiredTier = normalizeTier(news.tier ?? settings?.tier);
668 const tierChanged =
669 news.tier !== undefined &&
670 (settings?.tier ?? DEFAULT_TIER) !== desiredTier;
671
672 const desiredEdition = normalizeEdition(
673 news.edition ?? settings?.edition,
674 );
675 const editionChanged =
676 news.edition !== undefined &&
677 (settings?.edition ?? DEFAULT_EDITION).toUpperCase() !== desiredEdition;
678
679 const desiredActivation = normalizeActivation(
680 news.activationPolicy ?? settings?.activationPolicy,
681 );
682 const activationChanged =
683 news.activationPolicy !== undefined &&
684 (settings?.activationPolicy ?? DEFAULT_ACTIVATION).toUpperCase() !==
685 desiredActivation;
686
687 const desiredAvailability = normalizeAvailability(
688 news.availabilityType ?? settings?.availabilityType,
689 );
690 const availabilityChanged =
691 news.availabilityType !== undefined &&
692 (settings?.availabilityType ?? DEFAULT_AVAILABILITY).toUpperCase() !==
693 desiredAvailability;
694
695 const desiredProtection = news.deletionProtectionEnabled === true;
696 const protectionChanged =
697 (settings?.deletionProtectionEnabled === true) !== desiredProtection;
698
699 const desiredIpv4 = news.ipv4Enabled !== false;
700 const ipv4Changed =
701 news.ipv4Enabled !== undefined &&
702 (settings?.ipConfiguration?.ipv4Enabled !== false) !== desiredIpv4;
703
704 const desiredBackup = news.backupEnabled === true;
705 const backupChanged =
706 news.backupEnabled !== undefined &&
707 (settings?.backupConfiguration?.enabled === true) !== desiredBackup;
708
709 const desiredDisk = news.dataDiskSizeGb;
710 const currentDisk = diskSizeOf(settings?.dataDiskSizeGb);
711 const diskChanged =
712 desiredDisk !== undefined &&
713 currentDisk !== undefined &&
714 desiredDisk > currentDisk;
715
716 const desiredZone = news.zone;
717 const zoneChanged =
718 desiredZone !== undefined &&
719 (current.gceZone ?? settings?.locationPreference?.zone) !== desiredZone;
720
721 const flagsChanged =
722 news.databaseFlags !== undefined &&
723 flagsKey(settings?.databaseFlags) !== flagsKey(news.databaseFlags);
724
725 const desiredDataApi = dataApiAccessOf(news.dataApiAccess);
726 const dataApiChanged =
727 desiredDataApi !== undefined &&
728 (settings?.dataApiAccess ?? "DISALLOW_DATA_API") !== desiredDataApi;
729
730 if (
731 labelsChanged ||
732 tierChanged ||
733 editionChanged ||
734 activationChanged ||
735 availabilityChanged ||
736 protectionChanged ||
737 ipv4Changed ||
738 backupChanged ||
739 diskChanged ||
740 zoneChanged ||
741 flagsChanged ||
742 dataApiChanged
743 ) {
744 const nextLabels: Record<string, string | null> = {
745 ...desiredLabels,
746 };
747 for (const key of removed) {
748 nextLabels[key] = null;
749 }
750 const patched = yield* applyPatch(env.project, instanceName, {
751 settings: {
752 settingsVersion: settings?.settingsVersion,
753 userLabels: nextLabels as unknown as Record<string, string>,
754 tier: desiredTier,
755 edition: news.edition !== undefined ? desiredEdition : undefined,
756 activationPolicy:
757 news.activationPolicy !== undefined
758 ? desiredActivation
759 : undefined,
760 availabilityType:
761 news.availabilityType !== undefined
762 ? desiredAvailability
763 : undefined,
764 deletionProtectionEnabled: desiredProtection,
765 dataDiskSizeGb: diskChanged ? String(desiredDisk) : undefined,
766 ipConfiguration: ipv4Changed
767 ? { ipv4Enabled: desiredIpv4 }
768 : undefined,
769 backupConfiguration: backupChanged
770 ? backupConfiguration(desiredBackup, version)
771 : undefined,
772 locationPreference: zoneChanged ? { zone: desiredZone } : undefined,
773 databaseFlags:
774 news.databaseFlags !== undefined ? news.databaseFlags : undefined,
775 dataApiAccess: desiredDataApi,
776 },
777 });
778 yield* waitForOperation(env.project, patched);
779 current = yield* waitUntilRunnable(env.project, instanceName);
780 }
781
782 return toAttrs(current, env.project, env.region);
783 }),
784
785 delete: Effect.fn(function* ({ output }) {
786 const env = yield* GcpEnvironment.current;
787 const instanceName = output.instanceName;
788 const existing = yield* getByName(env.project, instanceName);
789 if (existing === undefined) return;
790
791 if (existing.settings?.deletionProtectionEnabled === true) {
792 const patched = yield* applyPatch(env.project, instanceName, {
793 settings: {
794 settingsVersion: existing.settings.settingsVersion,
795 deletionProtectionEnabled: false,
796 },
797 }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
798 if (patched !== undefined) {
799 yield* waitForOperation(env.project, patched, { notFoundOk: true });
800 }
801 }
802
803 const operation = yield* sqladmin
804 .deleteInstances({
805 project: env.project,
806 instance: instanceName,
807 })
808 .pipe(
809 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
810 Effect.retry({
811 while: (error) => error._tag === "Conflict",
812 times: 8,
813 schedule: Schedule.spaced("5 seconds"),
814 }),
815 );
816 if (operation !== undefined) {
817 yield* waitForOperation(env.project, operation, { notFoundOk: true });
818 }
819 yield* waitUntilGone(env.project, instanceName);
820 }),
821 });