1import * as dataform from "@distilled.cloud/gcp/dataform_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 deleteTeamFolderTree,
13 encodeOwnershipLine,
14 hasOwnershipMarker,
15 listTeamFolders,
16 locationParent,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 retryTransient,
23 sameText,
24 updateMaskOf,
25 waitUntilGone,
26} from "./internal.ts";
27
28export type TeamProps = {
29 /**
30 * Region (`us-central1`, …). Immutable — changing it replaces the team
31 * folder. `US-CENTRAL1` is accepted and normalized to `us-central1`.
32 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
33 */
34 location?: string;
35 /**
36 * User-friendly display name. Required by the API. Team folders have no
37 * labels field, so Alchemy stamps ownership into a `[alchemy …]`
38 * prefix and strips it from attributes. If omitted, only the ownership
39 * marker is stored.
40 */
41 displayName?: string;
42};
43
44export type Team = Resource<
45 "GCP.Dataform.Team",
46 TeamProps,
47 {
48 /** Full resource name `projects/{project}/locations/{location}/teamFolders/{teamFolder}`. */
49 name: string;
50 /** Team folder id (last path segment). */
51 teamFolderId: string;
52 /** Project id. */
53 project: string;
54 /** Location id. */
55 location: string;
56 /** User display name with the Alchemy ownership prefix stripped. */
57 displayName: string | undefined;
58 /** Creator IAM principal. */
59 creatorIamPrincipal: string | undefined;
60 /** RFC3339 creation timestamp. */
61 createTime: string | undefined;
62 /** RFC3339 last-update timestamp. */
63 updateTime: string | undefined;
64 },
65 never,
66 Providers
67>;
68
69/**
70 * A Dataform team folder — a project-level container for repositories
71 * and folders with hierarchical access control.
72 *
73 * Team folders have no labels API, so Alchemy stamps ownership into
74 * `displayName` for `list` / nuke. Location is immutable. Display name
75 * updates in place.
76 *
77 * ### Creating a Team Folder
78 * **Example:** Generated display name
79 * ```typescript
80 * const team = yield* GCP.Dataform.Team("Analytics", {});
81 * ```
82 *
83 * **Example:** Named team folder
84 * ```typescript
85 * const team = yield* GCP.Dataform.Team("Analytics", {
86 * location: "us-central1",
87 * displayName: "analytics",
88 * });
89 * ```
90 *
91 * @resource
92 * @category Dataform
93 */
94export const Team = Resource<Team>("GCP.Dataform.Team");
95
96export class TeamNotResolved extends Data.TaggedError(
97 "GCP.Dataform.TeamNotResolved",
98)<{
99 name: string;
100}> {}
101
102const toAttrs = (
103 folder: dataform.TeamFolder,
104 project: string,
105 region: string,
106) => {
107 const name = folder.name ?? "";
108 const parsed = parseResourceName(name, "teamFolders", region);
109 return {
110 name,
111 teamFolderId: parsed.id,
112 project: parsed.project || project,
113 location: parsed.location,
114 displayName: parseOwnership(folder.displayName).text,
115 creatorIamPrincipal: folder.creatorIamPrincipal,
116 createTime: folder.createTime,
117 updateTime: folder.updateTime,
118 };
119};
120
121const getByName = (name: string) =>
122 name.length === 0 || name.includes("//")
123 ? Effect.succeed(undefined)
124 : dataform
125 .getProjectsLocationsTeamFolders({ name })
126 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
127
128const findOwned = (
129 project: string,
130 location: string,
131 id: string,
132 displayName: string | undefined,
133) =>
134 Effect.gen(function* () {
135 const folders = yield* listTeamFolders(project, location);
136 for (const folder of folders) {
137 if (yield* ownedByAlchemy(id, folder.displayName)) return folder;
138 }
139 if (displayName !== undefined) {
140 const match = folders.find(
141 (folder) => parseOwnership(folder.displayName).text === displayName,
142 );
143 if (match) return match;
144 }
145 return undefined;
146 });
147
148export const TeamProvider = () =>
149 Provider.succeed(Team, {
150 stables: ["name", "teamFolderId", "project", "location", "createTime"],
151
152 diff: Effect.fn(function* ({ news, olds, output }) {
153 if (!isResolved(news)) return undefined;
154 return replaceOnIdentity({
155 previousLocation: olds?.location ?? output?.location,
156 nextLocation: news.location ?? olds?.location ?? output?.location,
157 });
158 }),
159
160 read: Effect.fn(function* ({ id, olds, output }) {
161 const env = yield* GcpEnvironment.current;
162 const location = normalizeLocation(
163 olds?.location ?? output?.location,
164 env.region,
165 );
166 const existing = output?.name
167 ? yield* getByName(output.name)
168 : yield* findOwned(env.project, location, id, olds?.displayName);
169 if (existing === undefined) return undefined;
170 const attrs = toAttrs(existing, env.project, env.region);
171 return (yield* ownedByAlchemy(id, existing.displayName))
172 ? attrs
173 : Unowned(attrs);
174 }),
175
176 list: () =>
177 Effect.gen(function* () {
178 const env = yield* GcpEnvironment.current;
179 const folders = yield* listTeamFolders(env.project, env.region);
180 return folders
181 .filter((folder) => hasOwnershipMarker(folder.displayName))
182 .map((folder) => toAttrs(folder, env.project, env.region));
183 }),
184
185 reconcile: Effect.fn(function* ({ id, news, output }) {
186 const env = yield* GcpEnvironment.current;
187 const location = normalizeLocation(
188 news.location ?? output?.location,
189 env.region,
190 );
191 const ownership = yield* createInternalLabels(id);
192 const displayName = encodeOwnershipLine(ownership, news.displayName);
193
194 let current = output?.name
195 ? yield* getByName(output.name)
196 : yield* findOwned(env.project, location, id, news.displayName);
197
198 if (current === undefined) {
199 const created = yield* retryTransient(
200 dataform.createProjectsLocationsTeamFolders({
201 parent: locationParent(env.project, location),
202 body: { displayName },
203 }),
204 ).pipe(
205 Effect.catchTag("Conflict", () =>
206 findOwned(env.project, location, id, news.displayName),
207 ),
208 );
209 current = created ?? undefined;
210 if (current?.name) {
211 current = (yield* getByName(current.name)) ?? current;
212 }
213 }
214
215 if (current === undefined) {
216 return yield* new TeamNotResolved({
217 name: output?.name ?? locationParent(env.project, location),
218 });
219 }
220
221 const currentName = current.name ?? "";
222 if (!sameText(current.displayName, displayName)) {
223 current = yield* retryTransient(
224 dataform.patchProjectsLocationsTeamFolders({
225 name: currentName,
226 updateMask: updateMaskOf("displayName"),
227 body: { displayName },
228 }),
229 );
230 }
231
232 return toAttrs(current, env.project, env.region);
233 }),
234
235 delete: Effect.fn(function* ({ output }) {
236 if (!output.name) return;
237 yield* retryTransient(deleteTeamFolderTree(output.name));
238 yield* waitUntilGone(getByName(output.name));
239 }),
240 });