1import * as alloydb from "@distilled.cloud/gcp/alloydb_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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import type { EncryptionConfig } from "./Cluster.ts";
22import { waitForDeleteOperation, waitForOperation } from "./operations.ts";
23
24const DEFAULT_BACKUP_TYPE = "ON_DEMAND";
25const MAX_NAME_LENGTH = 63;
26
27export type BackupProps = {
28 /**
29 * Source cluster id or full resource name
30 * (`projects/{project}/locations/{location}/clusters/{cluster}`).
31 * Immutable — changing it replaces the backup.
32 */
33 clusterName: string;
34 /**
35 * Region of the backup (`us-central1`, …). Ignored when
36 * `clusterName` is a full resource name unless set explicitly.
37 * Immutable — changing it replaces the backup. `US-CENTRAL1` is
38 * accepted and normalized to `us-central1`.
39 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
40 */
41 location?: string;
42 /**
43 * Backup id (the `{backup}` segment of
44 * `projects/{project}/locations/{location}/backups/{backup}`). If
45 * omitted, a unique RFC1035 name is generated. Must match
46 * `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable — changing it
47 * replaces the backup.
48 */
49 backupId?: string;
50 /**
51 * User-facing display name.
52 */
53 displayName?: string;
54 /**
55 * User-provided description. Changing it replaces the backup.
56 */
57 description?: string;
58 /**
59 * User labels. Alchemy ownership labels are merged in automatically.
60 */
61 labels?: Record<string, string>;
62 /**
63 * Arbitrary client annotations (distinct from labels).
64 */
65 annotations?: Record<string, string>;
66 /**
67 * Backup type. User-created backups are `ON_DEMAND`. Immutable.
68 * @default "ON_DEMAND"
69 */
70 type?: alloydb.BackupTypeEnum | (string & {});
71 /**
72 * Customer-managed encryption. Immutable — changing it replaces the
73 * backup.
74 */
75 encryptionConfig?: EncryptionConfig;
76 /**
77 * Resource Manager tags bound at create time. Immutable.
78 */
79 tags?: Record<string, string>;
80};
81
82export type Backup = Resource<
83 "GCP.AlloyDB.Backup",
84 BackupProps,
85 {
86 /** Full resource name `projects/{project}/locations/{location}/backups/{backup}`. */
87 name: string;
88 /** Backup id (last path segment). */
89 backupId: string;
90 /** Source cluster resource name. */
91 clusterName: string;
92 /** Source cluster id. */
93 clusterId: string;
94 /** Project id. */
95 project: string;
96 /** Region id (`us-central1`, …). */
97 location: string;
98 /** User-facing display name. */
99 displayName: string | undefined;
100 /** User-provided description. */
101 description: string | undefined;
102 /** User labels (Alchemy ownership labels stripped). */
103 labels: Record<string, string>;
104 /** Client annotations. */
105 annotations: Record<string, string>;
106 /** Serving state (`READY`, `CREATING`, `FAILED`, …). */
107 state: string | undefined;
108 /** Backup type (`ON_DEMAND`, `AUTOMATED`, `CONTINUOUS`). */
109 type: string;
110 /** Size in bytes, as a decimal string. */
111 sizeBytes: string | undefined;
112 /** Customer-managed encryption, if any. */
113 encryptionConfig: EncryptionConfig | undefined;
114 /** Database engine major version of the source cluster. */
115 databaseVersion: string | undefined;
116 /** System-generated UID of the source cluster. */
117 clusterUid: string | undefined;
118 /** System-generated UID of the backup. */
119 uid: string | undefined;
120 /** Whether the service is reconciling intended vs actual state. */
121 reconciling: boolean;
122 /** RFC3339 creation timestamp. */
123 createTime: string | undefined;
124 /** RFC3339 last-update timestamp. */
125 updateTime: string | undefined;
126 /** RFC3339 time when creation finished. */
127 createCompletionTime: string | undefined;
128 /** RFC3339 time after which the backup may be garbage collected. */
129 expiryTime: string | undefined;
130 /** HTTP etag. */
131 etag: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * An on-demand AlloyDB backup of a cluster.
139 *
140 * Changing `backupId`, `location`, `clusterName`, `type`,
141 * `encryptionConfig`, `tags`, or `description` replaces the backup.
142 * `displayName`, `labels`, and `annotations` update in place.
143 *
144 * Creating a backup typically takes several minutes and is skipIf-gated
145 * in live tests behind `GCP_TEST_ALLOYDB`.
146 *
147 * ### Creating a Backup
148 * **Example:** On-demand backup of a cluster
149 * ```typescript
150 * const cluster = yield* GCP.AlloyDB.Cluster("AppDb", {
151 * pscConfig: { pscEnabled: true },
152 * automatedBackupPolicy: { enabled: false },
153 * continuousBackupConfig: { enabled: false },
154 * });
155 * const backup = yield* GCP.AlloyDB.Backup("Nightly", {
156 * clusterName: cluster.name,
157 * displayName: "nightly",
158 * labels: { env: "prod" },
159 * });
160 * ```
161 *
162 * **Example:** Explicit id and description
163 * ```typescript
164 * const backup = yield* GCP.AlloyDB.Backup("Nightly", {
165 * clusterName: cluster.name,
166 * backupId: "app-db-nightly",
167 * displayName: "app-db-nightly",
168 * description: "manual snapshot",
169 * });
170 * ```
171 *
172 * @resource
173 * @category AlloyDB
174 */
175export const Backup = Resource<Backup>("GCP.AlloyDB.Backup");
176
177export class BackupNotResolved extends Data.TaggedError(
178 "GCP.AlloyDB.BackupNotResolved",
179)<{
180 name: string;
181}> {}
182
183export class BackupClusterMissing extends Data.TaggedError(
184 "GCP.AlloyDB.BackupClusterMissing",
185)<{
186 message: string;
187}> {}
188
189export class BackupNotReady extends Data.TaggedError(
190 "GCP.AlloyDB.BackupNotReady",
191)<{
192 name: string;
193 state: string;
194}> {}
195
196export class BackupFailed extends Data.TaggedError("GCP.AlloyDB.BackupFailed")<{
197 name: string;
198 state: string;
199}> {}
200
201export class BackupStillExists extends Data.TaggedError(
202 "GCP.AlloyDB.BackupStillExists",
203)<{
204 name: string;
205}> {}
206
207const lastSegment = (value: string | undefined) => {
208 if (value === undefined || value.length === 0) return "";
209 const trimmed = value.replace(/\/+$/, "");
210 const parts = trimmed.split("/");
211 return parts[parts.length - 1] || trimmed;
212};
213
214const normalizeLocation = (location: string) =>
215 lastSegment(location).toLowerCase();
216
217const normalizeType = (type: string | undefined) => {
218 const value = (type ?? DEFAULT_BACKUP_TYPE).toUpperCase();
219 return value === "TYPE_UNSPECIFIED" ? DEFAULT_BACKUP_TYPE : value;
220};
221
222const rfc1035 = (name: string): string => {
223 let next = name
224 .toLowerCase()
225 .replace(/[^a-z0-9-]/g, "-")
226 .replace(/-+/g, "-")
227 .replace(/^-+|-+$/g, "");
228 if (!/^[a-z]/.test(next)) next = `b${next}`;
229 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
230 if (next.length === 0) return "backup";
231 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
232 return next.slice(0, MAX_NAME_LENGTH);
233};
234
235const resourceName = (project: string, location: string, backupId: string) =>
236 `projects/${project}/locations/${location}/backups/${backupId}`;
237
238const clusterNameOf = (project: string, location: string, clusterId: string) =>
239 `projects/${project}/locations/${location}/clusters/${clusterId}`;
240
241const parseBackupName = (name: string) => {
242 const parts = name.split("/").filter((part) => part.length > 0);
243 const backupsAt = parts.lastIndexOf("backups");
244 const locationsAt = parts.lastIndexOf("locations");
245 const projectsAt = parts.lastIndexOf("projects");
246 return {
247 project:
248 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
249 location:
250 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
251 backupId:
252 backupsAt >= 0 && parts[backupsAt + 1]
253 ? parts[backupsAt + 1]!
254 : lastSegment(name),
255 };
256};
257
258const parseClusterRef = (
259 cluster: string,
260 fallbackProject: string,
261 fallbackLocation: string,
262) => {
263 const trimmed = cluster.trim();
264 if (trimmed.length === 0) {
265 return {
266 project: fallbackProject,
267 location: normalizeLocation(fallbackLocation),
268 clusterId: "",
269 clusterName: "",
270 };
271 }
272 if (trimmed.includes("/clusters/") || trimmed.includes("projects/")) {
273 const parts = trimmed.split("/").filter((part) => part.length > 0);
274 const clustersAt = parts.lastIndexOf("clusters");
275 const locationsAt = parts.lastIndexOf("locations");
276 const projectsAt = parts.lastIndexOf("projects");
277 const clusterId =
278 clustersAt >= 0 && parts[clustersAt + 1] ? parts[clustersAt + 1]! : "";
279 const project =
280 projectsAt >= 0 && parts[projectsAt + 1]
281 ? parts[projectsAt + 1]!
282 : fallbackProject;
283 const location = normalizeLocation(
284 (locationsAt >= 0 && parts[locationsAt + 1]
285 ? parts[locationsAt + 1]
286 : undefined) ?? fallbackLocation,
287 );
288 return {
289 project,
290 location,
291 clusterId,
292 clusterName: clusterNameOf(project, location, clusterId),
293 };
294 }
295 const location = normalizeLocation(fallbackLocation);
296 const clusterId = lastSegment(trimmed);
297 return {
298 project: fallbackProject,
299 location,
300 clusterId,
301 clusterName: clusterNameOf(fallbackProject, location, clusterId),
302 };
303};
304
305const userLabels = (
306 labels: Record<string, string | undefined> | null | undefined,
307): Record<string, string> => stripInternalLabels(tagRecord(labels));
308
309const stringMapOf = (
310 map: Record<string, string | undefined> | null | undefined,
311): Record<string, string> =>
312 Object.fromEntries(
313 Object.entries(map ?? {}).filter(
314 (entry): entry is [string, string] =>
315 entry[1] !== undefined && entry[1].length > 0,
316 ),
317 );
318
319const toId = (id: string, backupId: string | undefined, existing?: string) =>
320 Effect.gen(function* () {
321 return (
322 backupId ??
323 existing ??
324 rfc1035(
325 yield* createPhysicalName({
326 id,
327 maxLength: MAX_NAME_LENGTH,
328 lowercase: true,
329 }),
330 )
331 );
332 });
333
334const fingerprint = (value: unknown): string => JSON.stringify(value ?? null);
335
336const toEncryptionConfig = (
337 config: alloydb.EncryptionConfig | EncryptionConfig | undefined,
338): EncryptionConfig | undefined => {
339 const kmsKeyName = config?.kmsKeyName;
340 if (kmsKeyName === undefined || kmsKeyName.length === 0) return undefined;
341 return { kmsKeyName };
342};
343
344const isAvailable = (state: string | undefined) =>
345 (state ?? "").toUpperCase() === "READY";
346
347const isFailed = (state: string | undefined) =>
348 (state ?? "").toUpperCase() === "FAILED";
349
350const toAttrs = (backup: alloydb.Backup, project: string) => {
351 const name = backup.name ?? "";
352 const parsed = parseBackupName(name);
353 const clusterRef = parseClusterRef(
354 backup.clusterName ?? "",
355 parsed.project || project,
356 parsed.location,
357 );
358 return {
359 name,
360 backupId: parsed.backupId,
361 clusterName: backup.clusterName ?? clusterRef.clusterName,
362 clusterId: clusterRef.clusterId,
363 project: parsed.project || project,
364 location: parsed.location,
365 displayName: backup.displayName,
366 description: backup.description,
367 labels: userLabels(backup.labels),
368 annotations: stringMapOf(backup.annotations),
369 state: backup.state,
370 type: normalizeType(backup.type),
371 sizeBytes: backup.sizeBytes,
372 encryptionConfig: toEncryptionConfig(backup.encryptionConfig),
373 databaseVersion: backup.databaseVersion,
374 clusterUid: backup.clusterUid,
375 uid: backup.uid,
376 reconciling: backup.reconciling === true,
377 createTime: backup.createTime,
378 updateTime: backup.updateTime,
379 createCompletionTime: backup.createCompletionTime,
380 expiryTime: backup.expiryTime,
381 etag: backup.etag,
382 };
383};
384
385const isPlaceholder = (backup: alloydb.Backup) => {
386 const name = backup.name ?? "";
387 return name.endsWith("/backups/-") || name.endsWith("/backups/");
388};
389
390const getByName = (name: string) =>
391 alloydb
392 .getProjectsLocationsBackups({ name })
393 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
394
395const waitUntilExists = (name: string) =>
396 getByName(name).pipe(
397 Effect.flatMap((backup) =>
398 backup
399 ? Effect.succeed(backup)
400 : Effect.fail(new BackupNotResolved({ name })),
401 ),
402 Effect.retry({
403 while: (error) => error._tag === "GCP.AlloyDB.BackupNotResolved",
404 times: 8,
405 schedule: Schedule.spaced("1 second"),
406 }),
407 );
408
409const waitUntilReady = (name: string) =>
410 Effect.gen(function* () {
411 const backup = yield* getByName(name);
412 if (backup === undefined) {
413 return yield* new BackupNotReady({ name, state: "MISSING" });
414 }
415 if (isFailed(backup.state)) {
416 return yield* new BackupFailed({
417 name,
418 state: backup.state ?? "FAILED",
419 });
420 }
421 if (!(isAvailable(backup.state) && backup.reconciling !== true)) {
422 return yield* new BackupNotReady({
423 name,
424 state: backup.state ?? "STATE_UNSPECIFIED",
425 });
426 }
427 return backup;
428 }).pipe(
429 Effect.retry({
430 while: (error) => error._tag === "GCP.AlloyDB.BackupNotReady",
431 times: 120,
432 schedule: Schedule.spaced("15 seconds"),
433 }),
434 );
435
436const waitUntilGone = (name: string) =>
437 getByName(name).pipe(
438 Effect.flatMap((backup) =>
439 backup === undefined
440 ? Effect.void
441 : Effect.fail(new BackupStillExists({ name })),
442 ),
443 Effect.retry({
444 while: (error) => error._tag === "GCP.AlloyDB.BackupStillExists",
445 times: 120,
446 schedule: Schedule.spaced("15 seconds"),
447 }),
448 );
449
450const toCreateBody = (
451 news: BackupProps,
452 desiredLabels: Record<string, string>,
453 clusterName: string,
454 backupType: string,
455): alloydb.Backup => {
456 const body: alloydb.Backup = {
457 clusterName,
458 type: backupType,
459 displayName: news.displayName,
460 labels: desiredLabels,
461 };
462 if (news.description !== undefined) {
463 body.description = news.description;
464 }
465 if (news.annotations !== undefined) {
466 body.annotations = news.annotations;
467 }
468 const encryption = toEncryptionConfig(news.encryptionConfig);
469 if (encryption !== undefined) {
470 body.encryptionConfig = encryption;
471 }
472 if (news.tags !== undefined) {
473 body.tags = news.tags;
474 }
475 return body;
476};
477
478export const BackupProvider = () =>
479 Provider.succeed(Backup, {
480 stables: [
481 "name",
482 "backupId",
483 "clusterName",
484 "clusterId",
485 "project",
486 "location",
487 "uid",
488 "createTime",
489 ],
490
491 diff: Effect.fn(function* ({ news, olds, output }) {
492 if (!isResolved(news)) return undefined;
493 const env = yield* GcpEnvironment.current;
494
495 const previousId = olds?.backupId ?? output?.backupId;
496 const nextId = news.backupId ?? previousId;
497 const previousCluster = lastSegment(
498 olds?.clusterName ?? output?.clusterName ?? output?.clusterId,
499 );
500 const nextCluster = lastSegment(news.clusterName ?? previousCluster);
501 const previousLocation = normalizeLocation(
502 olds?.location ?? output?.location ?? env.region,
503 );
504 const nextLocation = normalizeLocation(
505 news.location ?? output?.location ?? env.region,
506 );
507 const previousType = normalizeType(olds?.type ?? output?.type);
508 const nextType = normalizeType(news.type ?? output?.type);
509 const previousKey =
510 olds?.encryptionConfig?.kmsKeyName ??
511 output?.encryptionConfig?.kmsKeyName ??
512 "";
513 const nextKey = news.encryptionConfig?.kmsKeyName ?? previousKey;
514 const previousTags = fingerprint(olds?.tags ?? undefined);
515 const nextTags = fingerprint(news.tags ?? olds?.tags ?? undefined);
516
517 const replace =
518 (previousId !== undefined &&
519 nextId !== undefined &&
520 nextId !== previousId) ||
521 (previousCluster.length > 0 &&
522 nextCluster.length > 0 &&
523 previousCluster !== nextCluster) ||
524 previousLocation !== nextLocation ||
525 previousType !== nextType ||
526 previousKey !== nextKey ||
527 (news.tags !== undefined && previousTags !== nextTags) ||
528 // PATCH accepts `description` but leaves it unchanged (observed live),
529 // so a new description replaces the backup.
530 (olds !== undefined &&
531 (news.description ?? "") !== (olds.description ?? ""));
532
533 if (!replace) return undefined;
534 return {
535 action: "replace" as const,
536 deleteFirst:
537 previousLocation === nextLocation &&
538 previousCluster === nextCluster &&
539 previousId !== undefined &&
540 nextId === previousId,
541 };
542 }),
543
544 read: Effect.fn(function* ({ id, olds, output }) {
545 const env = yield* GcpEnvironment.current;
546 if (output?.name) {
547 const existing = yield* getByName(output.name);
548 if (existing === undefined) return undefined;
549 const attrs = toAttrs(existing, env.project);
550 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
551 ? attrs
552 : Unowned(attrs);
553 }
554 const backupId = yield* toId(id, olds?.backupId, output?.backupId);
555 const ref = parseClusterRef(
556 olds?.clusterName ?? output?.clusterName ?? output?.clusterId ?? "",
557 env.project,
558 olds?.location ?? output?.location ?? env.region,
559 );
560 const location = normalizeLocation(
561 olds?.location ?? output?.location ?? ref.location,
562 );
563 const name = resourceName(env.project, location, backupId);
564 const existing = yield* getByName(name);
565 if (existing === undefined) return undefined;
566 const attrs = toAttrs(existing, env.project);
567 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
568 ? attrs
569 : Unowned(attrs);
570 }),
571
572 list: () =>
573 Effect.gen(function* () {
574 const env = yield* GcpEnvironment.current;
575 return yield* alloydb.listProjectsLocationsBackups
576 .pages({
577 parent: `projects/${env.project}/locations/-`,
578 pageSize: 1000,
579 })
580 .pipe(
581 Stream.flatMap((page) => Stream.fromIterable(page.backups ?? [])),
582 Stream.filter(
583 (backup) =>
584 !isPlaceholder(backup) &&
585 Object.keys(backup.labels ?? {}).some((key) =>
586 key.startsWith("alchemy-"),
587 ),
588 ),
589 Stream.map((backup) => toAttrs(backup, env.project)),
590 Stream.runCollect,
591 Effect.map((chunk) => Array.from(chunk)),
592 Effect.catchTag("NotFound", () => Effect.succeed([])),
593 );
594 }),
595
596 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
597 const env = yield* GcpEnvironment.current;
598 const backupId = yield* toId(id, news.backupId, output?.backupId);
599 const ref = parseClusterRef(
600 news.clusterName,
601 env.project,
602 news.location ?? output?.location ?? env.region,
603 );
604 if (ref.clusterId.length === 0) {
605 return yield* new BackupClusterMissing({
606 message:
607 "GCP.AlloyDB.Backup requires `clusterName` (cluster id or full resource name)",
608 });
609 }
610 const location = normalizeLocation(news.location ?? ref.location);
611 const name = resourceName(env.project, location, backupId);
612 const backupType = normalizeType(news.type);
613 const desiredLabels = {
614 ...toLabels(news.labels),
615 ...(yield* createInternalLabels(id)),
616 };
617
618 let current = yield* getByName(output?.name ?? name);
619
620 if (current === undefined) {
621 const parent = `projects/${env.project}/locations/${location}`;
622 const created = yield* alloydb
623 .createProjectsLocationsBackups({
624 parent,
625 backupId,
626 body: toCreateBody(
627 news,
628 desiredLabels,
629 ref.clusterName,
630 backupType,
631 ),
632 })
633 .pipe(
634 // The cluster only accepts backups a few minutes after its
635 // primary instance is created.
636 Effect.retry({
637 while: (error) => error._tag === "ClusterNotReadyForBackup",
638 times: 40,
639 schedule: Schedule.spaced("15 seconds"),
640 }),
641 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
642 );
643 if (created !== undefined) {
644 yield* waitForOperation(created);
645 }
646 current = yield* waitUntilExists(name);
647 }
648
649 if (current === undefined) {
650 return yield* new BackupNotResolved({ name });
651 }
652
653 if (!isAvailable(current.state) || current.reconciling === true) {
654 current = yield* waitUntilReady(name);
655 }
656
657 const observedLabels = tagRecord(current.labels);
658 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
659 const labelsChanged = upsert.length > 0 || removed.length > 0;
660 const observedDisplayName =
661 current.displayName ?? output?.displayName ?? olds?.displayName;
662 const displayNameChanged =
663 (observedDisplayName ?? "") !== (news.displayName ?? "");
664 const annotationsChanged =
665 news.annotations !== undefined &&
666 fingerprint(stringMapOf(current.annotations)) !==
667 fingerprint(news.annotations);
668
669 if (labelsChanged || displayNameChanged || annotationsChanged) {
670 const updateMask = [
671 labelsChanged ? "labels" : undefined,
672 displayNameChanged ? "displayName" : undefined,
673 annotationsChanged ? "annotations" : undefined,
674 ].filter((field): field is string => field !== undefined);
675
676 const patched = yield* alloydb
677 .patchProjectsLocationsBackups({
678 name,
679 updateMask: updateMask.join(","),
680 body: {
681 name,
682 labels: desiredLabels,
683 displayName: news.displayName,
684 annotations: news.annotations,
685 },
686 })
687 .pipe(
688 Effect.retry({
689 while: (error) => error._tag === "Conflict",
690 times: 40,
691 schedule: Schedule.spaced("15 seconds"),
692 }),
693 );
694 yield* waitForOperation(patched);
695 current = yield* waitUntilReady(name);
696 }
697
698 // AlloyDB omits `displayName` from backup reads (like clusters); the
699 // desired value is what was applied.
700 return toAttrs(
701 { ...current, displayName: current.displayName ?? news.displayName },
702 env.project,
703 );
704 }),
705
706 delete: Effect.fn(function* ({ output }) {
707 const operation = yield* alloydb
708 .deleteProjectsLocationsBackups({ name: output.name })
709 .pipe(
710 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
711 Effect.retry({
712 while: (error) => error._tag === "Conflict",
713 times: 40,
714 schedule: Schedule.spaced("15 seconds"),
715 }),
716 );
717 if (operation !== undefined) {
718 yield* waitForDeleteOperation(operation);
719 }
720 yield* waitUntilGone(output.name);
721 }),
722 });