1import * as certificatemanager from "@distilled.cloud/gcp/certificatemanager_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_LOCATION = "global";
24const MAX_NAME_LENGTH = 63;
25
26export type CertificateMapEntryMatcher = "PRIMARY" | (string & {});
27
28export type CertificateMapEntryProps = {
29 /**
30 * Parent certificate map. Full name
31 * `projects/{project}/locations/{location}/certificateMaps/{certificateMap}`
32 * or the map id (combined with `location`). Immutable — changing it
33 * replaces the entry.
34 */
35 certificateMap: string;
36 /**
37 * Certificate map entry id (the `{certificateMapEntry}` segment of
38 * `.../certificateMaps/{certificateMap}/certificateMapEntries/{certificateMapEntry}`).
39 * If omitted, a unique name is generated from the stack, stage, and
40 * logical id. Must be 1-63 characters, start with a letter, and match
41 * `[a-zA-Z][a-zA-Z0-9_-]*`. Immutable — changing it replaces the entry.
42 */
43 certificateMapEntryId?: string;
44 /**
45 * Certificate Manager location. Used when `certificateMap` is a bare
46 * id. Certificate maps are global resources; omit or set `"global"`.
47 * Immutable — changing it replaces the entry. `GLOBAL` is accepted and
48 * normalized to `global`.
49 * @default "global"
50 */
51 location?: string;
52 /**
53 * Human-readable description.
54 */
55 description?: string;
56 /**
57 * User labels. Alchemy ownership labels are merged in automatically.
58 */
59 labels?: Record<string, string>;
60 /**
61 * Certificate resource names served for this hostname (or PRIMARY
62 * matcher). Up to four certificates, each
63 * `projects/{project}/locations/{location}/certificates/{certificate}`.
64 * Bare certificate ids are qualified with the entry's project and
65 * location. Mutable.
66 */
67 certificates?: string[];
68 /**
69 * Hostname (FQDN, e.g. `www.example.com`) or wildcard (`*.example.com`)
70 * used as SNI. Mutually exclusive with `matcher`. Immutable — changing
71 * it replaces the entry.
72 */
73 hostname?: string;
74 /**
75 * Predefined matcher when this entry is not selected by SNI. `PRIMARY`
76 * is served when the client omits SNI or the hostname is not in the
77 * map. Mutually exclusive with `hostname`. Immutable — changing it
78 * replaces the entry. If both are set, `hostname` is used.
79 */
80 matcher?: CertificateMapEntryMatcher;
81};
82
83export type CertificateMapEntry = Resource<
84 "GCP.CertificateManager.CertificateMapEntry",
85 CertificateMapEntryProps,
86 {
87 /** Full resource name `projects/{project}/locations/{location}/certificateMaps/{certificateMap}/certificateMapEntries/{certificateMapEntry}`. */
88 name: string;
89 /** Certificate map entry id (last path segment). */
90 certificateMapEntryId: string;
91 /** Parent certificate map resource name. */
92 certificateMap: string;
93 /** Parent certificate map id. */
94 certificateMapId: string;
95 /** Project id. */
96 project: string;
97 /** Location id (`global`). */
98 location: string;
99 /** User-provided description. */
100 description: string | undefined;
101 /** User labels (Alchemy ownership labels stripped). */
102 labels: Record<string, string>;
103 /** Certificate resource names attached to this entry. */
104 certificates: string[];
105 /** SNI hostname, if this is a hostname entry. */
106 hostname: string | undefined;
107 /** Predefined matcher (`PRIMARY`), if this is not a hostname entry. */
108 matcher: string | undefined;
109 /** Serving state (`ACTIVE`, `PENDING`). */
110 state: string | undefined;
111 /** RFC3339 creation timestamp. */
112 createTime: string | undefined;
113 /** RFC3339 last-update timestamp. */
114 updateTime: string | undefined;
115 },
116 never,
117 Providers
118>;
119
120/**
121 * A Certificate Manager certificate map entry — binds one or more
122 * certificates to an SNI hostname (or the PRIMARY fallback) inside a
123 * certificate map.
124 *
125 * Changing `certificateMapEntryId`, `certificateMap`, `location`,
126 * `hostname`, or `matcher` replaces the resource. Description, labels,
127 * and `certificates` update in place. Hostname and matcher are mutually
128 * exclusive; if both are set, hostname is used.
129 *
130 * ### Creating a Hostname Entry
131 * **Example:** SNI hostname
132 * ```typescript
133 * const cert = yield* GCP.CertificateManager.Certificate("FrontendTls", {
134 * pemCertificate,
135 * pemPrivateKey,
136 * });
137 * const map = yield* GCP.CertificateManager.CertificateMap("FrontendMap", {});
138 * const entry = yield* GCP.CertificateManager.CertificateMapEntry("Www", {
139 * certificateMap: map.name,
140 * certificates: [cert.name],
141 * hostname: "www.example.com",
142 * });
143 * ```
144 *
145 * **Example:** Named entry with labels
146 * ```typescript
147 * const entry = yield* GCP.CertificateManager.CertificateMapEntry("Www", {
148 * certificateMap: map.name,
149 * certificateMapEntryId: "app-www",
150 * description: "prod www",
151 * labels: { env: "prod" },
152 * certificates: [cert.name],
153 * hostname: "www.example.com",
154 * });
155 * ```
156 *
157 * ### Primary Fallback
158 * **Example:** PRIMARY matcher
159 * ```typescript
160 * const fallback = yield* GCP.CertificateManager.CertificateMapEntry("Default", {
161 * certificateMap: map.name,
162 * certificates: [cert.name],
163 * matcher: "PRIMARY",
164 * });
165 * ```
166 *
167 * ### Updating Certificates
168 * **Example:** Attach an extra certificate
169 * ```typescript
170 * const entry = yield* GCP.CertificateManager.CertificateMapEntry("Www", {
171 * certificateMap: map.name,
172 * certificates: [cert.name, extra.name],
173 * hostname: "www.example.com",
174 * });
175 * ```
176 *
177 * @resource
178 * @category CertificateManager
179 */
180export const CertificateMapEntry = Resource<CertificateMapEntry>(
181 "GCP.CertificateManager.CertificateMapEntry",
182);
183
184export class CertificateMapEntryNotResolved extends Data.TaggedError(
185 "GCP.CertificateManager.CertificateMapEntryNotResolved",
186)<{
187 name: string;
188}> {}
189
190export class CertificateMapEntryStillExists extends Data.TaggedError(
191 "GCP.CertificateManager.CertificateMapEntryStillExists",
192)<{
193 name: string;
194}> {}
195
196type Match =
197 | { kind: "hostname"; hostname: string }
198 | { kind: "matcher"; matcher: string };
199
200const lastSegment = (value: string) => {
201 const trimmed = value.replace(/\/+$/, "");
202 const parts = trimmed.split("/");
203 return parts[parts.length - 1] || trimmed;
204};
205
206const rfc1035 = (name: string): string => {
207 let next = name
208 .toLowerCase()
209 .replace(/[^a-z0-9-]/g, "-")
210 .replace(/-+/g, "-")
211 .replace(/^-+/, "")
212 .replace(/-+$/, "");
213 if (!/^[a-z]/.test(next)) {
214 next = `c${next}`;
215 }
216 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/, "");
217 return next.length > 0 ? next : "certificatemapentry";
218};
219
220const normalizeLocation = (location: string | undefined) =>
221 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
222
223const normalizeMatcher = (matcher: string | undefined) => {
224 const value = (matcher ?? "").toUpperCase();
225 if (value === "" || value === "MATCHER_UNSPECIFIED") return undefined;
226 return value;
227};
228
229const matchOf = (
230 props: { hostname?: string; matcher?: string },
231 fallback?: Match,
232): Match | undefined => {
233 if (props.hostname !== undefined && props.hostname !== "") {
234 return { kind: "hostname", hostname: props.hostname };
235 }
236 const matcher = normalizeMatcher(props.matcher);
237 if (matcher !== undefined) {
238 return { kind: "matcher", matcher };
239 }
240 return fallback;
241};
242
243const parseName = (name: string) => {
244 const parts = name.split("/").filter((part) => part.length > 0);
245 const entriesAt = parts.lastIndexOf("certificateMapEntries");
246 const mapsAt = parts.lastIndexOf("certificateMaps");
247 const locationsAt = parts.lastIndexOf("locations");
248 const projectsAt = parts.lastIndexOf("projects");
249 const certificateMap =
250 mapsAt >= 0 ? parts.slice(0, mapsAt + 2).join("/") : "";
251 return {
252 project:
253 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
254 location:
255 locationsAt >= 0 && parts[locationsAt + 1]
256 ? parts[locationsAt + 1]!
257 : DEFAULT_LOCATION,
258 certificateMap,
259 certificateMapId:
260 mapsAt >= 0 && parts[mapsAt + 1] ? parts[mapsAt + 1]! : "",
261 certificateMapEntryId:
262 entriesAt >= 0 && parts[entriesAt + 1]
263 ? parts[entriesAt + 1]!
264 : lastSegment(name),
265 };
266};
267
268const resolveParent = (
269 project: string,
270 certificateMap: string,
271 location: string | undefined,
272) => {
273 if (certificateMap.includes("/")) {
274 const parsed = parseName(
275 certificateMap.includes("/certificateMapEntries/")
276 ? certificateMap
277 : `${certificateMap.replace(/\/+$/, "")}/certificateMapEntries/_`,
278 );
279 return {
280 parent: parsed.certificateMap,
281 location: parsed.location,
282 project: parsed.project || project,
283 certificateMapId: parsed.certificateMapId,
284 };
285 }
286 const loc = normalizeLocation(location);
287 return {
288 parent: `projects/${project}/locations/${loc}/certificateMaps/${certificateMap}`,
289 location: loc,
290 project,
291 certificateMapId: certificateMap,
292 };
293};
294
295const parentKey = (
296 certificateMap: string | undefined,
297 location: string | undefined,
298) => {
299 if (certificateMap === undefined || certificateMap === "") return undefined;
300 const parsed = resolveParent("", certificateMap, location);
301 return `${parsed.location}/${parsed.certificateMapId}`;
302};
303
304const resourceName = (parent: string, certificateMapEntryId: string) =>
305 `${parent}/certificateMapEntries/${certificateMapEntryId}`;
306
307const userLabels = (
308 labels: Record<string, string | undefined> | null | undefined,
309): Record<string, string> => stripInternalLabels(tagRecord(labels));
310
311const toId = (
312 id: string,
313 certificateMapEntryId: string | undefined,
314 existing?: string,
315) =>
316 Effect.gen(function* () {
317 if (certificateMapEntryId !== undefined) return certificateMapEntryId;
318 if (existing !== undefined) return existing;
319 return rfc1035(
320 yield* createPhysicalName({
321 id,
322 maxLength: MAX_NAME_LENGTH,
323 lowercase: true,
324 }),
325 );
326 });
327
328const normalizeCertificateName = (
329 value: string,
330 project: string,
331 location: string,
332) => {
333 if (value.includes("/certificates/")) {
334 const parts = value.split("/").filter((part) => part.length > 0);
335 const certificatesAt = parts.lastIndexOf("certificates");
336 const locationsAt = parts.lastIndexOf("locations");
337 const loc =
338 locationsAt >= 0 && parts[locationsAt + 1]
339 ? parts[locationsAt + 1]!
340 : location;
341 const certificateId =
342 certificatesAt >= 0 && parts[certificatesAt + 1]
343 ? parts[certificatesAt + 1]!
344 : lastSegment(value);
345 return `projects/${project}/locations/${loc}/certificates/${certificateId}`;
346 }
347 return `projects/${project}/locations/${location}/certificates/${lastSegment(value)}`;
348};
349
350const normalizeCertificates = (
351 values: readonly string[] | undefined,
352 project: string,
353 location: string,
354) =>
355 (values ?? []).map((value) =>
356 normalizeCertificateName(value, project, location),
357 );
358
359const sameCertificates = (
360 left: readonly string[] | undefined,
361 right: readonly string[] | undefined,
362 project: string,
363 location: string,
364) =>
365 [...normalizeCertificates(left, project, location)].sort().join("\0") ===
366 [...normalizeCertificates(right, project, location)].sort().join("\0");
367
368const toAttrs = (
369 entry: certificatemanager.CertificateMapEntry,
370 project: string,
371) => {
372 const name = entry.name ?? "";
373 const parsed = parseName(name);
374 const matcher = normalizeMatcher(entry.matcher);
375 return {
376 name,
377 certificateMapEntryId: parsed.certificateMapEntryId,
378 certificateMap: parsed.certificateMap,
379 certificateMapId: parsed.certificateMapId,
380 project: parsed.project || project,
381 location: parsed.location,
382 description: entry.description,
383 labels: userLabels(entry.labels),
384 certificates: normalizeCertificates(
385 entry.certificates,
386 project,
387 parsed.location,
388 ),
389 hostname:
390 entry.hostname !== undefined && entry.hostname !== ""
391 ? entry.hostname
392 : undefined,
393 matcher,
394 state: entry.state,
395 createTime: entry.createTime,
396 updateTime: entry.updateTime,
397 };
398};
399
400const isUsableEntryName = (name: string) =>
401 /\/certificateMaps\/[^/]+\/certificateMapEntries\/[^/]+$/.test(name);
402
403const getByName = (name: string) => {
404 if (!isUsableEntryName(name)) {
405 return Effect.succeed(undefined);
406 }
407 return certificatemanager
408 .getProjectsLocationsCertificateMapsCertificateMapEntries({ name })
409 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
410};
411
412const waitUntilExists = (name: string) =>
413 getByName(name).pipe(
414 Effect.flatMap((entry) =>
415 entry
416 ? Effect.succeed(entry)
417 : Effect.fail(new CertificateMapEntryNotResolved({ name })),
418 ),
419 Effect.retry({
420 while: (error) =>
421 error._tag === "GCP.CertificateManager.CertificateMapEntryNotResolved",
422 times: 8,
423 schedule: Schedule.spaced("1 second"),
424 }),
425 );
426
427const waitUntilGone = (name: string) =>
428 getByName(name).pipe(
429 Effect.flatMap((entry) =>
430 entry === undefined
431 ? Effect.void
432 : Effect.fail(new CertificateMapEntryStillExists({ name })),
433 ),
434 Effect.retry({
435 while: (error) =>
436 error._tag === "GCP.CertificateManager.CertificateMapEntryStillExists",
437 times: 10,
438 schedule: Schedule.spaced("1 second"),
439 }),
440 );
441
442const listEntriesAt = (parent: string, project: string) =>
443 certificatemanager.listProjectsLocationsCertificateMapsCertificateMapEntries
444 .pages({
445 parent,
446 pageSize: 1000,
447 })
448 .pipe(
449 Stream.flatMap((page) =>
450 Stream.fromIterable(page.certificateMapEntries ?? []),
451 ),
452 Stream.filter((entry) =>
453 Object.keys(entry.labels ?? {}).some((key) =>
454 key.startsWith("alchemy-"),
455 ),
456 ),
457 Stream.map((entry) => toAttrs(entry, project)),
458 Stream.runCollect,
459 Effect.map((chunk) => Array.from(chunk)),
460 Effect.catchTag("NotFound", () => Effect.succeed([])),
461 );
462
463const listOwnedEntries = (project: string) =>
464 Effect.gen(function* () {
465 const maps = yield* certificatemanager.listProjectsLocationsCertificateMaps
466 .pages({
467 parent: `projects/${project}/locations/-`,
468 pageSize: 1000,
469 })
470 .pipe(
471 Stream.flatMap((page) =>
472 Stream.fromIterable(page.certificateMaps ?? []),
473 ),
474 Stream.runCollect,
475 Effect.map((chunk) => Array.from(chunk)),
476 Effect.catchTag("NotFound", () =>
477 Effect.succeed([] as certificatemanager.CertificateMap[]),
478 ),
479 );
480
481 const pages = yield* Effect.forEach(
482 maps,
483 (map) =>
484 map.name
485 ? listEntriesAt(map.name, project)
486 : Effect.succeed([] as ReturnType<typeof toAttrs>[]),
487 { concurrency: 4 },
488 );
489 return pages.flat();
490 });
491
492const toCreateBody = (
493 news: CertificateMapEntryProps,
494 match: Match | undefined,
495 certificates: string[],
496 desiredLabels: Record<string, string>,
497): certificatemanager.CertificateMapEntry => {
498 const body: certificatemanager.CertificateMapEntry = {
499 description: news.description,
500 labels: desiredLabels,
501 certificates,
502 };
503 if (match?.kind === "hostname") {
504 body.hostname = match.hostname;
505 } else if (match?.kind === "matcher") {
506 body.matcher = match.matcher;
507 }
508 return body;
509};
510
511const matchChanged = (
512 news: CertificateMapEntryProps,
513 olds: Partial<CertificateMapEntryProps> | undefined,
514 output: CertificateMapEntry["Attributes"] | undefined,
515) => {
516 const previous = matchOf({
517 hostname: olds?.hostname ?? output?.hostname,
518 matcher: olds?.matcher ?? output?.matcher,
519 });
520 const next = matchOf(news, previous);
521 if (previous === undefined || next === undefined) return false;
522 if (previous.kind !== next.kind) return true;
523 if (
524 previous.kind === "hostname" &&
525 next.kind === "hostname" &&
526 previous.hostname !== next.hostname
527 ) {
528 return true;
529 }
530 if (
531 previous.kind === "matcher" &&
532 next.kind === "matcher" &&
533 previous.matcher !== next.matcher
534 ) {
535 return true;
536 }
537 return false;
538};
539
540export const CertificateMapEntryProvider = () =>
541 Provider.succeed(CertificateMapEntry, {
542 stables: [
543 "name",
544 "certificateMapEntryId",
545 "certificateMap",
546 "certificateMapId",
547 "project",
548 "location",
549 "hostname",
550 "matcher",
551 "createTime",
552 ],
553
554 diff: Effect.fn(function* ({ news, olds, output }) {
555 if (!isResolved(news)) return undefined;
556
557 const previousId =
558 olds?.certificateMapEntryId ?? output?.certificateMapEntryId;
559 const nextId = news.certificateMapEntryId ?? previousId;
560 const previousLocation = normalizeLocation(
561 olds?.location ?? output?.location,
562 );
563 const nextLocation = normalizeLocation(
564 news.location ?? olds?.location ?? output?.location,
565 );
566 const previousParent = parentKey(
567 olds?.certificateMap ?? output?.certificateMap,
568 olds?.location ?? output?.location,
569 );
570 const nextParent =
571 news.certificateMap !== undefined
572 ? parentKey(
573 news.certificateMap,
574 news.location ?? olds?.location ?? output?.location,
575 )
576 : previousParent;
577
578 const replace =
579 (previousId !== undefined &&
580 nextId !== undefined &&
581 nextId !== previousId) ||
582 previousLocation !== nextLocation ||
583 (previousParent !== undefined &&
584 nextParent !== undefined &&
585 previousParent !== nextParent) ||
586 matchChanged(news, olds, output);
587
588 if (!replace) return undefined;
589 return {
590 action: "replace" as const,
591 deleteFirst:
592 previousParent === nextParent &&
593 previousLocation === nextLocation &&
594 previousId !== undefined &&
595 nextId === previousId,
596 };
597 }),
598
599 read: Effect.fn(function* ({ id, olds, output }) {
600 const env = yield* GcpEnvironment.current;
601 const mapRef = olds?.certificateMap ?? output?.certificateMap;
602 let name = output?.name;
603 if (name === undefined || name.length === 0) {
604 // Interrupted creates persist parent Outputs as holes (`{}` / "").
605 // Don't call GET with `.../certificateMaps//certificateMapEntries/...`.
606 if (typeof mapRef !== "string" || mapRef.length === 0) {
607 return undefined;
608 }
609 const certificateMapEntryId = yield* toId(
610 id,
611 olds?.certificateMapEntryId,
612 output?.certificateMapEntryId,
613 );
614 const parent = resolveParent(
615 env.project,
616 mapRef,
617 olds?.location ?? output?.location,
618 );
619 name = resourceName(parent.parent, certificateMapEntryId);
620 }
621 const existing = yield* getByName(name);
622 if (existing === undefined) return undefined;
623 const attrs = toAttrs(existing, env.project);
624 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
625 ? attrs
626 : Unowned(attrs);
627 }),
628
629 list: () =>
630 Effect.gen(function* () {
631 const env = yield* GcpEnvironment.current;
632 return yield* listOwnedEntries(env.project);
633 }),
634
635 reconcile: Effect.fn(function* ({ id, news, output }) {
636 const env = yield* GcpEnvironment.current;
637 const certificateMapEntryId = yield* toId(
638 id,
639 news.certificateMapEntryId,
640 output?.certificateMapEntryId,
641 );
642 const parent = resolveParent(
643 env.project,
644 news.certificateMap ?? output?.certificateMap ?? "",
645 news.location ?? output?.location,
646 );
647 const name = resourceName(parent.parent, certificateMapEntryId);
648 const desiredLabels = {
649 ...toLabels(news.labels),
650 ...(yield* createInternalLabels(id)),
651 };
652 const match = matchOf(news, matchOf(output ?? {}));
653 const certificates = normalizeCertificates(
654 news.certificates,
655 parent.project,
656 parent.location,
657 );
658
659 let current = yield* getByName(name);
660
661 if (current === undefined) {
662 const created = yield* certificatemanager
663 .createProjectsLocationsCertificateMapsCertificateMapEntries({
664 parent: parent.parent,
665 certificateMapEntryId,
666 body: toCreateBody(news, match, certificates, desiredLabels),
667 })
668 .pipe(
669 Effect.retry({
670 while: (error) =>
671 error._tag === "Conflict" || error._tag === "NotFound",
672 times: 5,
673 schedule: Schedule.spaced("2 seconds"),
674 }),
675 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
676 );
677 if (created !== undefined) {
678 yield* waitForOperation(created);
679 }
680 current = yield* waitUntilExists(name);
681 }
682
683 if (current === undefined) {
684 return yield* new CertificateMapEntryNotResolved({ name });
685 }
686
687 const observedLabels = tagRecord(current.labels);
688 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
689 const labelsChanged = upsert.length > 0 || removed.length > 0;
690 const descriptionChanged =
691 (current.description ?? "") !== (news.description ?? "");
692 const certificatesChanged =
693 news.certificates !== undefined &&
694 !sameCertificates(
695 current.certificates,
696 certificates,
697 parent.project,
698 parent.location,
699 );
700
701 if (labelsChanged || descriptionChanged || certificatesChanged) {
702 const updateMask = [
703 labelsChanged ? "labels" : undefined,
704 descriptionChanged ? "description" : undefined,
705 certificatesChanged ? "certificates" : undefined,
706 ].filter((field): field is string => field !== undefined);
707
708 const operation =
709 yield* certificatemanager.patchProjectsLocationsCertificateMapsCertificateMapEntries(
710 {
711 name,
712 updateMask: updateMask.join(","),
713 body: {
714 name,
715 labels: desiredLabels,
716 description: news.description,
717 certificates,
718 },
719 },
720 );
721 yield* waitForOperation(operation);
722 current = yield* waitUntilExists(name);
723 }
724
725 return toAttrs(current, env.project);
726 }),
727
728 delete: Effect.fn(function* ({ output }) {
729 const name = output.name;
730 if (name === undefined || name.length === 0 || !isUsableEntryName(name)) {
731 return;
732 }
733 const operation = yield* certificatemanager
734 .deleteProjectsLocationsCertificateMapsCertificateMapEntries({
735 name,
736 })
737 .pipe(
738 Effect.retry({
739 while: (error) => error._tag === "Conflict",
740 times: 8,
741 schedule: Schedule.spaced("2 seconds"),
742 }),
743 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
744 );
745 if (operation !== undefined) {
746 yield* waitForOperation(operation, { notFoundOk: true });
747 }
748 yield* waitUntilGone(name);
749 }),
750 });