1import * as bne from "@distilled.cloud/gcp/blockchainnodeengine_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 { stripInternalLabels } from "../Labels.ts";
9import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
10
11export const DEFAULT_BLOCKCHAIN_TYPE = "ETHEREUM";
12export const DEFAULT_NETWORK = "TESTNET_SEPOLIA";
13export const DEFAULT_NODE_TYPE = "FULL";
14export const DEFAULT_EXECUTION_CLIENT = "GETH";
15export const DEFAULT_CONSENSUS_CLIENT = "LIGHTHOUSE";
16export const MAX_NAME_LENGTH = 63;
17export const COLLECTION = "blockchainNodes";
18
19export class ResourceNotResolved extends Data.TaggedError(
20 "GCP.BlockchainNodeEngine.NotResolved",
21)<{
22 name: string;
23}> {}
24
25export class ResourceStillExists extends Data.TaggedError(
26 "GCP.BlockchainNodeEngine.StillExists",
27)<{
28 name: string;
29}> {}
30
31export class ResourceNotReady extends Data.TaggedError(
32 "GCP.BlockchainNodeEngine.NotReady",
33)<{
34 name: string;
35 state: string;
36}> {}
37
38export class ResourceFailed extends Data.TaggedError(
39 "GCP.BlockchainNodeEngine.Failed",
40)<{
41 name: string;
42 state: string;
43 message: string | undefined;
44}> {}
45
46export const lastSegment = (value: string | undefined) => {
47 if (value === undefined || value.length === 0) return "";
48 const trimmed = value.replace(/\/+$/, "");
49 const parts = trimmed.split("/");
50 return parts[parts.length - 1] || trimmed;
51};
52
53export const rfc1035 = (name: string, fallback = "node"): string => {
54 let next = name
55 .toLowerCase()
56 .replace(/[^a-z0-9-]/g, "-")
57 .replace(/-+/g, "-")
58 .replace(/^-+|-+$/g, "");
59 if (!/^[a-z]/.test(next)) next = `n${next}`;
60 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
61 if (next.length === 0) return fallback;
62 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
63 return next.slice(0, MAX_NAME_LENGTH);
64};
65
66export const normalizeLocation = (
67 location: string | undefined,
68 defaultLocation: string,
69) => lastSegment(location ?? defaultLocation).toLowerCase();
70
71export const normalizeEnum = (value: string | undefined, fallback: string) => {
72 const next = (value ?? fallback).toUpperCase();
73 return next.length === 0 || next.endsWith("_UNSPECIFIED") ? fallback : next;
74};
75
76export const parentOf = (project: string, location: string) =>
77 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
78
79export const resourceName = (
80 project: string,
81 location: string,
82 blockchainNodeId: string,
83) => `${parentOf(project, location)}/${COLLECTION}/${blockchainNodeId}`;
84
85export const toPhysicalId = (
86 id: string,
87 explicit: string | undefined,
88 existing: string | undefined,
89 fallback = "node",
90) =>
91 Effect.gen(function* () {
92 if (explicit !== undefined) return rfc1035(explicit, fallback);
93 if (existing !== undefined) return existing;
94 return rfc1035(
95 yield* createPhysicalName({
96 id,
97 maxLength: MAX_NAME_LENGTH,
98 lowercase: true,
99 }),
100 fallback,
101 );
102 });
103
104export const parseName = (name: string, defaultLocation: string) => {
105 const parts = name.split("/").filter((part) => part.length > 0);
106 const collectionAt = parts.lastIndexOf(COLLECTION);
107 const locationsAt = parts.lastIndexOf("locations");
108 const projectsAt = parts.lastIndexOf("projects");
109 return {
110 project:
111 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
112 location:
113 locationsAt >= 0 && parts[locationsAt + 1]
114 ? parts[locationsAt + 1]!
115 : defaultLocation,
116 id:
117 collectionAt >= 0 && parts[collectionAt + 1]
118 ? parts[collectionAt + 1]!
119 : lastSegment(name),
120 };
121};
122
123export const userLabels = (
124 labels: Record<string, string | undefined> | null | undefined,
125): Record<string, string> => stripInternalLabels(tagRecord(labels));
126
127export const hasAlchemyLabelMap = (
128 labels: Record<string, string | undefined> | null | undefined,
129) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
130
131export const canonical = (value: unknown): unknown => {
132 if (value === undefined || value === null) return undefined;
133 if (typeof value === "boolean" || typeof value === "number") return value;
134 if (typeof value === "string") {
135 return value.length === 0 ? undefined : value;
136 }
137 if (Array.isArray(value)) {
138 const items = value.map(canonical).filter((item) => item !== undefined);
139 return items.length === 0 ? undefined : items;
140 }
141 if (typeof value === "object") {
142 const entries = Object.entries(value as Record<string, unknown>)
143 .map(([key, item]) => [key, canonical(item)] as const)
144 .filter(([, item]) => item !== undefined)
145 .sort(([left], [right]) => left.localeCompare(right));
146 if (entries.length === 0) return undefined;
147 return Object.fromEntries(entries);
148 }
149 return undefined;
150};
151
152export const fingerprint = (value: unknown): string =>
153 JSON.stringify(canonical(value) ?? null);
154
155export const fieldMask = (fields: Array<string | false | undefined>) =>
156 fields
157 .filter((field): field is string => typeof field === "string")
158 .join(",");
159
160export const sameText = (left: string | undefined, right: string | undefined) =>
161 (left ?? "") === (right ?? "");
162
163export const sameEnum = (left: string | undefined, right: string | undefined) =>
164 (left ?? "").toUpperCase() === (right ?? "").toUpperCase();
165
166export const sameStringList = (
167 left: readonly string[] | undefined,
168 right: readonly string[] | undefined,
169) =>
170 fingerprint([...(left ?? [])].map((value) => value.toLowerCase()).sort()) ===
171 fingerprint([...(right ?? [])].map((value) => value.toLowerCase()).sort());
172
173export const replaceOnIdentity = (input: {
174 previousId: string | undefined;
175 nextId: string | undefined;
176 previousLocation: string;
177 nextLocation: string;
178 extra?: boolean;
179}) => {
180 const replace =
181 (input.extra ?? false) ||
182 (input.previousId !== undefined &&
183 input.nextId !== undefined &&
184 input.nextId !== input.previousId) ||
185 input.previousLocation !== input.nextLocation;
186 if (!replace) return undefined;
187 const samePhysical =
188 input.previousLocation === input.nextLocation &&
189 input.previousId !== undefined &&
190 input.nextId === input.previousId;
191 return {
192 action: "replace" as const,
193 deleteFirst: samePhysical,
194 };
195};
196
197const READY_STATES = new Set(["RUNNING", "SYNCING"]);
198const FAILED_STATES = new Set(["ERROR", "DELETING"]);
199
200export const isReadyState = (state: string | undefined) => {
201 const value = (state ?? "").toUpperCase();
202 return value.length === 0 || READY_STATES.has(value);
203};
204
205export const isFailedState = (state: string | undefined) =>
206 FAILED_STATES.has((state ?? "").toUpperCase());
207
208/**
209 * Wait for a Blockchain Node Engine operation; node creation takes up to
210 * ~30 minutes. ALREADY_EXISTS (code 6) counts as success (create race);
211 * with `notFoundOk`, so does NOT_FOUND (code 5, delete race).
212 */
213export const waitForOperation = (
214 operation: bne.Operation,
215 options?: { notFoundOk?: boolean },
216) =>
217 waitForGcpOperation(
218 operation,
219 (name) => bne.getProjectsLocationsOperations({ name }),
220 { budget: "45 minutes", interval: "10 seconds" },
221 ).pipe(
222 Effect.catchIf(
223 (error) =>
224 error._tag === "GCP.OperationFailed" &&
225 (error.code === 6 ||
226 (options?.notFoundOk === true && error.code === 5)),
227 () => Effect.succeed(operation),
228 ),
229 );
230
231export const waitUntilExists = <A, E, R>(
232 get: Effect.Effect<A | undefined, E, R>,
233 name: string,
234): Effect.Effect<NonNullable<A>, E | ResourceNotResolved, R> =>
235 get.pipe(
236 Effect.filterOrFail(
237 (value): value is NonNullable<A> => value != null,
238 () => new ResourceNotResolved({ name }),
239 ),
240 Effect.retry({
241 while: (error) => error instanceof ResourceNotResolved,
242 times: 8,
243 schedule: Schedule.spaced("1 second"),
244 }),
245 );
246
247export const waitUntilGone = <A, E, R>(
248 get: Effect.Effect<A | undefined, E, R>,
249 name: string,
250): Effect.Effect<void, E | ResourceStillExists, R> =>
251 get.pipe(
252 Effect.filterOrFail(
253 (value) => value === undefined,
254 () => new ResourceStillExists({ name }),
255 ),
256 Effect.retry({
257 while: (error) => error instanceof ResourceStillExists,
258 times: 10,
259 schedule: Schedule.spaced("8 seconds"),
260 }),
261 Effect.asVoid,
262 );
263
264export const waitUntilReady = <A, E, R>(
265 get: Effect.Effect<A | undefined, E, R>,
266 name: string,
267 stateOf: (value: NonNullable<A>) => string | undefined,
268 messageOf?: (value: NonNullable<A>) => string | undefined,
269): Effect.Effect<
270 NonNullable<A>,
271 E | ResourceNotResolved | ResourceFailed | ResourceNotReady,
272 R
273> =>
274 get.pipe(
275 Effect.filterOrFail(
276 (value): value is NonNullable<A> => value != null,
277 () => new ResourceNotResolved({ name }),
278 ),
279 Effect.filterOrFail(
280 (value) => !isFailedState(stateOf(value)),
281 (value) =>
282 new ResourceFailed({
283 name,
284 state: stateOf(value) ?? "",
285 message: messageOf?.(value),
286 }),
287 ),
288 Effect.filterOrFail(
289 (value) => isReadyState(stateOf(value)),
290 (value) =>
291 new ResourceNotReady({
292 name,
293 state: stateOf(value) ?? "",
294 }),
295 ),
296 Effect.retry({
297 while: (error) =>
298 error instanceof ResourceNotReady ||
299 error instanceof ResourceNotResolved,
300 times: 10,
301 schedule: Schedule.spaced("8 seconds"),
302 }),
303 );
304
305export const listAtLocation = <A, E, R>(
306 project: string,
307 region: string,
308 list: (parent: string) => Effect.Effect<A[], E, R>,
309): Effect.Effect<A[], E, R> =>
310 // Prefer the all-locations wildcard; fall back to the default region
311 // (whose error, if any, propagates).
312 list(`projects/${project}/locations/-`).pipe(
313 Effect.catch(() => list(`projects/${project}/locations/${region}`)),
314 );
315
316export const listLabeledPages = <Page, A, E, R>(
317 pages: Stream.Stream<Page, E, R>,
318 items: (page: Page) => readonly A[] | undefined,
319 labelsOf: (item: A) => Record<string, string | undefined> | null | undefined,
320): Effect.Effect<A[], E, R> =>
321 pages.pipe(
322 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
323 Stream.filter((item) => hasAlchemyLabelMap(labelsOf(item))),
324 Stream.runCollect,
325 Effect.map((chunk) => Array.from(chunk)),
326 );