1import * as firebaseappdistribution from "@distilled.cloud/gcp/firebaseappdistribution_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 { isTransientGcpError } from "../Errors.ts";
8
9export const MAX_DISPLAY_NAME = 1024;
10export const MAX_GROUP_ID = 63;
11export const MIN_GROUP_ID = 4;
12
13export class ResourceNotResolved extends Data.TaggedError(
14 "GCP.FirebaseAppDistribution.ResourceNotResolved",
15)<{
16 name: string;
17}> {}
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const projectParent = (project: string) =>
26 project.startsWith("projects/") ? project : `projects/${project}`;
27
28export const groupName = (project: string, groupId: string) =>
29 `${projectParent(project)}/groups/${groupId}`;
30
31export const parseGroupName = (
32 name: string,
33 fallbackProject = "",
34): { project: string; groupId: string; parent: string } => {
35 const parts = name.split("/").filter((part) => part.length > 0);
36 const projectsAt = parts.lastIndexOf("projects");
37 const groupsAt = parts.lastIndexOf("groups");
38 const project =
39 projectsAt >= 0 && parts[projectsAt + 1]
40 ? parts[projectsAt + 1]!
41 : fallbackProject;
42 return {
43 project,
44 groupId:
45 groupsAt >= 0 && parts[groupsAt + 1]
46 ? parts[groupsAt + 1]!
47 : lastSegment(name),
48 parent: project.length > 0 ? projectParent(project) : "",
49 };
50};
51
52export const groupIdOf = (value: string | undefined) => {
53 if (value === undefined) return undefined;
54 const trimmed = value.replace(/\/+$/, "").trim();
55 if (trimmed.length === 0) return undefined;
56 return lastSegment(trimmed);
57};
58
59export const sanitizeGroupId = (value: string) => {
60 let cleaned = value
61 .toLowerCase()
62 .replace(/[^a-z0-9-]/g, "-")
63 .replace(/-+/g, "-")
64 .replace(/^-+|-+$/g, "");
65 if (cleaned.length === 0) cleaned = "group";
66 if (!/^[a-z]/.test(cleaned)) cleaned = `g${cleaned}`;
67 if (cleaned.length < MIN_GROUP_ID) {
68 cleaned = `${cleaned}${"xxxx".slice(0, MIN_GROUP_ID - cleaned.length)}`;
69 }
70 cleaned = cleaned.slice(0, MAX_GROUP_ID).replace(/-+$/g, "");
71 if (cleaned.length < MIN_GROUP_ID) cleaned = "group";
72 return cleaned;
73};
74
75export const toGroupId = (
76 id: string,
77 requested: string | undefined,
78 existing?: string,
79) =>
80 Effect.gen(function* () {
81 const explicit = groupIdOf(requested);
82 if (explicit !== undefined) return sanitizeGroupId(explicit);
83 const previous = groupIdOf(existing);
84 if (previous !== undefined) return sanitizeGroupId(previous);
85 const generated = yield* createPhysicalName({
86 id,
87 maxLength: MAX_GROUP_ID,
88 lowercase: true,
89 });
90 return sanitizeGroupId(generated);
91 });
92
93export const parseDisplayName = (
94 displayName: string | undefined,
95): {
96 labels: Record<string, string>;
97 displayName: string | undefined;
98} => {
99 if (!displayName?.startsWith("[alchemy ")) {
100 return { labels: {}, displayName };
101 }
102 const end = displayName.indexOf("]");
103 if (end < 0) return { labels: {}, displayName };
104 const labels: Record<string, string> = {};
105 for (const part of displayName.slice("[alchemy ".length, end).split(/\s+/)) {
106 const eq = part.indexOf("=");
107 if (eq > 0) {
108 labels[part.slice(0, eq)] = part.slice(eq + 1);
109 }
110 }
111 const rest = displayName.slice(end + 1).trim();
112 return { labels, displayName: rest.length > 0 ? rest : undefined };
113};
114
115export const hasOwnershipMarker = (displayName: string | undefined) =>
116 Object.keys(parseDisplayName(displayName).labels).some((key) =>
117 key.startsWith("alchemy-"),
118 );
119
120export const toDisplayName = (
121 id: string,
122 explicit: string | undefined,
123 existing?: string,
124) =>
125 Effect.gen(function* () {
126 if (explicit !== undefined && explicit.length > 0) return explicit;
127 if (existing !== undefined && existing.length > 0) return existing;
128 return yield* createPhysicalName({
129 id,
130 maxLength: 40,
131 lowercase: true,
132 });
133 });
134
135export const sameText = (left: string | undefined, right: string | undefined) =>
136 (left ?? "") === (right ?? "");
137
138export const replaceOnIdentity = (
139 previous: string | undefined,
140 next: string | undefined,
141) =>
142 previous !== undefined && next !== undefined && previous !== next
143 ? ({ action: "replace" as const, deleteFirst: true } as const)
144 : undefined;
145
146export const retryTransient = <A, E extends { readonly _tag: string }, R>(
147 effect: Effect.Effect<A, E, R>,
148) =>
149 effect.pipe(
150 Effect.retry({
151 while: isTransientGcpError,
152 times: 8,
153 schedule: Schedule.exponential("250 millis"),
154 }),
155 );
156
157export const getGroup = (name: string) =>
158 name.length === 0
159 ? Effect.succeed(undefined)
160 : firebaseappdistribution
161 .getProjectsGroups({ name })
162 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
163
164export const listGroups = (parent: string) =>
165 parent.length === 0
166 ? Effect.succeed(
167 [] as firebaseappdistribution.GoogleFirebaseAppdistroV1Group[],
168 )
169 : firebaseappdistribution.listProjectsGroups
170 .pages({
171 parent,
172 pageSize: 100,
173 })
174 .pipe(
175 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
176 Stream.runCollect,
177 Effect.map((chunk) => Array.from(chunk)),
178 Effect.catchTag("NotFound", () =>
179 Effect.succeed(
180 [] as firebaseappdistribution.GoogleFirebaseAppdistroV1Group[],
181 ),
182 ),
183 );