1import * as dm from "@distilled.cloud/gcp/datamigration_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 {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13} from "../Labels.ts";
14import { waitForOperation as waitForLongRunning } from "../Operation.ts";
15
16export const MAX_NAME_LENGTH = 63;
17export const MAX_DISPLAY_NAME_LENGTH = 63;
18
19export class ResourceNotResolved extends Data.TaggedError(
20 "GCP.DataMigration.ResourceNotResolved",
21)<{
22 name: string;
23}> {}
24
25export class ResourceStillExists extends Data.TaggedError(
26 "GCP.DataMigration.ResourceStillExists",
27)<{
28 name: string;
29}> {}
30
31export const lastSegment = (value: string) => {
32 const trimmed = value.replace(/\/+$/, "");
33 const parts = trimmed.split("/");
34 return parts[parts.length - 1] || trimmed;
35};
36
37export const rfc1035 = (name: string, fallback = "dm"): string => {
38 let next = name
39 .toLowerCase()
40 .replace(/[^a-z0-9-]/g, "-")
41 .replace(/-+/g, "-")
42 .replace(/^-+|-+$/g, "");
43 if (!/^[a-z]/.test(next)) next = `d${next}`;
44 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
45 if (next.length === 0) return fallback;
46 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
47 return next.slice(0, MAX_NAME_LENGTH);
48};
49
50export const normalizeLocation = (
51 location: string | undefined,
52 defaultLocation: string,
53) => lastSegment(location ?? defaultLocation).toLowerCase();
54
55export const locationParent = (project: string, location: string) =>
56 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
57
58export const parentOfName = (name: string) => {
59 const parts = name.split("/").filter((part) => part.length > 0);
60 return parts.slice(0, -2).join("/");
61};
62
63export const toPhysicalId = (
64 id: string,
65 explicit: string | undefined,
66 existing: string | undefined,
67 fallback = "dm",
68) =>
69 Effect.gen(function* () {
70 if (explicit !== undefined) return rfc1035(explicit, fallback);
71 if (existing !== undefined) return existing;
72 return rfc1035(
73 yield* createPhysicalName({
74 id,
75 maxLength: MAX_NAME_LENGTH,
76 lowercase: true,
77 }),
78 fallback,
79 );
80 });
81
82export const parseName = (name: string, collection: string) => {
83 const parts = name.split("/").filter((part) => part.length > 0);
84 const collectionAt = parts.lastIndexOf(collection);
85 const locationsAt = parts.lastIndexOf("locations");
86 const projectsAt = parts.lastIndexOf("projects");
87 return {
88 project:
89 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
90 location:
91 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
92 id:
93 collectionAt >= 0 && parts[collectionAt + 1]
94 ? parts[collectionAt + 1]!
95 : lastSegment(name),
96 parent:
97 collectionAt > 0
98 ? parts.slice(0, collectionAt).join("/")
99 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
100 };
101};
102
103export const expandParent = (
104 value: string,
105 project: string,
106 location: string,
107 collection: string,
108) => {
109 if (value.includes("/")) return value.replace(/\/+$/, "");
110 return `projects/${project}/locations/${location}/${collection}/${value}`;
111};
112
113export const connectionProfileOf = (
114 value: string,
115 project: string,
116 location: string,
117) => expandParent(value, project, location, "connectionProfiles");
118
119export const conversionWorkspaceOf = (
120 value: string,
121 project: string,
122 location: string,
123) => expandParent(value, project, location, "conversionWorkspaces");
124
125export const networkOf = (value: string, project: string) => {
126 const trimmed = value.replace(/\/+$/, "");
127 const projectsAt = trimmed.indexOf("projects/");
128 if (projectsAt >= 0) return trimmed.slice(projectsAt);
129 return `projects/${project}/global/networks/${trimmed}`;
130};
131
132export const userLabels = (
133 labels: Record<string, string | undefined> | null | undefined,
134): Record<string, string> => stripInternalLabels(tagRecord(labels));
135
136export const hasAlchemyLabelMap = (
137 labels: Record<string, string | undefined> | null | undefined,
138) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
139
140export const canonical = (value: unknown): unknown => {
141 if (value === undefined || value === null) return undefined;
142 if (typeof value === "boolean" || typeof value === "number") return value;
143 if (typeof value === "string") {
144 return value.length === 0 ? undefined : value;
145 }
146 if (Array.isArray(value)) {
147 const items = value.map(canonical).filter((item) => item !== undefined);
148 return items.length === 0 ? undefined : items;
149 }
150 if (typeof value === "object") {
151 const entries = Object.entries(value as Record<string, unknown>)
152 .map(([key, item]) => [key, canonical(item)] as const)
153 .filter(([, item]) => item !== undefined)
154 .sort(([left], [right]) => left.localeCompare(right));
155 if (entries.length === 0) return undefined;
156 return Object.fromEntries(entries);
157 }
158 return undefined;
159};
160
161export const fingerprint = (value: unknown): string =>
162 JSON.stringify(canonical(value) ?? null);
163
164export const fieldMask = (fields: Array<string | false | undefined>) =>
165 fields
166 .filter((field): field is string => typeof field === "string")
167 .join(",");
168
169export const replaceOnIdentity = (input: {
170 previousId: string | undefined;
171 nextId: string | undefined;
172 previousLocation: string;
173 nextLocation: string;
174 extra?: boolean;
175 previousParent?: string;
176 nextParent?: string;
177}) => {
178 const parentChanged =
179 (input.previousParent ?? "") !== "" &&
180 (input.nextParent ?? "") !== "" &&
181 (input.previousParent ?? "") !== (input.nextParent ?? "");
182 const replace =
183 (input.extra ?? false) ||
184 parentChanged ||
185 (input.previousId !== undefined &&
186 input.nextId !== undefined &&
187 input.nextId !== input.previousId) ||
188 input.previousLocation !== input.nextLocation;
189 if (!replace) return undefined;
190 const samePhysical =
191 input.previousLocation === input.nextLocation &&
192 !parentChanged &&
193 input.previousId !== undefined &&
194 input.nextId === input.previousId;
195 return {
196 action: "replace" as const,
197 deleteFirst: samePhysical,
198 };
199};
200
201/**
202 * Wait for a Database Migration Service long-running operation. Cloud SQL /
203 * AlloyDB destination connection profiles provision a database instance
204 * (20–40 minutes).
205 * `ALREADY_EXISTS` (a concurrent create won) always succeeds;
206 * `notFoundOk` also accepts a `NOT_FOUND` result or an operation that has
207 * already been garbage-collected (deletes).
208 */
209export const waitForOperation = (
210 operation: dm.Operation,
211 options?: { notFoundOk?: boolean },
212) =>
213 waitForLongRunning(
214 operation,
215 (name) => dm.getProjectsLocationsOperations({ name }),
216 { budget: "45 minutes" },
217 ).pipe(
218 Effect.catchIf(
219 (error) =>
220 error._tag === "GCP.OperationFailed" &&
221 (error.code === 6 ||
222 (options?.notFoundOk === true && error.code === 5)),
223 () => Effect.void,
224 ),
225 Effect.catchIf(
226 (error) => options?.notFoundOk === true && error._tag === "NotFound",
227 () => Effect.void,
228 ),
229 );
230
231export const waitUntilExists = <A, E extends { readonly _tag: string }, R>(
232 get: Effect.Effect<A | undefined, E, R>,
233 name: string,
234) =>
235 get.pipe(
236 Effect.flatMap((value) =>
237 value
238 ? Effect.succeed(value)
239 : Effect.fail(new ResourceNotResolved({ name })),
240 ),
241 Effect.retry({
242 while: (error) => error._tag === "GCP.DataMigration.ResourceNotResolved",
243 times: 8,
244 schedule: Schedule.spaced("1 second"),
245 }),
246 );
247
248export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
249 get: Effect.Effect<A | undefined, E, R>,
250 name: string,
251) =>
252 get.pipe(
253 Effect.flatMap((value) =>
254 value === undefined
255 ? Effect.void
256 : Effect.fail(new ResourceStillExists({ name })),
257 ),
258 Effect.retry({
259 while: (error) => error._tag === "GCP.DataMigration.ResourceStillExists",
260 times: 10,
261 schedule: Schedule.spaced("4 seconds"),
262 }),
263 );
264
265export const collectPages = <Page, A, E, R>(
266 pages: Stream.Stream<Page, E, R>,
267 items: (page: Page) => readonly A[] | undefined,
268) =>
269 pages.pipe(
270 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
271 Stream.runCollect,
272 Effect.map((chunk) => Array.from(chunk)),
273 );
274
275/**
276 * List across every location via the `locations/-` wildcard, falling back to
277 * the default region where the API rejects the wildcard. A missing parent
278 * (`NotFound`) lists as empty; any other error fails.
279 */
280export const listAtLocation = <A, E extends { readonly _tag: string }, R>(
281 project: string,
282 region: string,
283 list: (parent: string) => Effect.Effect<A[], E, R>,
284) =>
285 list(`projects/${project}/locations/-`).pipe(
286 Effect.catchIf(
287 (error) => error._tag === "NotFound" || error._tag === "BadRequest",
288 () =>
289 list(`projects/${project}/locations/${region}`).pipe(
290 Effect.catchIf(
291 (error) => error._tag === "NotFound",
292 () => Effect.succeed<A[]>([]),
293 ),
294 ),
295 ),
296 );
297
298export const listConversionWorkspaces = (project: string, region: string) =>
299 collectPages(
300 dm.listProjectsLocationsConversionWorkspaces.pages({
301 parent: `projects/${project}/locations/-`,
302 pageSize: 1000,
303 }),
304 (page) => page.conversionWorkspaces,
305 ).pipe(
306 Effect.catchTag("NotFound", () =>
307 collectPages(
308 dm.listProjectsLocationsConversionWorkspaces.pages({
309 parent: locationParent(project, region),
310 pageSize: 1000,
311 }),
312 (page) => page.conversionWorkspaces,
313 ).pipe(
314 Effect.catchTag("NotFound", () =>
315 Effect.succeed([] as dm.ConversionWorkspace[]),
316 ),
317 ),
318 ),
319 );
320
321const markerOf = (
322 labels: Record<string, string>,
323 stack: string,
324 stage: string,
325 id: string,
326) =>
327 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
328
329// `[alc stack stage id]` keeps all three ownership fields when a short
330// display name leaves no room for the keyed `[alchemy ...]` form.
331const compactMarkerOf = (stack: string, stage: string, id: string) =>
332 `[alc ${stack} ${stage} ${id}]`;
333
334const COMPACT_BELOW = 54;
335
336const fitMarker = (labels: Record<string, string>, maxLength: number) => {
337 const build = (stack: string, stage: string, id: string) =>
338 maxLength < COMPACT_BELOW
339 ? compactMarkerOf(stack, stage, id)
340 : markerOf(labels, stack, stage, id);
341 let stack = labels[alchemyLabelKeys.stack] ?? "x";
342 let stage = labels[alchemyLabelKeys.stage] ?? "x";
343 let id = labels[alchemyLabelKeys.id] ?? "x";
344 let marker = build(stack, stage, id);
345 while (
346 marker.length > maxLength &&
347 (stack.length > 1 || stage.length > 1 || id.length > 1)
348 ) {
349 if (stack.length >= stage.length && stack.length >= id.length) {
350 stack = stack.slice(0, -1);
351 } else if (stage.length >= id.length) {
352 stage = stage.slice(0, -1);
353 } else {
354 id = id.slice(0, -1);
355 }
356 marker = build(stack, stage, id);
357 }
358 return marker.length <= maxLength
359 ? marker
360 : `${marker.slice(0, maxLength - 1)}]`;
361};
362
363export const encodeOwnership = (
364 labels: Record<string, string>,
365 text: string | undefined,
366): string => {
367 const marker = fitMarker(labels, 8000);
368 const trimmed = text?.trim();
369 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
370};
371
372export const encodeOwnershipLine = (
373 labels: Record<string, string>,
374 text: string | undefined,
375 maxLength = MAX_DISPLAY_NAME_LENGTH,
376): string => {
377 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
378 if (!trimmed) return fitMarker(labels, maxLength);
379 const minMarker = 16;
380 const reserved = Math.min(
381 trimmed.length + 1,
382 Math.max(0, maxLength - minMarker),
383 );
384 const marker = fitMarker(labels, maxLength - reserved);
385 return `${marker} ${trimmed}`.slice(0, maxLength);
386};
387
388export const parseOwnership = (
389 text: string | undefined,
390): {
391 labels: Record<string, string>;
392 text: string | undefined;
393} => {
394 if (text?.startsWith("[alc ")) {
395 const end = text.indexOf("]");
396 if (end < 0) return { labels: {}, text };
397 const [stack, stage, id] = text
398 .slice("[alc ".length, end)
399 .trim()
400 .split(/\s+/);
401 const labels: Record<string, string> = {};
402 if (stack) labels[alchemyLabelKeys.stack] = stack;
403 if (stage) labels[alchemyLabelKeys.stage] = stage;
404 if (id) labels[alchemyLabelKeys.id] = id;
405 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
406 return { labels, text: rest.length > 0 ? rest : undefined };
407 }
408 if (!text?.startsWith("[alchemy ")) {
409 return { labels: {}, text };
410 }
411 const end = text.indexOf("]");
412 if (end < 0) return { labels: {}, text };
413 const labels: Record<string, string> = {};
414 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
415 const eq = part.indexOf("=");
416 if (eq > 0) {
417 labels[part.slice(0, eq)] = part.slice(eq + 1);
418 }
419 }
420 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
421 return { labels, text: rest.length > 0 ? rest : undefined };
422};
423
424export const hasOwnershipMarker = (text: string | undefined) =>
425 Object.keys(parseOwnership(text).labels).some((key) =>
426 key.startsWith("alchemy-"),
427 );
428
429const prefixMatch = (expected: string, observed: string) =>
430 expected === observed ||
431 expected.startsWith(observed) ||
432 observed.startsWith(expected);
433
434export const ownedByAlchemy = (id: string, text: string | undefined) =>
435 Effect.gen(function* () {
436 const expected = yield* createInternalLabels(id);
437 const { labels } = parseOwnership(text);
438 if (!hasOwnershipMarker(text)) return false;
439 const exact = yield* hasAlchemyLabels(id, labels);
440 if (exact) return true;
441 return (
442 prefixMatch(
443 expected[alchemyLabelKeys.stack] ?? "",
444 labels[alchemyLabelKeys.stack] ?? "",
445 ) &&
446 prefixMatch(
447 expected[alchemyLabelKeys.stage] ?? "",
448 labels[alchemyLabelKeys.stage] ?? "",
449 ) &&
450 prefixMatch(
451 expected[alchemyLabelKeys.id] ?? "",
452 labels[alchemyLabelKeys.id] ?? "",
453 )
454 );
455 });