| 1 | import * as sqladmin from "@distilled.cloud/gcp/sqladmin_v1"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { waitForOperation as waitForGcpOperation } from "../Operation.ts"; |
| 5 | |
| 6 | const lastSegment = (value: string | undefined) => { |
| 7 | const parts = (value ?? "").replace(/\/+$/, "").split("/"); |
| 8 | return parts[parts.length - 1] ?? ""; |
| 9 | }; |
| 10 | |
| 11 | const operationNameOf = (operation: sqladmin.Operation) => |
| 12 | lastSegment(operation.name) || lastSegment(operation.selfLink); |
| 13 | |
| 14 | /** |
| 15 | * Wait for a Cloud SQL admin operation and return the finished operation. |
| 16 | * |
| 17 | * An operation whose error code (`errors[].code`) says the target already |
| 18 | * existed counts as done (a lost create race). With `notFoundOk`, one whose |
| 19 | * target was already gone — or whose operation record is gone — counts as |
| 20 | * done too (a lost delete race). |
| 21 | */ |
| 22 | export const waitForSqlOperation = ( |
| 23 | project: string, |
| 24 | operation: sqladmin.Operation, |
| 25 | options: { budget: Duration.Input; notFoundOk?: boolean }, |
| 26 | ) => |
| 27 | Effect.gen(function* () { |
| 28 | const name = operationNameOf(operation); |
| 29 | const get = (operationName: string) => |
| 30 | sqladmin.getOperations({ project, operation: operationName }); |
| 31 | yield* waitForGcpOperation({ ...operation, name }, get, { |
| 32 | budget: options.budget, |
| 33 | interval: "10 seconds", |
| 34 | }).pipe( |
| 35 | Effect.catchTag("GCP.OperationFailed", (error) => { |
| 36 | const code = (error.reason ?? "").toUpperCase(); |
| 37 | return code.includes("ALREADY_EXISTS") || |
| 38 | (options.notFoundOk === true && code.includes("NOT_FOUND")) |
| 39 | ? Effect.void |
| 40 | : Effect.fail(error); |
| 41 | }), |
| 42 | Effect.catchTag("NotFound", (error) => |
| 43 | options.notFoundOk === true ? Effect.void : Effect.fail(error), |
| 44 | ), |
| 45 | ); |
| 46 | if (operation.status === "DONE" || name.length === 0) return operation; |
| 47 | return yield* get(name).pipe( |
| 48 | Effect.catchTag("NotFound", (error) => |
| 49 | options.notFoundOk === true |
| 50 | ? Effect.succeed(operation) |
| 51 | : Effect.fail(error), |
| 52 | ), |
| 53 | ); |
| 54 | }); |
| 55 | |
| 56 | /** |
| 57 | * Cloud SQL answers `SqlInstanceNotAuthorized` (403) both for children of an |
| 58 | * instance that does not exist and for callers without permission. Recover |
| 59 | * with `onMissing` only after `getInstances` confirms the instance is gone |
| 60 | * (404); otherwise the original error stands. |
| 61 | */ |
| 62 | export const recoverIfInstanceMissing = |
| 63 | <B>(project: string, instance: string, onMissing: () => B) => |
| 64 | <A, E extends { readonly _tag: string }, R>(effect: Effect.Effect<A, E, R>) => |
| 65 | effect.pipe( |
| 66 | Effect.catchIf( |
| 67 | ( |
| 68 | error, |
| 69 | ): error is Extract<E, { readonly _tag: "SqlInstanceNotAuthorized" }> => |
| 70 | error._tag === "SqlInstanceNotAuthorized", |
| 71 | (error) => |
| 72 | sqladmin.getInstances({ project, instance }).pipe( |
| 73 | Effect.flatMap(() => Effect.fail(error)), |
| 74 | Effect.catchTag("NotFound", () => Effect.succeed(onMissing())), |
| 75 | ), |
| 76 | ), |
| 77 | ); |