1import * as oracle from "@distilled.cloud/gcp/oracledatabase_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 { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const DEFAULT_NETWORK = "default";
12export const MAX_NAME_LENGTH = 63;
13
14export class ResourceNotResolved extends Data.TaggedError(
15 "GCP.OracleDatabase.ResourceNotResolved",
16)<{
17 name: string;
18}> {}
19
20export { ResourceNotResolved as OracleDatabaseNotResolved };
21
22export class ResourceStillExists extends Data.TaggedError(
23 "GCP.OracleDatabase.ResourceStillExists",
24)<{
25 name: string;
26}> {}
27
28export class ResourceNotReady extends Data.TaggedError(
29 "GCP.OracleDatabase.ResourceNotReady",
30)<{
31 name: string;
32 state: string;
33}> {}
34
35export class ResourceFailed extends Data.TaggedError(
36 "GCP.OracleDatabase.ResourceFailed",
37)<{
38 name: string;
39 state: string;
40 details: string | undefined;
41}> {}
42
43export type CustomerContact = {
44 /** Email Oracle uses for notifications. */
45 email?: string;
46};
47
48export type TimeZone = {
49 /** IANA time zone (e.g. `America/New_York`). */
50 id?: string;
51 /** IANA database version (e.g. `2019a`). */
52 version?: string;
53};
54
55export const lastSegment = (value: string | undefined) => {
56 if (value === undefined || value.length === 0) return "";
57 const trimmed = value.replace(/\/+$/, "");
58 const parts = trimmed.split("/");
59 return parts[parts.length - 1] || trimmed;
60};
61
62export const rfc1035 = (name: string, fallback = "oracle"): string => {
63 let next = name
64 .toLowerCase()
65 .replace(/[^a-z0-9-]/g, "-")
66 .replace(/-+/g, "-")
67 .replace(/^-+|-+$/g, "");
68 if (!/^[a-z]/.test(next)) next = `o${next}`;
69 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
70 if (next.length === 0) return fallback;
71 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
72 return next.slice(0, MAX_NAME_LENGTH);
73};
74
75export const alphanumericId = (name: string, maxLength = 32): string => {
76 let next = name.toLowerCase().replace(/[^a-z0-9]/g, "");
77 if (!/^[a-z]/.test(next)) next = `o${next}`;
78 next = next.slice(0, maxLength);
79 if (next.length === 0) return "oggdeploy";
80 return next;
81};
82
83export const normalizeLocation = (location: string) =>
84 lastSegment(location).toLowerCase();
85
86export const parentOf = (project: string, location: string) =>
87 `projects/${project}/locations/${normalizeLocation(location)}`;
88
89export const toPhysicalId = (
90 id: string,
91 explicit: string | undefined,
92 existing: string | undefined,
93 fallback = "oracle",
94) =>
95 Effect.gen(function* () {
96 if (explicit !== undefined) return rfc1035(explicit, fallback);
97 if (existing !== undefined) return existing;
98 return rfc1035(
99 yield* createPhysicalName({
100 id,
101 maxLength: MAX_NAME_LENGTH,
102 lowercase: true,
103 }),
104 fallback,
105 );
106 });
107
108export const parseName = (name: string, collection: string) => {
109 const parts = name.split("/").filter((part) => part.length > 0);
110 const collectionAt = parts.lastIndexOf(collection);
111 const locationsAt = parts.lastIndexOf("locations");
112 const projectsAt = parts.lastIndexOf("projects");
113 return {
114 project:
115 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
116 location:
117 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
118 id:
119 collectionAt >= 0 && parts[collectionAt + 1]
120 ? parts.slice(collectionAt + 1).join("/")
121 : lastSegment(name),
122 parent:
123 collectionAt > 0
124 ? parts.slice(0, collectionAt).join("/")
125 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
126 };
127};
128
129export const expandParent = (
130 value: string,
131 project: string,
132 location: string,
133 collection: string,
134) => {
135 if (value.includes("/")) return value.replace(/\/+$/, "");
136 return `projects/${project}/locations/${location}/${collection}/${value}`;
137};
138
139export const networkName = (project: string, network: string | undefined) => {
140 const value = network ?? DEFAULT_NETWORK;
141 if (value.includes("/")) return value;
142 return `projects/${project}/global/networks/${value}`;
143};
144
145export const expandNetwork = (project: string, network: string | undefined) => {
146 if (network === undefined || network.length === 0) return undefined;
147 if (network.includes("/")) return network;
148 return `projects/${project}/global/networks/${network}`;
149};
150
151export const resourceNameOf = (
152 project: string,
153 location: string,
154 collection: string,
155 id: string,
156) => `projects/${project}/locations/${location}/${collection}/${id}`;
157
158export const retryQuota = <A, E extends { _tag: string }, R>(
159 effect: Effect.Effect<A, E, R>,
160) =>
161 effect.pipe(
162 Effect.retry({
163 while: (error) => error._tag === "TooManyRequests",
164 times: 8,
165 schedule: Schedule.exponential("2 seconds"),
166 }),
167 );
168
169export const retryConflict = <A, E extends { _tag: string }, R>(
170 effect: Effect.Effect<A, E, R>,
171) =>
172 effect.pipe(
173 Effect.retry({
174 while: (error) =>
175 error._tag === "Conflict" || error._tag === "TooManyRequests",
176 times: 8,
177 schedule: Schedule.spaced("5 seconds"),
178 }),
179 );
180
181const canonical = (value: unknown): unknown => {
182 if (value === undefined || value === null) return undefined;
183 if (typeof value === "boolean" || typeof value === "number") return value;
184 if (typeof value === "string") {
185 return value.length === 0 ? undefined : value;
186 }
187 if (Array.isArray(value)) {
188 const items = value.map(canonical).filter((item) => item !== undefined);
189 return items.length === 0 ? undefined : items;
190 }
191 if (typeof value === "object") {
192 const entries = Object.entries(value as Record<string, unknown>)
193 .map(([key, item]) => [key, canonical(item)] as const)
194 .filter(([, item]) => item !== undefined)
195 .sort(([left], [right]) => left.localeCompare(right));
196 if (entries.length === 0) return undefined;
197 return Object.fromEntries(entries);
198 }
199 return undefined;
200};
201
202export const specifiedEquals = (
203 desired: unknown,
204 observed: unknown,
205): boolean => {
206 if (desired === undefined) return true;
207 return (
208 JSON.stringify(canonical(desired) ?? null) ===
209 JSON.stringify(canonical(observed) ?? null)
210 );
211};
212
213export const userLabels = (
214 labels: Record<string, string | undefined> | null | undefined,
215): Record<string, string> => stripInternalLabels(tagRecord(labels));
216
217export const hasAlchemyLabelMap = (
218 labels: Record<string, string | undefined> | null | undefined,
219) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
220
221export const replaceOnIdentity = (input: {
222 previousId: string | undefined;
223 nextId: string | undefined;
224 previousLocation: string;
225 nextLocation: string;
226 extra?: boolean;
227 previousParent?: string;
228 nextParent?: string;
229}) => {
230 const parentChanged =
231 (input.previousParent ?? "") !== "" &&
232 (input.nextParent ?? "") !== "" &&
233 (input.previousParent ?? "") !== (input.nextParent ?? "");
234 const replace =
235 (input.extra ?? false) ||
236 parentChanged ||
237 (input.previousId !== undefined &&
238 input.nextId !== undefined &&
239 input.nextId !== input.previousId) ||
240 input.previousLocation !== input.nextLocation;
241 if (!replace) return undefined;
242 const samePhysical =
243 input.previousLocation === input.nextLocation &&
244 !parentChanged &&
245 input.previousId !== undefined &&
246 input.nextId === input.previousId;
247 return {
248 action: "replace" as const,
249 deleteFirst: samePhysical,
250 };
251};
252
253const READY_STATES = new Set([
254 "AVAILABLE",
255 "AVAILABLE_NEEDS_ATTENTION",
256 "ACTIVE",
257 "READY",
258 "SUCCEEDED",
259]);
260const FAILED_STATES = new Set(["FAILED", "NEEDS_ATTENTION"]);
261
262export const isReadyState = (state: string | undefined) =>
263 READY_STATES.has((state ?? "").toUpperCase());
264
265export const isFailedState = (state: string | undefined) =>
266 FAILED_STATES.has((state ?? "").toUpperCase());
267
268/** Wait for an operation through the shared GCP waiter. */
269export const waitForOperation = (
270 operation: oracle.Operation,
271 options?: { notFoundOk?: boolean; alreadyExistsOk?: boolean },
272) =>
273 waitForGcpOperation(
274 operation,
275 (name) =>
276 oracle
277 .getProjectsLocationsOperations({ name })
278 .pipe(
279 Effect.catchTag("NotFound", (error) =>
280 options?.notFoundOk === true
281 ? Effect.succeed<oracle.Operation>({ name, done: true })
282 : Effect.fail(error),
283 ),
284 ),
285 { budget: "60 minutes" },
286 ).pipe(
287 // ALREADY_EXISTS (6): a concurrent create won the race. NOT_FOUND (5)
288 // is success for a delete.
289 Effect.catchIf(
290 (error) =>
291 error._tag === "GCP.OperationFailed" &&
292 ((options?.alreadyExistsOk !== false && error.code === 6) ||
293 (options?.notFoundOk === true && error.code === 5)),
294 () => Effect.void,
295 ),
296 // Re-read the finished operation for its typed response and metadata.
297 Effect.flatMap(() =>
298 operation.name === undefined || operation.name.length === 0
299 ? Effect.succeed(operation)
300 : oracle
301 .getProjectsLocationsOperations({ name: operation.name })
302 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(operation))),
303 ),
304 );
305
306export const waitUntilExists = <A, E extends { _tag: string }, R>(
307 get: Effect.Effect<A | undefined, E, R>,
308 name: string,
309) =>
310 get.pipe(
311 Effect.filterOrFail(
312 (value): value is A => value !== undefined,
313 () => new ResourceNotResolved({ name }),
314 ),
315 Effect.retry({
316 while: (error) => error._tag === "GCP.OracleDatabase.ResourceNotResolved",
317 times: 8,
318 schedule: Schedule.spaced("1 second"),
319 }),
320 );
321
322export const waitUntilGone = <A, E extends { _tag: string }, R>(
323 get: Effect.Effect<A | undefined, E, R>,
324 name: string,
325) =>
326 get.pipe(
327 Effect.filterOrFail(
328 (value) => value === undefined,
329 () => new ResourceStillExists({ name }),
330 ),
331 Effect.retry({
332 while: (error) => error._tag === "GCP.OracleDatabase.ResourceStillExists",
333 times: 10,
334 schedule: Schedule.spaced("5 seconds"),
335 }),
336 Effect.asVoid,
337 );
338
339export const waitUntilReady = <A, E extends { _tag: string }, R>(
340 get: Effect.Effect<A | undefined, E, R>,
341 name: string,
342 stateOf: (value: A) => string | undefined,
343 detailsOf?: (value: A) => string | undefined,
344) =>
345 get.pipe(
346 Effect.filterOrFail(
347 (value): value is A => value !== undefined,
348 () => new ResourceNotResolved({ name }),
349 ),
350 Effect.filterOrFail(
351 (value) => !isFailedState(stateOf(value)),
352 (value) =>
353 new ResourceFailed({
354 name,
355 state: stateOf(value) ?? "",
356 details: detailsOf?.(value),
357 }),
358 ),
359 Effect.filterOrFail(
360 (value) => {
361 const state = stateOf(value) ?? "";
362 return isReadyState(state) || state.length === 0;
363 },
364 (value) =>
365 new ResourceNotReady({
366 name,
367 state: stateOf(value) ?? "",
368 }),
369 ),
370 Effect.retry({
371 while: (error) =>
372 error._tag === "GCP.OracleDatabase.ResourceNotReady" ||
373 error._tag === "GCP.OracleDatabase.ResourceNotResolved",
374 times: 10,
375 schedule: Schedule.spaced("8 seconds"),
376 }),
377 );
378
379export const listAtLocation = <A, E extends { _tag: string }, R>(
380 project: string,
381 region: string,
382 list: (parent: string) => Effect.Effect<A[], E, R>,
383) =>
384 list(`projects/${project}/locations/-`).pipe(
385 Effect.catchIf(
386 (error) => error._tag === "NotFound" || error._tag === "BadRequest",
387 () => list(`projects/${project}/locations/${region}`),
388 ),
389 );
390
391export const collectPages = <Page, A, E, R>(
392 pages: Stream.Stream<Page, E, R>,
393 items: (page: Page) => readonly A[] | undefined,
394) =>
395 pages.pipe(
396 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
397 Stream.runCollect,
398 Effect.map((chunk) => Array.from(chunk)),
399 );