1import * as sasportal from "@distilled.cloud/gcp/sasportal_v1alpha1";
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 {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const MAX_DISPLAY_NAME_LENGTH = 256;
14export const DIRECT_CHILDREN = "DIRECT_CHILDREN";
15
16export const lastSegment = (value: string) => {
17 const trimmed = value.replace(/\/+$/, "");
18 const parts = trimmed.split("/");
19 return parts[parts.length - 1] || trimmed;
20};
21
22export const parentOf = (name: string) => {
23 const parts = name.split("/").filter((part) => part.length > 0);
24 if (parts.length >= 4) return parts.slice(0, -2).join("/");
25 return "";
26};
27
28export const expandName = (value: string, collection: string) => {
29 const trimmed = value.replace(/\/+$/, "").trim();
30 if (trimmed.includes("/")) return trimmed;
31 return `${collection}/${trimmed}`;
32};
33
34export const expandCustomer = (value: string) => expandName(value, "customers");
35
36export const expandNode = (value: string) => expandName(value, "nodes");
37
38export const expandPath = (value: string) => value.replace(/\/+$/, "").trim();
39
40export const sameText = (left: string | undefined, right: string | undefined) =>
41 (left ?? "") === (right ?? "");
42
43export const sameJson = (left: unknown, right: unknown) =>
44 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
45
46export const sameStringList = (
47 left: readonly string[] | undefined,
48 right: readonly string[] | undefined,
49) =>
50 sameJson([...(left ?? [])].slice().sort(), [...(right ?? [])].slice().sort());
51
52export const updateMaskOf = (...fields: Array<string | undefined>) =>
53 fields.filter((field): field is string => field !== undefined).join(",");
54
55export const replaceOnIdentity = (input: {
56 previousId?: string;
57 nextId?: string;
58 previousParent?: string;
59 nextParent?: string;
60 extra?: boolean;
61}) => {
62 if (input.extra === true) {
63 return { action: "replace" as const, deleteFirst: false };
64 }
65 if (
66 input.previousId !== undefined &&
67 input.nextId !== undefined &&
68 input.previousId !== input.nextId
69 ) {
70 return { action: "replace" as const, deleteFirst: false };
71 }
72 if (
73 input.previousParent !== undefined &&
74 input.nextParent !== undefined &&
75 input.previousParent !== input.nextParent
76 ) {
77 return { action: "replace" as const, deleteFirst: false };
78 }
79 return undefined;
80};
81
82const markerOf = (
83 _labels: Record<string, string>,
84 stack: string,
85 stage: string,
86 id: string,
87) =>
88 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
89
90const fitMarker = (labels: Record<string, string>, maxLength: number) => {
91 let stack = labels[alchemyLabelKeys.stack] ?? "x";
92 let stage = labels[alchemyLabelKeys.stage] ?? "x";
93 let id = labels[alchemyLabelKeys.id] ?? "x";
94 let marker = markerOf(labels, stack, stage, id);
95 while (
96 marker.length > maxLength &&
97 (stack.length > 1 || stage.length > 1 || id.length > 1)
98 ) {
99 if (stack.length >= stage.length && stack.length >= id.length) {
100 stack = stack.slice(0, -1);
101 } else if (stage.length >= id.length) {
102 stage = stage.slice(0, -1);
103 } else {
104 id = id.slice(0, -1);
105 }
106 marker = markerOf(labels, stack, stage, id);
107 }
108 return marker.slice(0, maxLength);
109};
110
111export const encodeOwnershipLine = (
112 labels: Record<string, string>,
113 text: string | undefined,
114 maxLength = MAX_DISPLAY_NAME_LENGTH,
115): string => {
116 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
117 if (!trimmed) return fitMarker(labels, maxLength);
118 const minMarker = 24;
119 const reserved = Math.min(
120 trimmed.length + 1,
121 Math.max(0, maxLength - minMarker),
122 );
123 const marker = fitMarker(labels, maxLength - reserved);
124 return `${marker} ${trimmed}`.slice(0, maxLength);
125};
126
127export const parseOwnership = (
128 text: string | undefined,
129): {
130 labels: Record<string, string>;
131 text: string | undefined;
132} => {
133 if (!text?.startsWith("[alchemy ")) {
134 return { labels: {}, text };
135 }
136 const end = text.indexOf("]");
137 if (end < 0) return { labels: {}, text };
138 const labels: Record<string, string> = {};
139 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
140 const eq = part.indexOf("=");
141 if (eq > 0) {
142 labels[part.slice(0, eq)] = part.slice(eq + 1);
143 }
144 }
145 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
146 return { labels, text: rest.length > 0 ? rest : undefined };
147};
148
149export const hasOwnershipMarker = (text: string | undefined) =>
150 Object.keys(parseOwnership(text).labels).some((key) =>
151 key.startsWith("alchemy-"),
152 );
153
154const prefixMatch = (expected: string, observed: string) =>
155 expected === observed ||
156 expected.startsWith(observed) ||
157 observed.startsWith(expected);
158
159export const ownedByAlchemy = (id: string, text: string | undefined) =>
160 Effect.gen(function* () {
161 const expected = yield* createInternalLabels(id);
162 const { labels } = parseOwnership(text);
163 if (!hasOwnershipMarker(text)) return false;
164 const exact = yield* hasAlchemyLabels(id, labels);
165 if (exact) return true;
166 return (
167 prefixMatch(
168 expected[alchemyLabelKeys.stack] ?? "",
169 labels[alchemyLabelKeys.stack] ?? "",
170 ) &&
171 prefixMatch(
172 expected[alchemyLabelKeys.stage] ?? "",
173 labels[alchemyLabelKeys.stage] ?? "",
174 ) &&
175 prefixMatch(
176 expected[alchemyLabelKeys.id] ?? "",
177 labels[alchemyLabelKeys.id] ?? "",
178 )
179 );
180 });
181
182export const ownershipLabels = (id: string) => createInternalLabels(id);
183
184export const toDisplayName = (
185 id: string,
186 requested: string | undefined,
187 existing: string | undefined,
188 maxLength = 40,
189) =>
190 Effect.gen(function* () {
191 if (requested !== undefined && requested.length > 0) return requested;
192 if (existing !== undefined && existing.length > 0) return existing;
193 const generated = yield* createPhysicalName({
194 id,
195 maxLength,
196 lowercase: true,
197 });
198 const next = /^[a-z]/.test(generated)
199 ? generated
200 : `s${generated}`.slice(0, maxLength);
201 return next.length > 0 ? next : "s";
202 });
203
204export const toSerialNumber = (
205 id: string,
206 requested: string | undefined,
207 existing: string | undefined,
208) =>
209 Effect.gen(function* () {
210 if (requested !== undefined && requested.length > 0) return requested;
211 if (existing !== undefined && existing.length > 0) return existing;
212 const generated = yield* createPhysicalName({
213 id,
214 maxLength: 16,
215 lowercase: true,
216 });
217 const serial = generated.replace(/[^a-z0-9]/g, "").slice(0, 16);
218 return serial.length > 0 ? serial.toUpperCase() : "SN1";
219 });
220
221export class DeleteNotConfirmed extends Data.TaggedError(
222 "GCP.SasPortal.DeleteNotConfirmed",
223)<{}> {}
224
225/** Poll until the resource is gone; fails if it is still readable after ~60s. */
226export const waitUntilGone = <A, E, R>(
227 get: Effect.Effect<A | undefined, E, R>,
228) =>
229 get.pipe(
230 Effect.repeat({
231 schedule: Schedule.spaced("2 seconds"),
232 until: (value) => value === undefined,
233 times: 30,
234 }),
235 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
236 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
237 ),
238 );
239
240export const retryDelete = <A, E extends { readonly _tag: string }, R>(
241 effect: Effect.Effect<A, E, R>,
242) =>
243 effect.pipe(
244 Effect.retry({
245 while: (error) => error._tag === "Conflict",
246 times: 8,
247 schedule: Schedule.exponential("250 millis"),
248 }),
249 );
250
251const emptyList = <A>() => Effect.succeed([] as A[]);
252
253const isMissing = <E extends { readonly _tag: string }>(
254 error: E,
255): error is Extract<E, { readonly _tag: "NotFound" }> =>
256 error._tag === "NotFound";
257
258export const collectPages = <
259 Page,
260 Item,
261 E extends { readonly _tag: string },
262 R,
263>(
264 pages: Stream.Stream<Page, E, R>,
265 items: (page: Page) => readonly Item[] | null | undefined,
266) =>
267 pages.pipe(
268 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
269 Stream.runCollect,
270 Effect.map((chunk) => Array.from(chunk) as Item[]),
271 Effect.catchIf(isMissing, () => emptyList<Item>()),
272 );
273
274export const listCustomers = () =>
275 collectPages(
276 sasportal.listCustomers.pages({ pageSize: 100 }),
277 (page) => page.customers,
278 );
279
280export const listCustomerDeployments = (parent: string) =>
281 parent.length === 0
282 ? emptyList<sasportal.SasPortalDeployment>()
283 : collectPages(
284 sasportal.listCustomersDeployments.pages({
285 parent,
286 pageSize: 100,
287 filter: DIRECT_CHILDREN,
288 }),
289 (page) => page.deployments,
290 );
291
292export const listCustomerDevices = (parent: string) =>
293 parent.length === 0
294 ? emptyList<sasportal.SasPortalDevice>()
295 : collectPages(
296 sasportal.listCustomersDevices.pages({
297 parent,
298 pageSize: 1000,
299 }),
300 (page) => page.devices,
301 );
302
303export const listCustomerNodes = (parent: string) =>
304 parent.length === 0
305 ? emptyList<sasportal.SasPortalNode>()
306 : collectPages(
307 sasportal.listCustomersNodes.pages({
308 parent,
309 pageSize: 100,
310 filter: DIRECT_CHILDREN,
311 }),
312 (page) => page.nodes,
313 );
314
315export const listNodeDevices = (parent: string) =>
316 parent.length === 0
317 ? emptyList<sasportal.SasPortalDevice>()
318 : collectPages(
319 sasportal.listNodesDevices.pages({
320 parent,
321 pageSize: 1000,
322 }),
323 (page) => page.devices,
324 );
325
326export const listNodeNodes = (parent: string) =>
327 parent.length === 0
328 ? emptyList<sasportal.SasPortalNode>()
329 : collectPages(
330 sasportal.listNodesNodes.pages({
331 parent,
332 pageSize: 100,
333 filter: DIRECT_CHILDREN,
334 }),
335 (page) => page.nodes,
336 );
337
338export const listCustomerDeploymentDevices = (parent: string) =>
339 parent.length === 0
340 ? emptyList<sasportal.SasPortalDevice>()
341 : collectPages(
342 sasportal.listCustomersDeploymentsDevices.pages({
343 parent,
344 pageSize: 1000,
345 }),
346 (page) => page.devices,
347 );
348
349export const listCustomerNodeDeployments = (parent: string) =>
350 parent.length === 0
351 ? emptyList<sasportal.SasPortalDeployment>()
352 : collectPages(
353 sasportal.listCustomersNodesDeployments.pages({
354 parent,
355 pageSize: 100,
356 filter: DIRECT_CHILDREN,
357 }),
358 (page) => page.deployments,
359 );
360
361export const listCustomerNodeDevices = (parent: string) =>
362 parent.length === 0
363 ? emptyList<sasportal.SasPortalDevice>()
364 : collectPages(
365 sasportal.listCustomersNodesDevices.pages({
366 parent,
367 pageSize: 1000,
368 }),
369 (page) => page.devices,
370 );
371
372export const listCustomerNodeNodes = (parent: string) =>
373 parent.length === 0
374 ? emptyList<sasportal.SasPortalNode>()
375 : collectPages(
376 sasportal.listCustomersNodesNodes.pages({
377 parent,
378 pageSize: 100,
379 filter: DIRECT_CHILDREN,
380 }),
381 (page) => page.nodes,
382 );
383
384export const listNodeDeployments = (parent: string) =>
385 parent.length === 0
386 ? emptyList<sasportal.SasPortalDeployment>()
387 : collectPages(
388 sasportal.listNodesDeployments.pages({
389 parent,
390 pageSize: 100,
391 filter: DIRECT_CHILDREN,
392 }),
393 (page) => page.deployments,
394 );
395
396export const listNodeDeploymentDevices = (parent: string) =>
397 parent.length === 0
398 ? emptyList<sasportal.SasPortalDevice>()
399 : collectPages(
400 sasportal.listNodesDeploymentsDevices.pages({
401 parent,
402 pageSize: 1000,
403 }),
404 (page) => page.devices,
405 );
406
407export const listNodeNodeDeployments = (parent: string) =>
408 parent.length === 0
409 ? emptyList<sasportal.SasPortalDeployment>()
410 : collectPages(
411 sasportal.listNodesNodesDeployments.pages({
412 parent,
413 pageSize: 100,
414 filter: DIRECT_CHILDREN,
415 }),
416 (page) => page.deployments,
417 );
418
419export const listNodeNodeDevices = (parent: string) =>
420 parent.length === 0
421 ? emptyList<sasportal.SasPortalDevice>()
422 : collectPages(
423 sasportal.listNodesNodesDevices.pages({
424 parent,
425 pageSize: 1000,
426 }),
427 (page) => page.devices,
428 );
429
430export const listNodeNodeNodes = (parent: string) =>
431 parent.length === 0
432 ? emptyList<sasportal.SasPortalNode>()
433 : collectPages(
434 sasportal.listNodesNodesNodes.pages({
435 parent,
436 pageSize: 100,
437 filter: DIRECT_CHILDREN,
438 }),
439 (page) => page.nodes,
440 );
441
442export type LocatedNode = {
443 node: sasportal.SasPortalNode;
444 parent: string;
445};
446
447export const walkNodes = () =>
448 Effect.gen(function* () {
449 const customers = yield* listCustomers();
450 const located: LocatedNode[] = [];
451 const seen = new Set<string>();
452 const queue: Array<{ parent: string; underCustomer: boolean }> =
453 customers.flatMap((customer) =>
454 customer.name ? [{ parent: customer.name, underCustomer: true }] : [],
455 );
456 while (queue.length > 0) {
457 const batch = queue.splice(0, queue.length);
458 const pages = yield* Effect.forEach(
459 batch,
460 (item) =>
461 (item.underCustomer
462 ? listCustomerNodes(item.parent)
463 : listNodeNodes(item.parent)
464 ).pipe(
465 Effect.map((nodes) =>
466 nodes.map((node) => ({ node, parent: item.parent })),
467 ),
468 ),
469 { concurrency: 4 },
470 );
471 for (const entry of pages.flat()) {
472 const name = entry.node.name ?? "";
473 if (name.length === 0 || seen.has(name)) continue;
474 seen.add(name);
475 located.push(entry);
476 queue.push({ parent: name, underCustomer: false });
477 }
478 }
479 return located;
480 });
481
482const catchMissing = <A, E extends { readonly _tag: string }, R>(
483 effect: Effect.Effect<A, E, R>,
484) => effect.pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
485
486export const getCustomerDeployment = (name: string) =>
487 name.length === 0
488 ? Effect.succeed(undefined)
489 : catchMissing(sasportal.getCustomersDeployments({ name }));
490
491export const getCustomerDevice = (name: string) =>
492 name.length === 0
493 ? Effect.succeed(undefined)
494 : catchMissing(sasportal.getCustomersDevices({ name }));
495
496export const getCustomerNode = (name: string) =>
497 name.length === 0
498 ? Effect.succeed(undefined)
499 : catchMissing(sasportal.getCustomersNodes({ name }));
500
501export const getNodeDevice = (name: string) =>
502 name.length === 0
503 ? Effect.succeed(undefined)
504 : catchMissing(sasportal.getNodesDevices({ name }));
505
506export const getNodeNode = (name: string) =>
507 name.length === 0
508 ? Effect.succeed(undefined)
509 : catchMissing(sasportal.getNodesNodes({ name }));
510
511export const getDeploymentDevice = (name: string) =>
512 name.length === 0
513 ? Effect.succeed(undefined)
514 : catchMissing(sasportal.getDeploymentsDevices({ name }));
515
516export const getNodeDeployment = (name: string) =>
517 name.length === 0
518 ? Effect.succeed(undefined)
519 : catchMissing(sasportal.getNodesDeployments({ name }));
520
521export const findOwned = <A>(
522 id: string,
523 rows: readonly A[],
524 displayName: (row: A) => string | undefined,
525) =>
526 Effect.gen(function* () {
527 for (const row of rows) {
528 if (yield* ownedByAlchemy(id, displayName(row))) {
529 return row;
530 }
531 }
532 return undefined;
533 });
534
535export const findOwnedCustomerDeployment = (id: string, parent: string) =>
536 Effect.gen(function* () {
537 const rows = yield* listCustomerDeployments(parent);
538 const match = yield* findOwned(id, rows, (row) => row.displayName);
539 return match === undefined ? undefined : { row: match, parent };
540 });
541
542export const findOwnedCustomerDevice = (id: string, parent: string) =>
543 Effect.gen(function* () {
544 const rows = yield* listCustomerDevices(parent);
545 const match = yield* findOwned(id, rows, (row) => row.displayName);
546 return match === undefined ? undefined : { row: match, parent };
547 });
548
549export const findOwnedCustomerNode = (id: string, parent: string) =>
550 Effect.gen(function* () {
551 const rows = yield* listCustomerNodes(parent);
552 const match = yield* findOwned(id, rows, (row) => row.displayName);
553 return match === undefined ? undefined : { row: match, parent };
554 });
555
556export const findOwnedNodeDevice = (id: string, parent: string) =>
557 Effect.gen(function* () {
558 const rows = yield* listNodeDevices(parent);
559 const match = yield* findOwned(id, rows, (row) => row.displayName);
560 return match === undefined ? undefined : { row: match, parent };
561 });
562
563export const findOwnedNodeNode = (id: string, parent: string) =>
564 Effect.gen(function* () {
565 const rows = yield* listNodeNodes(parent);
566 const match = yield* findOwned(id, rows, (row) => row.displayName);
567 return match === undefined ? undefined : { row: match, parent };
568 });
569
570export const findOwnedCustomerDeploymentDevice = (id: string, parent: string) =>
571 Effect.gen(function* () {
572 const rows = yield* listCustomerDeploymentDevices(parent);
573 const match = yield* findOwned(id, rows, (row) => row.displayName);
574 return match === undefined ? undefined : { row: match, parent };
575 });
576
577export const findOwnedCustomerNodeDeployment = (id: string, parent: string) =>
578 Effect.gen(function* () {
579 const rows = yield* listCustomerNodeDeployments(parent);
580 const match = yield* findOwned(id, rows, (row) => row.displayName);
581 return match === undefined ? undefined : { row: match, parent };
582 });
583
584export const findOwnedCustomerNodeDevice = (id: string, parent: string) =>
585 Effect.gen(function* () {
586 const rows = yield* listCustomerNodeDevices(parent);
587 const match = yield* findOwned(id, rows, (row) => row.displayName);
588 return match === undefined ? undefined : { row: match, parent };
589 });
590
591export const findOwnedCustomerNodeNode = (id: string, parent: string) =>
592 Effect.gen(function* () {
593 const rows = yield* listCustomerNodeNodes(parent);
594 const match = yield* findOwned(id, rows, (row) => row.displayName);
595 return match === undefined ? undefined : { row: match, parent };
596 });
597
598export const findOwnedNodeDeployment = (id: string, parent: string) =>
599 Effect.gen(function* () {
600 const rows = yield* listNodeDeployments(parent);
601 const match = yield* findOwned(id, rows, (row) => row.displayName);
602 return match === undefined ? undefined : { row: match, parent };
603 });
604
605export const findOwnedNodeDeploymentDevice = (id: string, parent: string) =>
606 Effect.gen(function* () {
607 const rows = yield* listNodeDeploymentDevices(parent);
608 const match = yield* findOwned(id, rows, (row) => row.displayName);
609 return match === undefined ? undefined : { row: match, parent };
610 });
611
612export const findOwnedNodeNodeDeployment = (id: string, parent: string) =>
613 Effect.gen(function* () {
614 const rows = yield* listNodeNodeDeployments(parent);
615 const match = yield* findOwned(id, rows, (row) => row.displayName);
616 return match === undefined ? undefined : { row: match, parent };
617 });
618
619export const findOwnedNodeNodeDevice = (id: string, parent: string) =>
620 Effect.gen(function* () {
621 const rows = yield* listNodeNodeDevices(parent);
622 const match = yield* findOwned(id, rows, (row) => row.displayName);
623 return match === undefined ? undefined : { row: match, parent };
624 });
625
626export const findOwnedNodeNodeNode = (id: string, parent: string) =>
627 Effect.gen(function* () {
628 const rows = yield* listNodeNodeNodes(parent);
629 const match = yield* findOwned(id, rows, (row) => row.displayName);
630 return match === undefined ? undefined : { row: match, parent };
631 });
632
633export const findDeviceBySerial = (
634 rows: readonly sasportal.SasPortalDevice[],
635 serialNumber: string | undefined,
636) => {
637 if (serialNumber === undefined || serialNumber.length === 0) {
638 return undefined;
639 }
640 return rows.find((row) => row.serialNumber === serialNumber);
641};
642
643export const scanOwnedCustomerDeployment = (id: string, parent?: string) =>
644 Effect.gen(function* () {
645 if (parent !== undefined && parent.length > 0) {
646 const local = yield* findOwnedCustomerDeployment(id, parent);
647 if (local !== undefined) return local;
648 }
649 const customers = yield* listCustomers();
650 for (const customer of customers) {
651 if (!customer.name) continue;
652 const found = yield* findOwnedCustomerDeployment(id, customer.name);
653 if (found !== undefined) return found;
654 }
655 return undefined;
656 });
657
658export const scanOwnedCustomerDevice = (id: string, parent?: string) =>
659 Effect.gen(function* () {
660 if (parent !== undefined && parent.length > 0) {
661 const local = yield* findOwnedCustomerDevice(id, parent);
662 if (local !== undefined) return local;
663 }
664 const customers = yield* listCustomers();
665 for (const customer of customers) {
666 if (!customer.name) continue;
667 const found = yield* findOwnedCustomerDevice(id, customer.name);
668 if (found !== undefined) return found;
669 }
670 return undefined;
671 });
672
673export const scanOwnedCustomerNode = (id: string, parent?: string) =>
674 Effect.gen(function* () {
675 if (parent !== undefined && parent.length > 0) {
676 const local = yield* findOwnedCustomerNode(id, parent);
677 if (local !== undefined) return local;
678 }
679 const customers = yield* listCustomers();
680 for (const customer of customers) {
681 if (!customer.name) continue;
682 const found = yield* findOwnedCustomerNode(id, customer.name);
683 if (found !== undefined) return found;
684 }
685 return undefined;
686 });
687
688export const scanOwnedNodeDevice = (id: string, parent?: string) =>
689 Effect.gen(function* () {
690 if (parent !== undefined && parent.length > 0) {
691 const local = yield* findOwnedNodeDevice(id, parent);
692 if (local !== undefined) return local;
693 }
694 const nodes = yield* walkNodes();
695 for (const entry of nodes) {
696 const name = entry.node.name ?? "";
697 if (name.length === 0) continue;
698 const found = yield* findOwnedNodeDevice(id, name);
699 if (found !== undefined) return found;
700 }
701 return undefined;
702 });
703
704export const scanOwnedNodeNode = (id: string, parent?: string) =>
705 Effect.gen(function* () {
706 if (parent !== undefined && parent.length > 0) {
707 const local = yield* findOwnedNodeNode(id, parent);
708 if (local !== undefined) return local;
709 }
710 const nodes = yield* walkNodes();
711 for (const entry of nodes) {
712 const name = entry.node.name ?? "";
713 if (name.length === 0) continue;
714 const found = yield* findOwnedNodeNode(id, name);
715 if (found !== undefined) return found;
716 }
717 return undefined;
718 });
719
720export type LocatedDeployment = {
721 deployment: sasportal.SasPortalDeployment;
722 parent: string;
723};
724
725export const walkCustomerDeployments = () =>
726 Effect.gen(function* () {
727 const customers = yield* listCustomers();
728 const pages = yield* Effect.forEach(
729 customers,
730 (customer) => {
731 const parent = customer.name ?? "";
732 return parent.length === 0
733 ? Effect.succeed([] as LocatedDeployment[])
734 : listCustomerDeployments(parent).pipe(
735 Effect.map((rows) =>
736 rows.map((deployment) => ({ deployment, parent })),
737 ),
738 );
739 },
740 { concurrency: 4 },
741 );
742 return pages.flat();
743 });
744
745export const walkCustomerNodes = () =>
746 Effect.gen(function* () {
747 const customers = yield* listCustomers();
748 const pages = yield* Effect.forEach(
749 customers,
750 (customer) => {
751 const parent = customer.name ?? "";
752 return parent.length === 0
753 ? Effect.succeed([] as LocatedNode[])
754 : listCustomerNodes(parent).pipe(
755 Effect.map((rows) => rows.map((node) => ({ node, parent }))),
756 );
757 },
758 { concurrency: 4 },
759 );
760 return pages.flat();
761 });
762
763export const walkNodeDeployments = () =>
764 Effect.gen(function* () {
765 const nodes = yield* walkNodes();
766 const pages = yield* Effect.forEach(
767 nodes,
768 (entry) => {
769 const parent = entry.node.name ?? "";
770 return parent.length === 0
771 ? Effect.succeed([] as LocatedDeployment[])
772 : listNodeDeployments(parent).pipe(
773 Effect.map((rows) =>
774 rows.map((deployment) => ({ deployment, parent })),
775 ),
776 );
777 },
778 { concurrency: 4 },
779 );
780 return pages.flat();
781 });
782
783export const walkNodeChildren = () =>
784 Effect.gen(function* () {
785 const nodes = yield* walkNodes();
786 const pages = yield* Effect.forEach(
787 nodes,
788 (entry) => {
789 const parent = entry.node.name ?? "";
790 return parent.length === 0
791 ? Effect.succeed([] as LocatedNode[])
792 : listNodeNodes(parent).pipe(
793 Effect.map((rows) => rows.map((node) => ({ node, parent }))),
794 );
795 },
796 { concurrency: 4 },
797 );
798 return pages.flat();
799 });
800
801const scanFirst = <A, E, R>(
802 parent: string | undefined,
803 local: (parent: string) => Effect.Effect<A | undefined, E, R>,
804 walk: Effect.Effect<ReadonlyArray<{ name: string }>, E, R>,
805) =>
806 Effect.gen(function* () {
807 if (parent !== undefined && parent.length > 0) {
808 const found = yield* local(parent);
809 if (found !== undefined) return found;
810 }
811 const rows = yield* walk;
812 for (const row of rows) {
813 if (row.name.length === 0) continue;
814 const found = yield* local(row.name);
815 if (found !== undefined) return found;
816 }
817 return undefined;
818 });
819
820export const scanOwnedCustomerDeploymentDevice = (
821 id: string,
822 parent?: string,
823) =>
824 scanFirst(
825 parent,
826 (next) => findOwnedCustomerDeploymentDevice(id, next),
827 walkCustomerDeployments().pipe(
828 Effect.map((rows) =>
829 rows.map((entry) => ({ name: entry.deployment.name ?? "" })),
830 ),
831 ),
832 );
833
834export const scanOwnedCustomerNodeDeployment = (id: string, parent?: string) =>
835 scanFirst(
836 parent,
837 (next) => findOwnedCustomerNodeDeployment(id, next),
838 walkCustomerNodes().pipe(
839 Effect.map((rows) =>
840 rows.map((entry) => ({ name: entry.node.name ?? "" })),
841 ),
842 ),
843 );
844
845export const scanOwnedCustomerNodeDevice = (id: string, parent?: string) =>
846 scanFirst(
847 parent,
848 (next) => findOwnedCustomerNodeDevice(id, next),
849 walkCustomerNodes().pipe(
850 Effect.map((rows) =>
851 rows.map((entry) => ({ name: entry.node.name ?? "" })),
852 ),
853 ),
854 );
855
856export const scanOwnedCustomerNodeNode = (id: string, parent?: string) =>
857 scanFirst(
858 parent,
859 (next) => findOwnedCustomerNodeNode(id, next),
860 walkCustomerNodes().pipe(
861 Effect.map((rows) =>
862 rows.map((entry) => ({ name: entry.node.name ?? "" })),
863 ),
864 ),
865 );
866
867export const scanOwnedNodeDeployment = (id: string, parent?: string) =>
868 scanFirst(
869 parent,
870 (next) => findOwnedNodeDeployment(id, next),
871 walkNodes().pipe(
872 Effect.map((rows) =>
873 rows.map((entry) => ({ name: entry.node.name ?? "" })),
874 ),
875 ),
876 );
877
878export const scanOwnedNodeDeploymentDevice = (id: string, parent?: string) =>
879 scanFirst(
880 parent,
881 (next) => findOwnedNodeDeploymentDevice(id, next),
882 walkNodeDeployments().pipe(
883 Effect.map((rows) =>
884 rows.map((entry) => ({ name: entry.deployment.name ?? "" })),
885 ),
886 ),
887 );
888
889export const scanOwnedNodeNodeDeployment = (id: string, parent?: string) =>
890 scanFirst(
891 parent,
892 (next) => findOwnedNodeNodeDeployment(id, next),
893 walkNodeChildren().pipe(
894 Effect.map((rows) =>
895 rows.map((entry) => ({ name: entry.node.name ?? "" })),
896 ),
897 ),
898 );
899
900export const scanOwnedNodeNodeDevice = (id: string, parent?: string) =>
901 scanFirst(
902 parent,
903 (next) => findOwnedNodeNodeDevice(id, next),
904 walkNodeChildren().pipe(
905 Effect.map((rows) =>
906 rows.map((entry) => ({ name: entry.node.name ?? "" })),
907 ),
908 ),
909 );
910
911export const scanOwnedNodeNodeNode = (id: string, parent?: string) =>
912 scanFirst(
913 parent,
914 (next) => findOwnedNodeNodeNode(id, next),
915 walkNodeChildren().pipe(
916 Effect.map((rows) =>
917 rows.map((entry) => ({ name: entry.node.name ?? "" })),
918 ),
919 ),
920 );
921
922export type FrequencyRange = {
923 /** Lowest frequency of the range in MHz. */
924 lowFrequencyMhz?: number;
925 /** Highest frequency of the range in MHz. */
926 highFrequencyMhz?: number;
927};
928
929export type InstallationParams = {
930 height?: number;
931 horizontalAccuracy?: number;
932 antennaGain?: number;
933 antennaAzimuth?: number;
934 verticalAccuracy?: number;
935 latitude?: number;
936 heightType?: string;
937 indoorDeployment?: boolean;
938 eirpCapability?: number;
939 antennaModel?: string;
940 longitude?: number;
941 antennaDowntilt?: number;
942 antennaBeamwidth?: number;
943 cpeCbsdIndication?: boolean;
944};
945
946export type DeviceModel = {
947 name?: string;
948 softwareVersion?: string;
949 firmwareVersion?: string;
950 hardwareVersion?: string;
951 vendor?: string;
952};
953
954export type DeviceAirInterface = {
955 supportedSpec?: string;
956 radioTechnology?: string;
957};
958
959export type DeviceConfig = {
960 state?: string;
961 installationParams?: InstallationParams;
962 measurementCapabilities?: string[];
963 model?: DeviceModel;
964 airInterface?: DeviceAirInterface;
965 callSign?: string;
966 category?: string;
967 userId?: string;
968};
969
970export type DeviceMetadata = {
971 interferenceCoordinationGroup?: string;
972 commonChannelGroup?: string;
973 antennaModel?: string;
974};
975
976export type DeviceGrant = {
977 grantId?: string;
978 maxEirp?: number;
979 channelType?: string;
980 expireTime?: string;
981 lastHeartbeatTransmitExpireTime?: string;
982 suspensionReason?: string[];
983 frequencyRange?: FrequencyRange;
984 state?: string;
985};
986
987export const frequencyRangesOf = (
988 ranges: sasportal.SasPortalFrequencyRangeList | undefined,
989): FrequencyRange[] | undefined => {
990 if (ranges === undefined) return undefined;
991 return ranges.map((range) => ({
992 lowFrequencyMhz: range.lowFrequencyMhz,
993 highFrequencyMhz: range.highFrequencyMhz,
994 }));
995};
996
997export const deviceConfigOf = (
998 config: sasportal.SasPortalDeviceConfig | undefined,
999): DeviceConfig | undefined => {
1000 if (config === undefined) return undefined;
1001 return {
1002 state: config.state,
1003 installationParams: config.installationParams,
1004 measurementCapabilities: config.measurementCapabilities,
1005 model: config.model,
1006 airInterface: config.airInterface,
1007 callSign: config.callSign,
1008 category: config.category,
1009 userId: config.userId,
1010 };
1011};
1012
1013export const deviceMetadataOf = (
1014 metadata: sasportal.SasPortalDeviceMetadata | undefined,
1015): DeviceMetadata | undefined => {
1016 if (metadata === undefined) return undefined;
1017 return {
1018 interferenceCoordinationGroup: metadata.interferenceCoordinationGroup,
1019 commonChannelGroup: metadata.commonChannelGroup,
1020 antennaModel: metadata.antennaModel,
1021 };
1022};
1023
1024export const deviceGrantsOf = (
1025 grants: sasportal.SasPortalDeviceGrantList | undefined,
1026): DeviceGrant[] | undefined => {
1027 if (grants === undefined) return undefined;
1028 return grants.map((grant) => ({
1029 grantId: grant.grantId,
1030 maxEirp: grant.maxEirp,
1031 channelType: grant.channelType,
1032 expireTime: grant.expireTime,
1033 lastHeartbeatTransmitExpireTime: grant.lastHeartbeatTransmitExpireTime,
1034 suspensionReason: grant.suspensionReason,
1035 frequencyRange: grant.frequencyRange,
1036 state: grant.state,
1037 }));
1038};
1039
1040export const deviceBody = (input: {
1041 displayName: string;
1042 serialNumber?: string;
1043 fccId?: string;
1044 grantRangeAllowlists?: FrequencyRange[];
1045 preloadedConfig?: DeviceConfig;
1046 deviceMetadata?: DeviceMetadata;
1047}): sasportal.SasPortalDevice => ({
1048 displayName: input.displayName,
1049 serialNumber: input.serialNumber,
1050 fccId: input.fccId,
1051 grantRangeAllowlists: input.grantRangeAllowlists,
1052 preloadedConfig: input.preloadedConfig,
1053 deviceMetadata: input.deviceMetadata,
1054});
1055
1056export const signedDeviceBody = (input: {
1057 encodedDevice: string;
1058 installerId: string;
1059}): sasportal.SasPortalCreateSignedDeviceRequest => ({
1060 encodedDevice: input.encodedDevice,
1061 installerId: input.installerId,
1062});
1063
1064export const decodeSignedDevice = (encodedDevice: string) =>
1065 Effect.sync(() => {
1066 const parts = encodedDevice.split(".");
1067 if (parts.length < 2) return undefined;
1068 try {
1069 const json = Buffer.from(parts[1]!, "base64url").toString("utf8");
1070 return JSON.parse(json) as sasportal.SasPortalDevice;
1071 } catch {
1072 return undefined;
1073 }
1074 });