import * as ps from "@distilled.cloud/planetscale"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import type { ScopedPlanStatusSession } from "../Cli/Cli.ts"; /** * Tagged error raised when polling for a state predicate that has not yet * been reached. Used internally with `Effect.retry` to drive long-running * status waits. */ export class NotReady extends Data.TaggedError("Planetscale::NotReady")<{ description: string; }> {} /** * Tagged error raised by reconcilers when an immutable property of a live * PlanetScale resource does not match the desired configuration (e.g. * region, kind, parent_branch). User-recoverable: change the config to * match, or replace/delete the existing resource and retry. */ export class PlanetscaleConflict extends Data.TaggedError( "Planetscale::Conflict", )<{ message: string; }> {} /** * Default polling schedule: 5s spaced retries with a 30-minute total * budget (360 × 5s). Avoids the exponential-blowup trap where later * iterations would wait hours, indistinguishable from a hang. Postgres * database creates routinely run 10-12 minutes, so a 10-minute budget * regularly false-positives as "stuck". */ const defaultSchedule = Schedule.max([ Schedule.spaced("5 seconds"), Schedule.recurs(360), ]); /** * Generic polling helper that retries until `predicate(value)` returns true * (or until the schedule is exhausted). Engine-specific helpers (e.g. * `waitForKeyspaceReady`, `waitForPendingPostgresChanges`) are built on top * of this primitive. */ export const pollUntil = ( description: string, fn: Effect.Effect, predicate: (value: A) => boolean, schedule: Schedule.Schedule = defaultSchedule, ): Effect.Effect => fn.pipe( Effect.flatMap((value) => predicate(value) ? Effect.succeed(value) : Effect.fail(new NotReady({ description })), ), Effect.retry({ while: (e: any) => e?._tag === "Planetscale::NotReady", schedule, }), ) as Effect.Effect; /** * Polls a branch via `getBranch` until it reports `ready === true`. Returns * the final branch shape. `NotFound` during polling is treated as * not-yet-ready (the branch is being provisioned by an upstream operation). * * If a `session` is supplied, each poll emits a note with elapsed seconds and * expectation-setting so the CLI surfaces progress while we sit in the spaced * retry loop. */ export const waitForBranchReady = Effect.fn(function* ( organization: string, database: string, branch: string, session?: ScopedPlanStatusSession, ) { const startedAt = yield* Clock.currentTimeMillis; return yield* pollUntil( `branch "${branch}" ready`, Effect.gen(function* () { if (session) { const now = yield* Clock.currentTimeMillis; const seconds = Math.floor((now - startedAt) / 1000); yield* session.note( `Waiting for branch to be ready... (${seconds} seconds elapsed; this can take a few minutes)`, ); } return yield* ps.getBranch({ organization, database, branch }); }).pipe( Effect.catchTag("NotFound", () => Effect.fail( new NotReady({ description: `branch "${branch}" not found yet` }), ), ), ), (data) => data.ready, ); }); /** * Polls a database via `getDatabase` until it reports `ready === true`. * `NotFound` during polling is treated as not-yet-ready (the database is * being provisioned by an upstream operation). * * If a `session` is supplied, each poll emits a note with elapsed seconds and * expectation-setting so the CLI surfaces progress while we sit in the spaced * retry loop. */ export const waitForDatabaseReady = Effect.fn(function* ( organization: string, database: string, session?: ScopedPlanStatusSession, ) { const startedAt = yield* Clock.currentTimeMillis; return yield* pollUntil( `database "${database}" ready`, Effect.gen(function* () { if (session) { const now = yield* Clock.currentTimeMillis; const seconds = Math.floor((now - startedAt) / 1000); yield* session.note( `Waiting for database to be ready... (${seconds} seconds elapsed; this can take a few minutes)`, ); } return yield* ps.getDatabase({ organization, database }); }).pipe( Effect.catchTag("NotFound", () => Effect.fail( new NotReady({ description: `database "${database}" not found yet`, }), ), ), ), (data) => data.ready, ); }); export const isKnownError = (tag: string, message: string) => (error: unknown) => typeof error === "object" && error !== null && (error as { readonly _tag?: unknown })._tag === tag && (error as { readonly message?: unknown }).message === message;