1import * as bne from "@distilled.cloud/gcp/blockchainnodeengine_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 COLLECTION,
19 DEFAULT_BLOCKCHAIN_TYPE,
20 DEFAULT_CONSENSUS_CLIENT,
21 DEFAULT_EXECUTION_CLIENT,
22 DEFAULT_NETWORK,
23 DEFAULT_NODE_TYPE,
24 ResourceNotResolved,
25 fieldMask,
26 isReadyState,
27 listAtLocation,
28 listLabeledPages,
29 normalizeEnum,
30 normalizeLocation,
31 parentOf,
32 parseName,
33 replaceOnIdentity,
34 resourceName,
35 sameEnum,
36 sameStringList,
37 sameText,
38 toPhysicalId,
39 userLabels,
40 waitForOperation,
41 waitUntilExists,
42 waitUntilGone,
43 waitUntilReady,
44} from "./internal.ts";
45
46export type ValidatorConfig = {
47 /**
48 * Ethereum address the beacon client sends fee rewards to when the
49 * validator client has no recipient configured.
50 */
51 beaconFeeRecipient?: string;
52 /**
53 * MEV-relay URLs. When set, a GCP-managed MEV-boost service is
54 * configured on the beacon client.
55 */
56 mevRelayUrls?: string[];
57 /**
58 * Deploy a GCP-managed validator client next to the beacon client.
59 * Immutable — changing it replaces the node.
60 */
61 managedValidatorClient?: boolean;
62};
63
64export type GethDetails = {
65 /**
66 * Geth garbage collection mode (`FULL` or `ARCHIVE`). Immutable —
67 * changing it replaces the node.
68 */
69 garbageCollectionMode?:
70 | bne.GethDetailsGarbageCollectionModeEnum
71 | (string & {});
72};
73
74export type EthereumDetails = {
75 /**
76 * Ethereum node type (`LIGHT`, `FULL`, `ARCHIVE`). Immutable —
77 * changing it replaces the node.
78 * @default "FULL"
79 */
80 nodeType?: bne.EthereumDetailsNodeTypeEnum | (string & {});
81 /**
82 * Enable JSON-RPC `debug` namespace methods. Immutable — changing it
83 * replaces the node.
84 * @default false
85 */
86 apiEnableDebug?: boolean;
87 /**
88 * Ethereum network (`MAINNET`, `TESTNET_SEPOLIA`, `TESTNET_HOLESKY`,
89 * `TESTNET_GOERLI_PRATER`). Immutable — changing it replaces the node.
90 * @default "TESTNET_SEPOLIA"
91 */
92 network?: bne.EthereumDetailsNetworkEnum | (string & {});
93 /**
94 * Validator / MEV configuration. `beaconFeeRecipient` and
95 * `mevRelayUrls` update in place; `managedValidatorClient` is
96 * immutable.
97 */
98 validatorConfig?: ValidatorConfig;
99 /**
100 * Geth-specific options. Immutable.
101 */
102 gethDetails?: GethDetails;
103 /**
104 * Execution client (`GETH` or `ERIGON`). Immutable — changing it
105 * replaces the node.
106 * @default "GETH"
107 */
108 executionClient?: bne.EthereumDetailsExecutionClientEnum | (string & {});
109 /**
110 * Consensus client (`LIGHTHOUSE` or `ERIGON_EMBEDDED_CONSENSUS_LAYER`).
111 * Immutable — changing it replaces the node.
112 * @default "LIGHTHOUSE"
113 */
114 consensusClient?: bne.EthereumDetailsConsensusClientEnum | (string & {});
115 /**
116 * Enable JSON-RPC `admin` namespace methods. Immutable — changing it
117 * replaces the node.
118 * @default false
119 */
120 apiEnableAdmin?: boolean;
121};
122
123export type EndpointInfo = {
124 /** JSON-RPC HTTPS endpoint. */
125 jsonRpcApiEndpoint: string | undefined;
126 /** WebSocket endpoint. */
127 websocketsApiEndpoint: string | undefined;
128};
129
130export type ConnectionInfo = {
131 /**
132 * Private Service Connect attachment
133 * (`projects/{project}/regions/{region}/serviceAttachments/{name}`).
134 */
135 serviceAttachment: string | undefined;
136 /** Public JSON-RPC / WebSocket endpoints, when exposed. */
137 endpointInfo: EndpointInfo | undefined;
138};
139
140export type EthereumEndpoints = {
141 /** Beacon API endpoint. */
142 beaconApiEndpoint: string | undefined;
143 /** Beacon Prometheus metrics endpoint. */
144 beaconPrometheusMetricsApiEndpoint: string | undefined;
145 /** Execution-client Prometheus metrics endpoint. */
146 executionClientPrometheusMetricsApiEndpoint: string | undefined;
147};
148
149export type BlockchainNodeProps = {
150 /**
151 * Node id (the `{blockchainNode}` segment of
152 * `projects/{project}/locations/{location}/blockchainNodes/{blockchainNode}`).
153 * If omitted, a unique RFC1035 name is generated from the stack, stage,
154 * and logical id. Must be 1-63 characters, start with a letter, and end
155 * with a letter or digit. Immutable — changing it replaces the node.
156 */
157 blockchainNodeId?: string;
158 /**
159 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
160 * replaces the node. `US-CENTRAL1` is accepted and normalized to
161 * `us-central1`.
162 * @default the stack's GCP region (`GCP.Region`, else the profile
163 * region, else `us-central1`)
164 */
165 location?: string;
166 /**
167 * Blockchain type. Ethereum is the only supported value. Immutable —
168 * changing it replaces the node.
169 * @default "ETHEREUM"
170 */
171 blockchainType?: bne.BlockchainNodeBlockchainTypeEnum | (string & {});
172 /**
173 * Restrict access to Private Service Connect (no public endpoints).
174 * Deprecated by the API in favor of public endpoints. Immutable —
175 * changing it replaces the node.
176 * @default false
177 */
178 privateServiceConnectEnabled?: boolean;
179 /**
180 * Ethereum-specific configuration. Immutable fields (`network`,
181 * `nodeType`, clients, API namespaces, Geth GC mode, managed
182 * validator) replace the node. Validator fee recipient and MEV
183 * relays update in place.
184 */
185 ethereumDetails?: EthereumDetails;
186 /**
187 * User labels. Alchemy ownership labels are merged in automatically.
188 */
189 labels?: Record<string, string>;
190};
191
192export type BlockchainNode = Resource<
193 "GCP.BlockchainNodeEngine.BlockchainNode",
194 BlockchainNodeProps,
195 {
196 /** Full resource name `projects/{project}/locations/{location}/blockchainNodes/{blockchainNode}`. */
197 name: string;
198 /** Node id (last path segment). */
199 blockchainNodeId: string;
200 /** Project id. */
201 project: string;
202 /** Region id (`us-central1`, …). */
203 location: string;
204 /** Blockchain type (`ETHEREUM`). */
205 blockchainType: string | undefined;
206 /** Whether Private Service Connect is enabled. */
207 privateServiceConnectEnabled: boolean;
208 /** User labels (Alchemy ownership labels stripped). */
209 labels: Record<string, string>;
210 /** Ethereum configuration currently applied. */
211 ethereumDetails: EthereumDetails | undefined;
212 /** Ethereum-specific additional endpoints. */
213 additionalEndpoints: EthereumEndpoints | undefined;
214 /** Connection information (JSON-RPC / WebSocket / PSC). */
215 connectionInfo: ConnectionInfo | undefined;
216 /** Server-reported state (`CREATING`, `RUNNING`, `SYNCING`, …). */
217 state: string | undefined;
218 /** RFC3339 creation timestamp. */
219 createTime: string | undefined;
220 /** RFC3339 last-update timestamp. */
221 updateTime: string | undefined;
222 },
223 never,
224 Providers
225>;
226
227/**
228 * A Blockchain Node Engine node — a fully managed Ethereum execution
229 * and consensus client.
230 *
231 * Changing `blockchainNodeId`, `location`, `blockchainType`,
232 * `privateServiceConnectEnabled`, or immutable Ethereum fields
233 * (`network`, `nodeType`, execution/consensus clients, API namespaces,
234 * Geth garbage collection, managed validator) replaces the node.
235 * Labels, `beaconFeeRecipient`, and `mevRelayUrls` update in place.
236 *
237 * Provisioning typically takes 15-45 minutes and the API is
238 * entitlement-gated. Prefer `TESTNET_SEPOLIA` over `MAINNET`.
239 *
240 * ### Creating a Node
241 * **Example:** Generated name, Sepolia full node
242 * ```typescript
243 * const node = yield* GCP.BlockchainNodeEngine.BlockchainNode("Sepolia", {});
244 * ```
245 *
246 * **Example:** Explicit id, labels, and Geth archive
247 * ```typescript
248 * const node = yield* GCP.BlockchainNodeEngine.BlockchainNode("Sepolia", {
249 * blockchainNodeId: "app-sepolia",
250 * location: "us-central1",
251 * labels: { env: "prod" },
252 * ethereumDetails: {
253 * network: "TESTNET_SEPOLIA",
254 * nodeType: "ARCHIVE",
255 * executionClient: "GETH",
256 * consensusClient: "LIGHTHOUSE",
257 * gethDetails: { garbageCollectionMode: "ARCHIVE" },
258 * },
259 * });
260 * ```
261 *
262 * ### Validator configuration
263 * **Example:** Suggested fee recipient and MEV relays
264 * ```typescript
265 * const node = yield* GCP.BlockchainNodeEngine.BlockchainNode("Sepolia", {
266 * ethereumDetails: {
267 * network: "TESTNET_SEPOLIA",
268 * nodeType: "FULL",
269 * validatorConfig: {
270 * beaconFeeRecipient: "0x0000000000000000000000000000000000000000",
271 * mevRelayUrls: ["https://relay.example"],
272 * },
273 * },
274 * });
275 * ```
276 *
277 * @resource
278 * @category BlockchainNodeEngine
279 */
280export const BlockchainNode = Resource<BlockchainNode>(
281 "GCP.BlockchainNodeEngine.BlockchainNode",
282);
283
284const toValidator = (
285 config: bne.ValidatorConfig | ValidatorConfig | undefined,
286): ValidatorConfig | undefined => {
287 if (config === undefined) return undefined;
288 if (
289 config.beaconFeeRecipient === undefined &&
290 (config.mevRelayUrls === undefined || config.mevRelayUrls.length === 0) &&
291 config.managedValidatorClient === undefined
292 ) {
293 return undefined;
294 }
295 return {
296 beaconFeeRecipient: config.beaconFeeRecipient,
297 mevRelayUrls: config.mevRelayUrls ? [...config.mevRelayUrls] : undefined,
298 managedValidatorClient: config.managedValidatorClient,
299 };
300};
301
302const toGeth = (
303 details: bne.GethDetails | GethDetails | undefined,
304): GethDetails | undefined => {
305 if (details?.garbageCollectionMode === undefined) return undefined;
306 return { garbageCollectionMode: details.garbageCollectionMode };
307};
308
309const toEthereum = (
310 details: bne.EthereumDetails | EthereumDetails | undefined,
311): EthereumDetails | undefined => {
312 if (details === undefined) return undefined;
313 return {
314 nodeType: details.nodeType,
315 apiEnableDebug: details.apiEnableDebug,
316 network: details.network,
317 validatorConfig: toValidator(details.validatorConfig),
318 gethDetails: toGeth(details.gethDetails),
319 executionClient: details.executionClient,
320 consensusClient: details.consensusClient,
321 apiEnableAdmin: details.apiEnableAdmin,
322 };
323};
324
325const mergeEthereum = (
326 previous: EthereumDetails | undefined,
327 next: EthereumDetails | undefined,
328): EthereumDetails => ({
329 nodeType: next?.nodeType ?? previous?.nodeType,
330 apiEnableDebug: next?.apiEnableDebug ?? previous?.apiEnableDebug,
331 network: next?.network ?? previous?.network,
332 validatorConfig: {
333 beaconFeeRecipient:
334 next?.validatorConfig?.beaconFeeRecipient ??
335 previous?.validatorConfig?.beaconFeeRecipient,
336 mevRelayUrls:
337 next?.validatorConfig?.mevRelayUrls ??
338 previous?.validatorConfig?.mevRelayUrls,
339 managedValidatorClient:
340 next?.validatorConfig?.managedValidatorClient ??
341 previous?.validatorConfig?.managedValidatorClient,
342 },
343 gethDetails: {
344 garbageCollectionMode:
345 next?.gethDetails?.garbageCollectionMode ??
346 previous?.gethDetails?.garbageCollectionMode,
347 },
348 executionClient: next?.executionClient ?? previous?.executionClient,
349 consensusClient: next?.consensusClient ?? previous?.consensusClient,
350 apiEnableAdmin: next?.apiEnableAdmin ?? previous?.apiEnableAdmin,
351});
352
353const ethereumIdentityKey = (details: EthereumDetails | undefined) =>
354 JSON.stringify({
355 nodeType: normalizeEnum(details?.nodeType, ""),
356 network: normalizeEnum(details?.network, ""),
357 executionClient: normalizeEnum(details?.executionClient, ""),
358 consensusClient: normalizeEnum(details?.consensusClient, ""),
359 apiEnableAdmin: details?.apiEnableAdmin === true,
360 apiEnableDebug: details?.apiEnableDebug === true,
361 gc: normalizeEnum(details?.gethDetails?.garbageCollectionMode, ""),
362 managedValidator: details?.validatorConfig?.managedValidatorClient === true,
363 });
364
365const desiredEthereum = (news: BlockchainNodeProps): EthereumDetails => {
366 const details = news.ethereumDetails;
367 return {
368 nodeType: normalizeEnum(details?.nodeType, DEFAULT_NODE_TYPE),
369 apiEnableDebug: details?.apiEnableDebug === true,
370 network: normalizeEnum(details?.network, DEFAULT_NETWORK),
371 validatorConfig: details?.validatorConfig,
372 gethDetails: details?.gethDetails,
373 executionClient: normalizeEnum(
374 details?.executionClient,
375 DEFAULT_EXECUTION_CLIENT,
376 ),
377 consensusClient: normalizeEnum(
378 details?.consensusClient,
379 DEFAULT_CONSENSUS_CLIENT,
380 ),
381 apiEnableAdmin: details?.apiEnableAdmin === true,
382 };
383};
384
385const toCreateBody = (
386 news: BlockchainNodeProps,
387 desiredLabels: Record<string, string>,
388): bne.BlockchainNode => {
389 const ethereum = desiredEthereum(news);
390 return {
391 blockchainType: normalizeEnum(news.blockchainType, DEFAULT_BLOCKCHAIN_TYPE),
392 privateServiceConnectEnabled: news.privateServiceConnectEnabled === true,
393 labels: desiredLabels,
394 ethereumDetails: {
395 nodeType: ethereum.nodeType,
396 apiEnableDebug: ethereum.apiEnableDebug,
397 network: ethereum.network,
398 validatorConfig: ethereum.validatorConfig,
399 gethDetails: ethereum.gethDetails,
400 executionClient: ethereum.executionClient,
401 consensusClient: ethereum.consensusClient,
402 apiEnableAdmin: ethereum.apiEnableAdmin,
403 },
404 };
405};
406
407const toEndpoints = (
408 endpoints: bne.EthereumEndpoints | undefined,
409): EthereumEndpoints | undefined => {
410 if (endpoints === undefined) return undefined;
411 return {
412 beaconApiEndpoint: endpoints.beaconApiEndpoint,
413 beaconPrometheusMetricsApiEndpoint:
414 endpoints.beaconPrometheusMetricsApiEndpoint,
415 executionClientPrometheusMetricsApiEndpoint:
416 endpoints.executionClientPrometheusMetricsApiEndpoint,
417 };
418};
419
420const toConnection = (
421 info: bne.ConnectionInfo | undefined,
422): ConnectionInfo | undefined => {
423 if (info === undefined) return undefined;
424 return {
425 serviceAttachment: info.serviceAttachment,
426 endpointInfo: info.endpointInfo
427 ? {
428 jsonRpcApiEndpoint: info.endpointInfo.jsonRpcApiEndpoint,
429 websocketsApiEndpoint: info.endpointInfo.websocketsApiEndpoint,
430 }
431 : undefined,
432 };
433};
434
435const toAttrs = (node: bne.BlockchainNode, project: string, region: string) => {
436 const name = node.name ?? "";
437 const parsed = parseName(name, region);
438 return {
439 name,
440 blockchainNodeId: parsed.id,
441 project: parsed.project || project,
442 location: parsed.location,
443 blockchainType: node.blockchainType,
444 privateServiceConnectEnabled: node.privateServiceConnectEnabled === true,
445 labels: userLabels(node.labels),
446 ethereumDetails: toEthereum(node.ethereumDetails),
447 additionalEndpoints: toEndpoints(node.ethereumDetails?.additionalEndpoints),
448 connectionInfo: toConnection(node.connectionInfo),
449 state: node.state,
450 createTime: node.createTime,
451 updateTime: node.updateTime,
452 };
453};
454
455const isPlaceholder = (node: bne.BlockchainNode) => {
456 const name = node.name ?? "";
457 return (
458 name.length === 0 ||
459 name.endsWith(`/${COLLECTION}/-`) ||
460 name.endsWith(`/${COLLECTION}/`)
461 );
462};
463
464const getByName = (name: string) =>
465 name.length === 0
466 ? Effect.succeed(undefined)
467 : bne
468 .getProjectsLocationsBlockchainNodes({ name })
469 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
470
471const listOwned = (project: string, region: string) =>
472 listAtLocation(project, region, (parent) =>
473 listLabeledPages(
474 bne.listProjectsLocationsBlockchainNodes.pages({
475 parent,
476 pageSize: 1000,
477 }),
478 (page) => page.blockchainNodes,
479 (item) => item.labels,
480 ),
481 );
482
483export const BlockchainNodeProvider = () =>
484 Provider.succeed(BlockchainNode, {
485 stables: [
486 "name",
487 "blockchainNodeId",
488 "project",
489 "location",
490 "blockchainType",
491 "createTime",
492 ],
493
494 diff: Effect.fn(function* ({ news, olds, output }) {
495 if (!isResolved(news)) return undefined;
496 const env = yield* GcpEnvironment.current;
497 const previousType = olds?.blockchainType ?? output?.blockchainType;
498 const nextType = news.blockchainType ?? previousType;
499 const previousPsc =
500 olds?.privateServiceConnectEnabled ??
501 output?.privateServiceConnectEnabled;
502 const nextPsc = news.privateServiceConnectEnabled ?? previousPsc ?? false;
503 const previousEthereum = toEthereum(
504 olds?.ethereumDetails ?? output?.ethereumDetails,
505 );
506 const nextEthereum = mergeEthereum(
507 previousEthereum,
508 news.ethereumDetails,
509 );
510 return replaceOnIdentity({
511 previousId: olds?.blockchainNodeId ?? output?.blockchainNodeId,
512 nextId:
513 news.blockchainNodeId ??
514 olds?.blockchainNodeId ??
515 output?.blockchainNodeId,
516 previousLocation: normalizeLocation(
517 olds?.location ?? output?.location,
518 env.region,
519 ),
520 nextLocation: normalizeLocation(
521 news.location ?? olds?.location ?? output?.location,
522 env.region,
523 ),
524 extra:
525 (previousType !== undefined &&
526 nextType !== undefined &&
527 !sameEnum(previousType, nextType)) ||
528 (previousPsc === true) !== (nextPsc === true) ||
529 (previousEthereum !== undefined &&
530 ethereumIdentityKey(previousEthereum) !==
531 ethereumIdentityKey(nextEthereum)),
532 });
533 }),
534
535 read: Effect.fn(function* ({ id, olds, output }) {
536 const env = yield* GcpEnvironment.current;
537 const blockchainNodeId = yield* toPhysicalId(
538 id,
539 olds?.blockchainNodeId,
540 output?.blockchainNodeId,
541 "node",
542 );
543 const location = normalizeLocation(
544 olds?.location ?? output?.location,
545 env.region,
546 );
547 const name =
548 output?.name ?? resourceName(env.project, location, blockchainNodeId);
549 const existing = yield* getByName(name);
550 if (existing === undefined) return undefined;
551 const attrs = toAttrs(existing, env.project, env.region);
552 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
553 ? attrs
554 : Unowned(attrs);
555 }),
556
557 list: () =>
558 Effect.gen(function* () {
559 const env = yield* GcpEnvironment.current;
560 const items = yield* listOwned(env.project, env.region);
561 return items
562 .filter((item) => !isPlaceholder(item))
563 .map((item) => toAttrs(item, env.project, env.region));
564 }),
565
566 reconcile: Effect.fn(function* ({ id, news, output }) {
567 const env = yield* GcpEnvironment.current;
568 const blockchainNodeId = yield* toPhysicalId(
569 id,
570 news.blockchainNodeId,
571 output?.blockchainNodeId,
572 "node",
573 );
574 const location = normalizeLocation(
575 news.location ?? output?.location,
576 env.region,
577 );
578 const name = resourceName(env.project, location, blockchainNodeId);
579 const desiredLabels = {
580 ...toLabels(news.labels),
581 ...(yield* createInternalLabels(id)),
582 };
583
584 let current = yield* getByName(output?.name ?? name);
585
586 if (current === undefined) {
587 const created = yield* bne
588 .createProjectsLocationsBlockchainNodes({
589 parent: parentOf(env.project, location),
590 blockchainNodeId,
591 body: toCreateBody(news, desiredLabels),
592 })
593 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
594 if (created !== undefined) {
595 yield* waitForOperation(created);
596 }
597 current = yield* waitUntilExists(getByName(name), name);
598 }
599
600 if (current === undefined) {
601 return yield* new ResourceNotResolved({ name });
602 }
603
604 let latest = current;
605 if (!isReadyState(latest.state)) {
606 latest = yield* waitUntilReady(
607 getByName(latest.name ?? name),
608 latest.name ?? name,
609 (node) => node.state,
610 );
611 }
612
613 const observedLabels = tagRecord(latest.labels);
614 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
615 const labelsChanged = upsert.length > 0 || removed.length > 0;
616 const desiredValidator = news.ethereumDetails?.validatorConfig;
617 const observedValidator = latest.ethereumDetails?.validatorConfig;
618 const feeChanged =
619 desiredValidator?.beaconFeeRecipient !== undefined &&
620 !sameText(
621 (observedValidator?.beaconFeeRecipient ?? "").toLowerCase(),
622 desiredValidator.beaconFeeRecipient.toLowerCase(),
623 );
624 const relaysChanged =
625 desiredValidator?.mevRelayUrls !== undefined &&
626 !sameStringList(
627 observedValidator?.mevRelayUrls,
628 desiredValidator.mevRelayUrls,
629 );
630
631 const mask = fieldMask([
632 labelsChanged && "labels",
633 feeChanged && "ethereum_details.validator_config.beacon_fee_recipient",
634 relaysChanged && "ethereum_details.validator_config.mev_relay_urls",
635 ]);
636
637 if (mask.length > 0) {
638 const operation = yield* bne.patchProjectsLocationsBlockchainNodes({
639 name: latest.name ?? name,
640 updateMask: mask,
641 body: {
642 name: latest.name ?? name,
643 labels: desiredLabels,
644 ethereumDetails:
645 feeChanged || relaysChanged
646 ? {
647 validatorConfig: {
648 beaconFeeRecipient:
649 desiredValidator?.beaconFeeRecipient ??
650 observedValidator?.beaconFeeRecipient,
651 mevRelayUrls:
652 desiredValidator?.mevRelayUrls ??
653 observedValidator?.mevRelayUrls,
654 },
655 }
656 : undefined,
657 },
658 });
659 yield* waitForOperation(operation);
660 latest = yield* waitUntilExists(
661 getByName(latest.name ?? name),
662 latest.name ?? name,
663 );
664 }
665
666 return toAttrs(latest, env.project, env.region);
667 }),
668
669 delete: Effect.fn(function* ({ output }) {
670 const operation = yield* bne
671 .deleteProjectsLocationsBlockchainNodes({
672 name: output.name,
673 })
674 .pipe(
675 Effect.retry({
676 while: (error) => error._tag === "Conflict",
677 times: 8,
678 schedule: Schedule.spaced("5 seconds"),
679 }),
680 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
681 );
682 if (operation !== undefined) {
683 yield* waitForOperation(operation, { notFoundOk: true });
684 }
685 yield* waitUntilGone(getByName(output.name), output.name);
686 }),
687 });