1import * as Data from "effect/Data";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5
6/**
7 * Shared waiter for Google long-running operations (`google.longrunning.Operation`
8 * and the Compute `Operation` shape). Every provider waits through this one
9 * helper so each service only states its time budget.
10 *
11 * NOT exported from `index.ts`.
12 */
13
14/** The subset of a long-running operation the waiter reads. */
15export interface LongRunningOperation {
16 name?: string;
17 done?: boolean;
18 /** Compute operations report `status: "DONE"` instead of `done`. */
19 status?: string;
20 error?: {
21 code?: number;
22 message?: string;
23 /** Compute operations list their errors here. */
24 errors?: ReadonlyArray<{ code?: string; message?: string }>;
25 };
26}
27
28/** The operation finished with an error. */
29export class OperationFailed extends Data.TaggedError("GCP.OperationFailed")<{
30 operation: string;
31 /** Numeric RPC code (google.rpc.Code), when reported. */
32 code: number | undefined;
33 /** Compute error code (e.g. `RESOURCE_NOT_FOUND`), when reported. */
34 reason: string | undefined;
35 message: string;
36}> {}
37
38/** The operation did not finish within the service's time budget. */
39export class OperationTimedOut extends Data.TaggedError(
40 "GCP.OperationTimedOut",
41)<{
42 operation: string;
43 budget: string;
44}> {}
45
46class OperationPending extends Data.TaggedError("GCP.OperationPending")<{
47 operation: string;
48}> {}
49
50const isDone = (operation: LongRunningOperation) =>
51 operation.done === true || operation.status === "DONE";
52
53const failureOf = (operation: LongRunningOperation) => {
54 const error = operation.error;
55 if (error === undefined) return undefined;
56 const first = error.errors?.[0];
57 if (error.code === undefined && first === undefined && !error.message) {
58 return undefined;
59 }
60 return new OperationFailed({
61 operation: operation.name ?? "",
62 code: error.code,
63 reason: first?.code,
64 message: first?.message ?? error.message ?? "operation failed",
65 });
66};
67
68export interface WaitOptions {
69 /**
70 * How long the service may take. Use the real provisioning time for the
71 * resource (Composer ~45m, AlloyDB/Cloud SQL ~20m, GKE ~20m), not a test
72 * timeout.
73 * @default "10 minutes"
74 */
75 budget?: Duration.Input;
76 /** Poll interval. @default "5 seconds" */
77 interval?: Duration.Input;
78}
79
80/**
81 * Poll `get` until the operation is done, then fail with
82 * {@link OperationFailed} if it reports an error. `get` re-reads the
83 * operation by name with the service's own operations API.
84 */
85export const waitForOperation = <E extends { readonly _tag: string }, R>(
86 operation: LongRunningOperation,
87 get: (name: string) => Effect.Effect<LongRunningOperation, E, R>,
88 options: WaitOptions = {},
89): Effect.Effect<
90 LongRunningOperation,
91 E | OperationFailed | OperationTimedOut,
92 R
93> => {
94 const budget = Duration.fromInputUnsafe(options.budget ?? "10 minutes");
95 const interval = Duration.fromInputUnsafe(options.interval ?? "5 seconds");
96 const name = operation.name ?? "";
97 const settle = (current: LongRunningOperation) => {
98 const failed = failureOf(current);
99 return failed ? Effect.fail(failed) : Effect.succeed(current);
100 };
101 if (isDone(operation) || name.length === 0) return settle(operation);
102 const times = Math.max(
103 1,
104 Math.ceil(Duration.toMillis(budget) / Duration.toMillis(interval)),
105 );
106 return get(name).pipe(
107 Effect.flatMap((current) =>
108 isDone(current)
109 ? Effect.succeed(current)
110 : Effect.fail(new OperationPending({ operation: name })),
111 ),
112 Effect.retry({
113 while: (error) => error._tag === "GCP.OperationPending",
114 schedule: Schedule.spaced(interval),
115 times,
116 }),
117 Effect.catchTag("GCP.OperationPending", () =>
118 Effect.fail(
119 new OperationTimedOut({
120 operation: name,
121 budget: Duration.format(budget),
122 }),
123 ),
124 ),
125 Effect.flatMap(settle),
126 );
127};