1import * as ps from "@distilled.cloud/planetscale";
2import * as Clock from "effect/Clock";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import type { ScopedPlanStatusSession } from "../Report.ts";
7
8/**
9 * Tagged error raised when polling for a state predicate that has not yet
10 * been reached. Used internally with `Effect.retry` to drive long-running
11 * status waits.
12 */
13export class NotReady extends Data.TaggedError("Planetscale::NotReady")<{
14 description: string;
15}> {}
16
17/**
18 * Tagged error raised by reconcilers when an immutable property of a live
19 * PlanetScale resource does not match the desired configuration (e.g.
20 * region, kind, parent_branch). User-recoverable: change the config to
21 * match, or replace/delete the existing resource and retry.
22 */
23export class PlanetscaleConflict extends Data.TaggedError(
24 "Planetscale::Conflict",
25)<{
26 message: string;
27}> {}
28
29/**
30 * Default polling schedule: 5s spaced retries with a 30-minute total
31 * budget (360 × 5s). Avoids the exponential-blowup trap where later
32 * iterations would wait hours, indistinguishable from a hang. Postgres
33 * database creates routinely run 10-12 minutes, so a 10-minute budget
34 * regularly false-positives as "stuck".
35 */
36const defaultSchedule = Schedule.max([
37 Schedule.spaced("5 seconds"),
38 Schedule.recurs(360),
39]);
40
41/**
42 * Generic polling helper that retries until `predicate(value)` returns true
43 * (or until the schedule is exhausted). Engine-specific helpers (e.g.
44 * `waitForKeyspaceReady`, `waitForPendingPostgresChanges`) are built on top
45 * of this primitive.
46 */
47export const pollUntil = <A, E, R>(
48 description: string,
49 fn: Effect.Effect<A, E, R>,
50 predicate: (value: A) => boolean,
51 schedule: Schedule.Schedule<unknown, unknown, never> = defaultSchedule,
52): Effect.Effect<A, E, R> =>
53 fn.pipe(
54 Effect.flatMap((value) =>
55 predicate(value)
56 ? Effect.succeed(value)
57 : Effect.fail(new NotReady({ description })),
58 ),
59 Effect.retry({
60 while: (e: any) => e?._tag === "Planetscale::NotReady",
61 schedule,
62 }),
63 ) as Effect.Effect<A, E, R>;
64
65/**
66 * Polls a branch via `getBranch` until it reports `ready === true`. Returns
67 * the final branch shape. `NotFound` during polling is treated as
68 * not-yet-ready (the branch is being provisioned by an upstream operation).
69 *
70 * If a `session` is supplied, each poll emits a note with elapsed seconds and
71 * expectation-setting so the CLI surfaces progress while we sit in the spaced
72 * retry loop.
73 */
74export const waitForBranchReady = Effect.fn(function* (
75 organization: string,
76 database: string,
77 branch: string,
78 session?: ScopedPlanStatusSession,
79) {
80 const startedAt = yield* Clock.currentTimeMillis;
81 return yield* pollUntil(
82 `branch "${branch}" ready`,
83 Effect.gen(function* () {
84 if (session) {
85 const now = yield* Clock.currentTimeMillis;
86 const seconds = Math.floor((now - startedAt) / 1000);
87 yield* session.note(
88 `Waiting for branch to be ready... (${seconds} seconds elapsed; this can take a few minutes)`,
89 );
90 }
91 return yield* ps.getBranch({ organization, database, branch });
92 }).pipe(
93 Effect.catchTag("NotFound", () =>
94 Effect.fail(
95 new NotReady({ description: `branch "${branch}" not found yet` }),
96 ),
97 ),
98 ),
99 (data) => data.ready,
100 );
101});
102
103/**
104 * Polls a database via `getDatabase` until it reports `ready === true`.
105 * `NotFound` during polling is treated as not-yet-ready (the database is
106 * being provisioned by an upstream operation).
107 *
108 * If a `session` is supplied, each poll emits a note with elapsed seconds and
109 * expectation-setting so the CLI surfaces progress while we sit in the spaced
110 * retry loop.
111 */
112export const waitForDatabaseReady = Effect.fn(function* (
113 organization: string,
114 database: string,
115 session?: ScopedPlanStatusSession,
116) {
117 const startedAt = yield* Clock.currentTimeMillis;
118 return yield* pollUntil(
119 `database "${database}" ready`,
120 Effect.gen(function* () {
121 if (session) {
122 const now = yield* Clock.currentTimeMillis;
123 const seconds = Math.floor((now - startedAt) / 1000);
124 yield* session.note(
125 `Waiting for database to be ready... (${seconds} seconds elapsed; this can take a few minutes)`,
126 );
127 }
128 return yield* ps.getDatabase({ organization, database });
129 }).pipe(
130 Effect.catchTag("NotFound", () =>
131 Effect.fail(
132 new NotReady({
133 description: `database "${database}" not found yet`,
134 }),
135 ),
136 ),
137 ),
138 (data) => data.ready,
139 );
140});
141
142export const isKnownError =
143 (tag: string, message: string) => (error: unknown) =>
144 typeof error === "object" &&
145 error !== null &&
146 (error as { readonly _tag?: unknown })._tag === tag &&
147 (error as { readonly message?: unknown }).message === message;