1import * as firebase from "@distilled.cloud/gcp/firebase_v1beta1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import {
8 alchemyLabelKeys,
9 createInternalLabels,
10 hasAlchemyLabels,
11} from "../Labels.ts";
12
13export const MAX_DISPLAY_NAME = 32;
14export const MAX_PACKAGE_SUFFIX = 40;
15
16export class ResourceNotResolved extends Data.TaggedError(
17 "GCP.Firebase.ResourceNotResolved",
18)<{
19 name: string;
20}> {}
21
22export const lastSegment = (value: string) => {
23 const trimmed = value.replace(/\/+$/, "");
24 const parts = trimmed.split("/");
25 return parts[parts.length - 1] || trimmed;
26};
27
28export const projectParent = (project: string) => `projects/${project}`;
29
30export const encodeDisplayName = (
31 labels: Record<string, string>,
32 displayName: string | undefined,
33): string => {
34 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
35 const combined =
36 displayName && displayName.length > 0 ? `${marker} ${displayName}` : marker;
37 return combined.slice(0, 1024);
38};
39
40export const parseDisplayName = (
41 displayName: string | undefined,
42): {
43 labels: Record<string, string>;
44 displayName: string | undefined;
45} => {
46 if (!displayName?.startsWith("[alchemy ")) {
47 return { labels: {}, displayName };
48 }
49 const end = displayName.indexOf("]");
50 if (end < 0) return { labels: {}, displayName };
51 const labels: Record<string, string> = {};
52 for (const part of displayName.slice("[alchemy ".length, end).split(/\s+/)) {
53 const eq = part.indexOf("=");
54 if (eq > 0) {
55 labels[part.slice(0, eq)] = part.slice(eq + 1);
56 }
57 }
58 const rest = displayName.slice(end + 1).trim();
59 return { labels, displayName: rest.length > 0 ? rest : undefined };
60};
61
62export const hasOwnershipMarker = (displayName: string | undefined) =>
63 Object.keys(parseDisplayName(displayName).labels).some((key) =>
64 key.startsWith("alchemy-"),
65 );
66
67export const ownedByAlchemy = (id: string, displayName: string | undefined) =>
68 Effect.gen(function* () {
69 const { labels } = parseDisplayName(displayName);
70 return yield* hasAlchemyLabels(id, labels);
71 });
72
73export const toDisplayName = (
74 id: string,
75 explicit: string | undefined,
76 existing?: string,
77) =>
78 Effect.gen(function* () {
79 if (explicit !== undefined) return explicit;
80 if (existing !== undefined) return existing;
81 return yield* createPhysicalName({
82 id,
83 maxLength: MAX_DISPLAY_NAME,
84 lowercase: true,
85 });
86 });
87
88export const ownedDisplayName = (
89 id: string,
90 requested: string | undefined,
91 existing: string | undefined,
92) =>
93 Effect.gen(function* () {
94 const labels = yield* createInternalLabels(id);
95 const user = yield* toDisplayName(id, requested, existing);
96 return encodeDisplayName(labels, user);
97 });
98
99export const packageNameOf = (
100 id: string,
101 requested: string | undefined,
102 existing?: string,
103) =>
104 Effect.gen(function* () {
105 if (requested !== undefined) return requested;
106 if (existing !== undefined) return existing;
107 const suffix = yield* createPhysicalName({
108 id,
109 maxLength: MAX_PACKAGE_SUFFIX,
110 lowercase: true,
111 });
112 const cleaned = suffix.replace(/[^a-z0-9]/g, "");
113 return `com.alchemy.test.${cleaned || "app"}`;
114 });
115
116/**
117 * Wait for a Firebase Management operation and return its final state
118 * (the created app name is in `response.name`). An operation that
119 * finished with `ALREADY_EXISTS` (6) is a lost create race. A freshly
120 * started operation can briefly read as NOT_FOUND; keep polling.
121 */
122export const waitForOperation = (operation: firebase.Operation) => {
123 let latest = operation;
124 return waitForGcpOperation(
125 operation,
126 (name) =>
127 firebase.getOperations({ name }).pipe(
128 Effect.catchTag("NotFound", () =>
129 Effect.succeed<firebase.Operation>({ name, done: false }),
130 ),
131 Effect.tap((current) =>
132 Effect.sync(() => {
133 latest = current;
134 }),
135 ),
136 ),
137 { budget: "5 minutes" },
138 ).pipe(
139 Effect.map(() => latest),
140 Effect.catchIf(
141 (error) => error._tag === "GCP.OperationFailed" && error.code === 6,
142 () => Effect.succeed(latest),
143 ),
144 );
145};
146
147export const listAndroidApps = (project: string, showDeleted = false) =>
148 firebase.listProjectsAndroidApps
149 .pages({
150 parent: projectParent(project),
151 pageSize: 100,
152 showDeleted,
153 })
154 .pipe(
155 Stream.flatMap((page) => Stream.fromIterable(page.apps ?? [])),
156 Stream.runCollect,
157 Effect.map((chunk) => Array.from(chunk)),
158 Effect.catchTag("NotFound", () =>
159 Effect.succeed([] as firebase.AndroidApp[]),
160 ),
161 );