1import * as spanner from "@distilled.cloud/gcp/spanner_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 { waitForOperation } from "./operations.ts";
22
23const DEFAULT_PROCESSING_UNITS = 100;
24const DEFAULT_INSTANCE_TYPE = "PROVISIONED";
25const DEFAULT_EDITION = "STANDARD";
26const DEFAULT_BACKUP_SCHEDULE = "NONE";
27const MAX_INSTANCE_ID_LENGTH = 64;
28const MAX_DISPLAY_NAME_LENGTH = 30;
29const MIN_DISPLAY_NAME_LENGTH = 4;
30
31export type InstanceType = spanner.InstanceInstanceTypeEnum | (string & {});
32export type InstanceEdition = spanner.InstanceEditionEnum | (string & {});
33export type InstanceDefaultBackupScheduleType =
34 | spanner.InstanceDefaultBackupScheduleTypeEnum
35 | (string & {});
36
37export type AutoscalingLimits = {
38 /** Minimum nodes. Mutually exclusive with processing-unit limits. */
39 minNodes?: number;
40 /** Minimum processing units (multiples of 1000). */
41 minProcessingUnits?: number;
42 /** Maximum nodes. */
43 maxNodes?: number;
44 /** Maximum processing units (multiples of 1000). */
45 maxProcessingUnits?: number;
46};
47
48export type AutoscalingTargets = {
49 /** Target high-priority CPU percent (`10`–`90`). */
50 highPriorityCpuUtilizationPercent?: number;
51 /** Target total CPU percent (`10`–`90`). */
52 totalCpuUtilizationPercent?: number;
53 /** Target storage percent (`10`–`99`). */
54 storageUtilizationPercent?: number;
55};
56
57export type AutoscalingConfig = {
58 /** Min/max compute capacity. */
59 autoscalingLimits?: AutoscalingLimits;
60 /** CPU and storage targets. */
61 autoscalingTargets?: AutoscalingTargets;
62};
63
64export type ReplicaComputeCapacity = {
65 /** Replica location (e.g. `us-central1`). */
66 location?: string;
67 /** Nodes allocated to this replica selection. */
68 nodeCount?: number;
69 /** Processing units allocated to this replica selection. */
70 processingUnits?: number;
71};
72
73export type InstanceProps = {
74 /**
75 * Instance id (the `{instance}` segment of
76 * `projects/{project}/instances/{instance}`). If omitted, a unique name
77 * is generated from the stack, stage, and logical id. Must match
78 * `^[a-z][-a-z0-9]*[a-z0-9]$` (2–64 characters). Immutable — changing
79 * it replaces the instance.
80 */
81 instanceId?: string;
82 /**
83 * Instance configuration id (`regional-us-central1`) or full name
84 * (`projects/{project}/instanceConfigs/{config}`). Immutable —
85 * changing it replaces the instance.
86 * @default `regional-{region}` for the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
87 */
88 config?: string;
89 /**
90 * User-facing display name. Must be unique per project and 4–30
91 * characters. Defaults to a truncated instance id.
92 */
93 displayName?: string;
94 /**
95 * User labels. Alchemy ownership labels are merged in automatically.
96 */
97 labels?: Record<string, string>;
98 /**
99 * Processing units. Mutually exclusive with `nodeCount`. Ignored when
100 * autoscaling is enabled or `instanceType` is `FREE_INSTANCE`.
101 * @default 100
102 */
103 processingUnits?: number;
104 /**
105 * Node count. Mutually exclusive with `processingUnits`. Ignored when
106 * autoscaling is enabled or `instanceType` is `FREE_INSTANCE`.
107 */
108 nodeCount?: number;
109 /**
110 * Autoscaling configuration. When set, `nodeCount` and
111 * `processingUnits` become output-only.
112 */
113 autoscalingConfig?: AutoscalingConfig;
114 /**
115 * Instance type. Immutable — changing it replaces the instance.
116 * @default "PROVISIONED"
117 */
118 instanceType?: InstanceType;
119 /**
120 * Edition (`STANDARD`, `ENTERPRISE`, `ENTERPRISE_PLUS`).
121 * @default "STANDARD"
122 */
123 edition?: InstanceEdition;
124 /**
125 * Default backup schedule for new databases. `NONE` skips automatic
126 * schedules (required for free instances).
127 * @default "NONE"
128 */
129 defaultBackupScheduleType?: InstanceDefaultBackupScheduleType;
130};
131
132export type Instance = Resource<
133 "GCP.Spanner.Instance",
134 InstanceProps,
135 {
136 /** Full resource name `projects/{project}/instances/{instance}`. */
137 name: string;
138 /** Instance id (last path segment). */
139 instanceId: string;
140 /** Project id. */
141 project: string;
142 /** Full instance config name. */
143 config: string;
144 /** User-facing display name. */
145 displayName: string | undefined;
146 /** User labels (Alchemy ownership labels stripped). */
147 labels: Record<string, string>;
148 /** Processing units currently allocated. */
149 processingUnits: number | undefined;
150 /** Nodes currently allocated. */
151 nodeCount: number | undefined;
152 /** Autoscaling configuration, if enabled. */
153 autoscalingConfig: AutoscalingConfig | undefined;
154 /** Compute capacity per replica selection. */
155 replicaComputeCapacity: ReplicaComputeCapacity[];
156 /** Instance type (`PROVISIONED`, `FREE_INSTANCE`). */
157 instanceType: string | undefined;
158 /** Edition (`STANDARD`, `ENTERPRISE`, `ENTERPRISE_PLUS`). */
159 edition: string | undefined;
160 /** Default backup schedule type. */
161 defaultBackupScheduleType: string | undefined;
162 /** Current instance state (`CREATING`, `READY`). */
163 state: string | undefined;
164 /** RFC3339 creation timestamp. */
165 createTime: string | undefined;
166 /** RFC3339 last-update timestamp. */
167 updateTime: string | undefined;
168 },
169 never,
170 Providers
171>;
172
173/**
174 * A Cloud Spanner instance.
175 *
176 * Changing `instanceId`, `config`, or `instanceType` replaces the
177 * instance. Display name, labels, compute capacity, edition, backup
178 * schedule, and autoscaling update in place. Create, update, and delete
179 * are long-running operations — a 100 processing-unit regional instance
180 * typically takes one to two minutes.
181 *
182 * ### Creating an Instance
183 * **Example:** Generated name, 100 processing units
184 * ```typescript
185 * const instance = yield* GCP.Spanner.Instance("App", {});
186 * ```
187 *
188 * **Example:** Explicit id, config, and labels
189 * ```typescript
190 * const instance = yield* GCP.Spanner.Instance("App", {
191 * instanceId: "app-spanner",
192 * config: "regional-us-central1",
193 * displayName: "app-spanner",
194 * processingUnits: 100,
195 * labels: { env: "prod" },
196 * });
197 * ```
198 *
199 * ### Observing Instances
200 * **Example:** Read the bound instance
201 * ```typescript
202 * const getInstance = yield* GCP.Spanner.GetInstance(instance);
203 * const live = yield* getInstance();
204 * ```
205 *
206 * @resource
207 * @category Spanner
208 */
209export const Instance = Resource<Instance>("GCP.Spanner.Instance");
210
211export class InstanceNotResolved extends Data.TaggedError(
212 "GCP.Spanner.InstanceNotResolved",
213)<{
214 name: string;
215}> {}
216
217export class InstanceNotReady extends Data.TaggedError(
218 "GCP.Spanner.InstanceNotReady",
219)<{
220 name: string;
221 state: string;
222}> {}
223
224export class InstanceStillExists extends Data.TaggedError(
225 "GCP.Spanner.InstanceStillExists",
226)<{
227 name: string;
228}> {}
229
230const lastSegment = (value: string) => {
231 const trimmed = value.replace(/\/+$/, "");
232 const parts = trimmed.split("/");
233 return parts[parts.length - 1] || trimmed;
234};
235
236const normalizeEnum = (value: string | undefined, fallback: string) => {
237 const next = (value ?? fallback).toUpperCase();
238 return next.endsWith("_UNSPECIFIED") ? fallback : next;
239};
240
241const normalizeInstanceType = (value: string | undefined) =>
242 normalizeEnum(value, DEFAULT_INSTANCE_TYPE);
243
244const normalizeEdition = (value: string | undefined) =>
245 normalizeEnum(value, DEFAULT_EDITION);
246
247const normalizeBackupSchedule = (value: string | undefined) =>
248 normalizeEnum(value, DEFAULT_BACKUP_SCHEDULE);
249
250const defaultConfigId = (region: string) => `regional-${region}`;
251
252const configIdOf = (config: string | undefined, region: string) =>
253 lastSegment(config ?? defaultConfigId(region)).toLowerCase();
254
255const configNameOf = (
256 project: string,
257 config: string | undefined,
258 region: string,
259) => {
260 const raw = (config ?? defaultConfigId(region)).trim();
261 if (raw.includes("/")) return raw;
262 return `projects/${project}/instanceConfigs/${raw}`;
263};
264
265const resourceName = (project: string, instanceId: string) =>
266 `projects/${project}/instances/${instanceId}`;
267
268const parseName = (name: string) => {
269 const parts = name.split("/").filter((part) => part.length > 0);
270 const instancesAt = parts.lastIndexOf("instances");
271 const projectsAt = parts.lastIndexOf("projects");
272 return {
273 project:
274 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
275 instanceId:
276 instancesAt >= 0 && parts[instancesAt + 1]
277 ? parts[instancesAt + 1]!
278 : lastSegment(name),
279 };
280};
281
282const toSpannerId = (name: string) => {
283 let next = name
284 .toLowerCase()
285 .replace(/[^a-z0-9-]/g, "-")
286 .replace(/-+/g, "-");
287 next = next.replace(/^-+/, "").replace(/-+$/g, "");
288 if (!/^[a-z]/.test(next)) next = `s${next}`;
289 next = next.slice(0, MAX_INSTANCE_ID_LENGTH).replace(/-+$/g, "");
290 if (next.length < 2) next = `${next}xx`.slice(0, MAX_INSTANCE_ID_LENGTH);
291 return next;
292};
293
294const toId = (id: string, instanceId: string | undefined, existing?: string) =>
295 Effect.gen(function* () {
296 if (instanceId !== undefined) return instanceId;
297 if (existing !== undefined) return existing;
298 return toSpannerId(
299 yield* createPhysicalName({
300 id,
301 maxLength: MAX_INSTANCE_ID_LENGTH,
302 lowercase: true,
303 }),
304 );
305 });
306
307const displayNameOf = (instanceId: string, displayName?: string) => {
308 const source = (displayName ?? instanceId).trim();
309 let next = source.slice(0, MAX_DISPLAY_NAME_LENGTH);
310 if (next.length < MIN_DISPLAY_NAME_LENGTH) {
311 next = `${next}inst`.slice(0, MAX_DISPLAY_NAME_LENGTH);
312 }
313 return next;
314};
315
316const userLabels = (
317 labels: Record<string, string | undefined> | null | undefined,
318): Record<string, string> => stripInternalLabels(tagRecord(labels));
319
320const autoscalingOf = (
321 config: spanner.AutoscalingConfig | undefined,
322): AutoscalingConfig | undefined => {
323 if (config === undefined) return undefined;
324 return {
325 autoscalingLimits: config.autoscalingLimits
326 ? {
327 minNodes: config.autoscalingLimits.minNodes,
328 minProcessingUnits: config.autoscalingLimits.minProcessingUnits,
329 maxNodes: config.autoscalingLimits.maxNodes,
330 maxProcessingUnits: config.autoscalingLimits.maxProcessingUnits,
331 }
332 : undefined,
333 autoscalingTargets: config.autoscalingTargets
334 ? {
335 highPriorityCpuUtilizationPercent:
336 config.autoscalingTargets.highPriorityCpuUtilizationPercent,
337 totalCpuUtilizationPercent:
338 config.autoscalingTargets.totalCpuUtilizationPercent,
339 storageUtilizationPercent:
340 config.autoscalingTargets.storageUtilizationPercent,
341 }
342 : undefined,
343 };
344};
345
346const replicaCapacityOf = (
347 items: spanner.ReplicaComputeCapacityList | undefined,
348): ReplicaComputeCapacity[] =>
349 (items ?? []).map((item) => ({
350 location: item.replicaSelection?.location,
351 nodeCount: item.nodeCount,
352 processingUnits: item.processingUnits,
353 }));
354
355const autoscalingKey = (config: AutoscalingConfig | undefined) =>
356 JSON.stringify(config ?? null);
357
358const toAttrs = (
359 instance: spanner.Instance,
360 project: string,
361): Instance["Attributes"] => {
362 const name = instance.name ?? "";
363 const parsed = parseName(name);
364 return {
365 name,
366 instanceId: parsed.instanceId,
367 project: parsed.project || project,
368 config: instance.config ?? "",
369 displayName: instance.displayName,
370 labels: userLabels(instance.labels),
371 processingUnits: instance.processingUnits,
372 nodeCount: instance.nodeCount,
373 autoscalingConfig: autoscalingOf(instance.autoscalingConfig),
374 replicaComputeCapacity: replicaCapacityOf(instance.replicaComputeCapacity),
375 instanceType: instance.instanceType,
376 edition: instance.edition,
377 defaultBackupScheduleType: instance.defaultBackupScheduleType,
378 state: instance.state,
379 createTime: instance.createTime,
380 updateTime: instance.updateTime,
381 };
382};
383
384const getByName = (name: string) =>
385 spanner
386 .getProjectsInstances({ name })
387 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
388
389const waitUntilExists = (name: string) =>
390 getByName(name).pipe(
391 Effect.flatMap((instance) =>
392 instance
393 ? Effect.succeed(instance)
394 : Effect.fail(new InstanceNotResolved({ name })),
395 ),
396 Effect.retry({
397 while: (error) => error._tag === "GCP.Spanner.InstanceNotResolved",
398 times: 8,
399 schedule: Schedule.spaced("2 seconds"),
400 }),
401 );
402
403const waitUntilReady = (name: string) =>
404 getByName(name).pipe(
405 Effect.filterOrFail(
406 (instance): instance is spanner.Instance => instance !== undefined,
407 () => new InstanceNotResolved({ name }),
408 ),
409 Effect.filterOrFail(
410 (instance) => (instance.state ?? "STATE_UNSPECIFIED") === "READY",
411 (instance) =>
412 new InstanceNotReady({
413 name,
414 state: instance.state ?? "STATE_UNSPECIFIED",
415 }),
416 ),
417 Effect.retry({
418 while: (error) =>
419 error._tag === "GCP.Spanner.InstanceNotReady" ||
420 error._tag === "GCP.Spanner.InstanceNotResolved",
421 times: 10,
422 schedule: Schedule.spaced("8 seconds"),
423 }),
424 );
425
426const waitUntilGone = (name: string) =>
427 getByName(name).pipe(
428 Effect.flatMap((instance) =>
429 instance === undefined
430 ? Effect.void
431 : Effect.fail(new InstanceStillExists({ name })),
432 ),
433 Effect.retry({
434 while: (error) => error._tag === "GCP.Spanner.InstanceStillExists",
435 times: 10,
436 schedule: Schedule.spaced("8 seconds"),
437 }),
438 );
439
440const retryConcurrentChanges = <A, E extends { readonly _tag: string }, R>(
441 effect: Effect.Effect<A, E, R>,
442) =>
443 effect.pipe(
444 Effect.retry({
445 while: (error) => error._tag === "Conflict",
446 times: 8,
447 schedule: Schedule.spaced("5 seconds"),
448 }),
449 );
450
451const toCreateInstance = (
452 name: string,
453 news: InstanceProps,
454 project: string,
455 region: string,
456 desiredLabels: Record<string, string>,
457 instanceType: string,
458 displayName: string,
459): spanner.Instance => {
460 const free = instanceType === "FREE_INSTANCE";
461 const autoscaling = news.autoscalingConfig;
462 const body: spanner.Instance = {
463 name,
464 config: configNameOf(project, news.config, region),
465 displayName,
466 labels: desiredLabels,
467 instanceType,
468 };
469 if (!free) {
470 body.edition = news.edition
471 ? normalizeEdition(news.edition)
472 : DEFAULT_EDITION;
473 body.defaultBackupScheduleType = news.defaultBackupScheduleType
474 ? normalizeBackupSchedule(news.defaultBackupScheduleType)
475 : DEFAULT_BACKUP_SCHEDULE;
476 }
477 if (autoscaling !== undefined) {
478 body.autoscalingConfig = autoscaling;
479 } else if (!free) {
480 if (news.nodeCount !== undefined) {
481 body.nodeCount = news.nodeCount;
482 } else {
483 body.processingUnits = news.processingUnits ?? DEFAULT_PROCESSING_UNITS;
484 }
485 }
486 return body;
487};
488
489export const InstanceProvider = () =>
490 Provider.succeed(Instance, {
491 stables: ["name", "instanceId", "project", "config", "createTime"],
492
493 diff: Effect.fn(function* ({ news, olds, output }) {
494 if (!isResolved(news)) return undefined;
495 const env = yield* GcpEnvironment.current;
496
497 const previousId = olds?.instanceId ?? output?.instanceId;
498 const nextId = news.instanceId ?? previousId;
499 const previousConfig = configIdOf(
500 olds?.config ?? output?.config,
501 env.region,
502 );
503 const nextConfig = configIdOf(news.config ?? output?.config, env.region);
504 const previousType = normalizeInstanceType(
505 olds?.instanceType ?? output?.instanceType,
506 );
507 const nextType = normalizeInstanceType(
508 news.instanceType ?? output?.instanceType,
509 );
510
511 const replace =
512 (previousId !== undefined &&
513 nextId !== undefined &&
514 nextId !== previousId) ||
515 previousConfig !== nextConfig ||
516 previousType !== nextType;
517
518 if (!replace) return undefined;
519 return {
520 action: "replace" as const,
521 deleteFirst:
522 previousConfig !== nextConfig ||
523 previousType !== nextType ||
524 (previousId !== undefined && nextId === previousId),
525 };
526 }),
527
528 read: Effect.fn(function* ({ id, olds, output }) {
529 const env = yield* GcpEnvironment.current;
530 const instanceId = yield* toId(id, olds?.instanceId, output?.instanceId);
531 const name = output?.name ?? resourceName(env.project, instanceId);
532 const existing = yield* getByName(name);
533 if (existing === undefined) return undefined;
534 const attrs = toAttrs(existing, env.project);
535 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
536 ? attrs
537 : Unowned(attrs);
538 }),
539
540 list: () =>
541 Effect.gen(function* () {
542 const env = yield* GcpEnvironment.current;
543 return yield* spanner.listProjectsInstances
544 .pages({
545 parent: `projects/${env.project}`,
546 pageSize: 1000,
547 })
548 .pipe(
549 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
550 Stream.filter((instance) =>
551 Object.keys(instance.labels ?? {}).some((key) =>
552 key.startsWith("alchemy-"),
553 ),
554 ),
555 Stream.map((instance) => toAttrs(instance, env.project)),
556 Stream.runCollect,
557 Effect.map((chunk) => Array.from(chunk)),
558 Effect.catchTag("NotFound", () => Effect.succeed([])),
559 );
560 }),
561
562 reconcile: Effect.fn(function* ({ id, news, output }) {
563 const env = yield* GcpEnvironment.current;
564 const instanceId = yield* toId(id, news.instanceId, output?.instanceId);
565 const name = resourceName(env.project, instanceId);
566 const instanceType = normalizeInstanceType(news.instanceType);
567 const displayName = displayNameOf(instanceId, news.displayName);
568 const desiredLabels = {
569 ...toLabels(news.labels),
570 ...(yield* createInternalLabels(id)),
571 };
572
573 let current = yield* getByName(output?.name ?? name);
574
575 if (current === undefined) {
576 const created = yield* spanner
577 .createProjectsInstances({
578 parent: `projects/${env.project}`,
579 body: {
580 instanceId,
581 instance: toCreateInstance(
582 name,
583 news,
584 env.project,
585 env.region,
586 desiredLabels,
587 instanceType,
588 displayName,
589 ),
590 },
591 })
592 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
593 if (created !== undefined) {
594 yield* waitForOperation(created, { alreadyExistsOk: true });
595 }
596 current = yield* waitUntilExists(name);
597 }
598
599 if (current === undefined) {
600 return yield* new InstanceNotResolved({ name });
601 }
602
603 if ((current.state ?? "") !== "READY") {
604 current = yield* waitUntilReady(name);
605 }
606
607 const observedLabels = tagRecord(current.labels);
608 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
609 const labelsChanged = upsert.length > 0 || removed.length > 0;
610 const displayNameChanged = (current.displayName ?? "") !== displayName;
611 const editionChanged =
612 news.edition !== undefined &&
613 normalizeEdition(current.edition) !== normalizeEdition(news.edition);
614 const backupChanged =
615 news.defaultBackupScheduleType !== undefined &&
616 normalizeBackupSchedule(current.defaultBackupScheduleType) !==
617 normalizeBackupSchedule(news.defaultBackupScheduleType);
618 const autoscalingChanged =
619 news.autoscalingConfig !== undefined &&
620 autoscalingKey(autoscalingOf(current.autoscalingConfig)) !==
621 autoscalingKey(news.autoscalingConfig);
622 const processingUnitsChanged =
623 news.autoscalingConfig === undefined &&
624 news.nodeCount === undefined &&
625 news.processingUnits !== undefined &&
626 (current.processingUnits ?? 0) !== news.processingUnits;
627 const nodeCountChanged =
628 news.autoscalingConfig === undefined &&
629 news.nodeCount !== undefined &&
630 (current.nodeCount ?? 0) !== news.nodeCount;
631
632 if (
633 labelsChanged ||
634 displayNameChanged ||
635 editionChanged ||
636 backupChanged ||
637 autoscalingChanged ||
638 processingUnitsChanged ||
639 nodeCountChanged
640 ) {
641 const fieldMask = [
642 labelsChanged ? "labels" : undefined,
643 displayNameChanged ? "display_name" : undefined,
644 editionChanged ? "edition" : undefined,
645 backupChanged ? "default_backup_schedule_type" : undefined,
646 autoscalingChanged ? "autoscaling_config" : undefined,
647 processingUnitsChanged ? "processing_units" : undefined,
648 nodeCountChanged ? "node_count" : undefined,
649 ].filter((field): field is string => field !== undefined);
650
651 const patchBody: spanner.Instance = {
652 name,
653 labels: desiredLabels,
654 displayName,
655 };
656 if (editionChanged && news.edition !== undefined) {
657 patchBody.edition = normalizeEdition(news.edition);
658 }
659 if (backupChanged && news.defaultBackupScheduleType !== undefined) {
660 patchBody.defaultBackupScheduleType = normalizeBackupSchedule(
661 news.defaultBackupScheduleType,
662 );
663 }
664 if (autoscalingChanged) {
665 patchBody.autoscalingConfig = news.autoscalingConfig;
666 }
667 if (processingUnitsChanged) {
668 patchBody.processingUnits = news.processingUnits;
669 }
670 if (nodeCountChanged) {
671 patchBody.nodeCount = news.nodeCount;
672 }
673
674 const patched = yield* retryConcurrentChanges(
675 spanner.patchProjectsInstances({
676 name,
677 body: {
678 instance: patchBody,
679 fieldMask: fieldMask.join(","),
680 },
681 }),
682 );
683 yield* waitForOperation(patched);
684 current = yield* waitUntilReady(name);
685 }
686
687 return toAttrs(current, env.project);
688 }),
689
690 delete: Effect.fn(function* ({ output }) {
691 yield* spanner.deleteProjectsInstances({ name: output.name }).pipe(
692 Effect.catchTag("NotFound", () => Effect.void),
693 Effect.retry({
694 while: (error) => error._tag === "Conflict",
695 times: 8,
696 schedule: Schedule.spaced("5 seconds"),
697 }),
698 );
699 yield* waitUntilGone(output.name);
700 }),
701 });