1import * as firebaseappdistribution from "@distilled.cloud/gcp/firebaseappdistribution_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 getGroup,
11 groupIdOf,
12 groupName,
13 hasOwnershipMarker,
14 listGroups,
15 parseDisplayName,
16 parseGroupName,
17 projectParent,
18 replaceOnIdentity,
19 ResourceNotResolved,
20 retryTransient,
21 sameText,
22 toDisplayName,
23 toGroupId,
24} from "./internal.ts";
25
26export type GroupProps = {
27 /**
28 * Group alias (the `{group}` segment of
29 * `projects/{project}/groups/{group}`). 4-63 characters: lowercase
30 * letters, digits, and hyphens. If omitted, a unique id is generated
31 * from the stack, stage, and logical id. Immutable — changing it
32 * replaces the group.
33 */
34 groupId?: string;
35 /**
36 * Display name shown to testers. Defaults to a generated name.
37 */
38 displayName?: string;
39};
40
41export type Group = Resource<
42 "GCP.FirebaseAppDistribution.Group",
43 GroupProps,
44 {
45 /** Full resource name `projects/{project}/groups/{group}`. */
46 name: string;
47 /** Group alias (last path segment). */
48 groupId: string;
49 /** Parent project id. */
50 project: string;
51 /** Parent resource `projects/{project}`. */
52 parent: string;
53 /** Display name (a legacy Alchemy ownership prefix is stripped). */
54 displayName: string | undefined;
55 /** Number of testers who are members of this group. */
56 testerCount: number | undefined;
57 /** Number of invite links for this group. */
58 inviteLinkCount: number | undefined;
59 /** Number of releases this group is permitted to access. */
60 releaseCount: number | undefined;
61 },
62 never,
63 Providers
64>;
65
66/**
67 * A Firebase App Distribution tester group.
68 *
69 * Groups have no labels field, and the display name is shown to testers,
70 * so Alchemy writes no ownership marker: a generated alias identifies the
71 * group, and `read` reports an explicit alias it has no state for as
72 * unowned. `groupId` is identity — changing it
73 * replaces the group. Display name updates in place.
74 *
75 * ### Creating a Group
76 * **Example:** Generated alias
77 * ```typescript
78 * const group = yield* GCP.FirebaseAppDistribution.Group("Qa", {
79 * displayName: "qa",
80 * });
81 * ```
82 *
83 * **Example:** Explicit alias
84 * ```typescript
85 * const group = yield* GCP.FirebaseAppDistribution.Group("Qa", {
86 * groupId: "qa-testers",
87 * displayName: "qa",
88 * });
89 * ```
90 *
91 * ### Updating a Group
92 * **Example:** Rename
93 * ```typescript
94 * const group = yield* GCP.FirebaseAppDistribution.Group("Qa", {
95 * displayName: "qa-prod",
96 * });
97 * ```
98 *
99 * @resource
100 * @category FirebaseAppDistribution
101 */
102export const Group = Resource<Group>("GCP.FirebaseAppDistribution.Group");
103
104const toAttrs = (
105 group: firebaseappdistribution.GoogleFirebaseAppdistroV1Group,
106 project: string,
107): Group["Attributes"] => {
108 const name = group.name ?? "";
109 const parsed = parseGroupName(name, project);
110 return {
111 name,
112 groupId: parsed.groupId,
113 project: parsed.project || project,
114 parent: parsed.parent || projectParent(project),
115 displayName: parseDisplayName(group.displayName).displayName,
116 testerCount: group.testerCount,
117 inviteLinkCount: group.inviteLinkCount,
118 releaseCount: group.releaseCount,
119 };
120};
121
122export const GroupProvider = () =>
123 Provider.succeed(Group, {
124 stables: ["name", "groupId", "project", "parent"],
125
126 diff: Effect.fn(function* ({ news, olds, output }) {
127 if (!isResolved(news)) return undefined;
128 const previous = groupIdOf(olds?.groupId ?? output?.groupId);
129 const next = groupIdOf(news.groupId);
130 return replaceOnIdentity(previous, next);
131 }),
132
133 read: Effect.fn(function* ({ id, olds, output }) {
134 const env = yield* GcpEnvironment.current;
135 const groupId = yield* toGroupId(
136 id,
137 olds?.groupId ?? output?.groupId,
138 output?.name,
139 );
140 const existing = yield* getGroup(
141 output?.name ?? groupName(env.project, groupId),
142 );
143 if (existing === undefined) return undefined;
144 const attrs = toAttrs(existing, env.project);
145 // No labels, and the display name is shown to testers: a generated
146 // alias derives from this stack, stage, logical id and instance; an
147 // explicit alias is only ours when state has it.
148 return output !== undefined || olds?.groupId === undefined
149 ? attrs
150 : Unowned(attrs);
151 }),
152
153 list: () =>
154 Effect.gen(function* () {
155 const env = yield* GcpEnvironment.current;
156 const groups = yield* listGroups(projectParent(env.project));
157 return groups
158 .filter((group) => hasOwnershipMarker(group.displayName))
159 .map((group) => toAttrs(group, env.project));
160 }),
161
162 reconcile: Effect.fn(function* ({ id, news, output }) {
163 const env = yield* GcpEnvironment.current;
164 const parent = projectParent(env.project);
165 const groupId = yield* toGroupId(
166 id,
167 news.groupId,
168 output?.groupId ?? output?.name,
169 );
170 const name = groupName(env.project, groupId);
171 const desiredDisplay = yield* toDisplayName(
172 id,
173 news.displayName,
174 output?.displayName,
175 );
176
177 let current = yield* getGroup(output?.name ?? name);
178
179 if (current === undefined) {
180 const created = yield* retryTransient(
181 firebaseappdistribution.createProjectsGroups({
182 parent,
183 groupId,
184 body: { displayName: desiredDisplay },
185 }),
186 ).pipe(Effect.catchTag("Conflict", () => getGroup(name)));
187 current = created ?? undefined;
188 }
189
190 if (current === undefined) {
191 return yield* new ResourceNotResolved({
192 name: output?.name ?? name,
193 });
194 }
195
196 const currentName = current.name ?? name;
197 if (
198 !sameText(current.displayName, desiredDisplay) &&
199 currentName.length > 0
200 ) {
201 current = yield* retryTransient(
202 firebaseappdistribution.patchProjectsGroups({
203 name: currentName,
204 updateMask: "display_name",
205 body: { displayName: desiredDisplay },
206 }),
207 );
208 }
209
210 return toAttrs(current, env.project);
211 }),
212
213 delete: Effect.fn(function* ({ output }) {
214 if (!output.name) return;
215 yield* retryTransient(
216 firebaseappdistribution.deleteProjectsGroups({
217 name: output.name,
218 }),
219 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
220 }),
221 });