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 deleteFolderTree,
13 encodeOwnershipLine,
14 expandParent,
15 hasOwnershipMarker,
16 listFolders,
17 locationParent,
18 normalizeLocation,
19 ownedByAlchemy,
20 parseOwnership,
21 parseResourceName,
22 replaceOnIdentity,
23 retryTransient,
24 sameText,
25 updateMaskOf,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type ProjectsLocationsFolderProps = {
30 /**
31 * Region (`us-central1`, …). Immutable — changing it replaces the
32 * folder. `US-CENTRAL1` is accepted and normalized to `us-central1`.
33 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
34 */
35 location?: string;
36 /**
37 * User-friendly display name. Dataform folders have no labels field,
38 * so Alchemy stamps ownership into a `[alchemy …]` prefix and strips
39 * it from attributes. If omitted, only the ownership marker is stored.
40 */
41 displayName?: string;
42 /**
43 * Parent folder or team folder
44 * (`projects/{project}/locations/{location}/folders/{folder}` or
45 * `.../teamFolders/{teamFolder}`). Empty or omitted creates a root
46 * user folder. Changing it moves the folder.
47 */
48 containingFolder?: string;
49};
50
51export type ProjectsLocationsFolder = Resource<
52 "GCP.Dataform.ProjectsLocationsFolder",
53 ProjectsLocationsFolderProps,
54 {
55 /** Full resource name `projects/{project}/locations/{location}/folders/{folder}`. */
56 name: string;
57 /** Folder id (last path segment). */
58 folderId: string;
59 /** Project id. */
60 project: string;
61 /** Location id. */
62 location: string;
63 /** User display name with the Alchemy ownership prefix stripped. */
64 displayName: string | undefined;
65 /** Parent folder or team folder, if any. */
66 containingFolder: string | undefined;
67 /** Associated team folder, if any. */
68 teamFolderName: string | undefined;
69 /** Creator IAM principal. */
70 creatorIamPrincipal: string | undefined;
71 /** RFC3339 creation timestamp. */
72 createTime: string | undefined;
73 /** RFC3339 last-update timestamp. */
74 updateTime: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Dataform folder used to organize repositories and nested folders.
82 *
83 * Folders have no labels API, so Alchemy stamps ownership into
84 * `displayName` for `list` / nuke. Location is immutable. Display name
85 * updates in place; `containingFolder` is applied with MoveFolder.
86 *
87 * ### Creating a Folder
88 * **Example:** Root folder
89 * ```typescript
90 * const folder = yield* GCP.Dataform.ProjectsLocationsFolder("Analytics", {
91 * displayName: "analytics",
92 * });
93 * ```
94 *
95 * **Example:** Nested folder
96 * ```typescript
97 * const child = yield* GCP.Dataform.ProjectsLocationsFolder("Models", {
98 * displayName: "models",
99 * containingFolder: folder.name,
100 * });
101 * ```
102 *
103 * @resource
104 * @category Dataform
105 */
106export const ProjectsLocationsFolder = Resource<ProjectsLocationsFolder>(
107 "GCP.Dataform.ProjectsLocationsFolder",
108);
109
110export class ProjectsLocationsFolderNotResolved extends Data.TaggedError(
111 "GCP.Dataform.ProjectsLocationsFolderNotResolved",
112)<{
113 name: string;
114}> {}
115
116const toAttrs = (folder: dataform.Folder, project: string, region: string) => {
117 const name = folder.name ?? "";
118 const parsed = parseResourceName(name, "folders", region);
119 return {
120 name,
121 folderId: parsed.id,
122 project: parsed.project || project,
123 location: parsed.location,
124 displayName: parseOwnership(folder.displayName).text,
125 containingFolder: folder.containingFolder,
126 teamFolderName: folder.teamFolderName,
127 creatorIamPrincipal: folder.creatorIamPrincipal,
128 createTime: folder.createTime,
129 updateTime: folder.updateTime,
130 };
131};
132
133const getByName = (name: string) =>
134 name.length === 0 || name.includes("//")
135 ? Effect.succeed(undefined)
136 : dataform
137 .getProjectsLocationsFolders({ name })
138 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
139
140const findOwned = (
141 project: string,
142 location: string,
143 id: string,
144 displayName: string | undefined,
145) =>
146 Effect.gen(function* () {
147 const folders = yield* listFolders(project, location);
148 for (const folder of folders) {
149 if (yield* ownedByAlchemy(id, folder.displayName)) return folder;
150 }
151 if (displayName !== undefined) {
152 const match = folders.find(
153 (folder) => parseOwnership(folder.displayName).text === displayName,
154 );
155 if (match) return match;
156 }
157 return undefined;
158 });
159
160export const ProjectsLocationsFolderProvider = () =>
161 Provider.succeed(ProjectsLocationsFolder, {
162 stables: ["name", "folderId", "project", "location", "createTime"],
163
164 diff: Effect.fn(function* ({ news, olds, output }) {
165 if (!isResolved(news)) return undefined;
166 return replaceOnIdentity({
167 previousLocation: olds?.location ?? output?.location,
168 nextLocation: news.location ?? olds?.location ?? output?.location,
169 });
170 }),
171
172 read: Effect.fn(function* ({ id, olds, output }) {
173 const env = yield* GcpEnvironment.current;
174 const location = normalizeLocation(
175 olds?.location ?? output?.location,
176 env.region,
177 );
178 const existing = output?.name
179 ? yield* getByName(output.name)
180 : yield* findOwned(env.project, location, id, olds?.displayName);
181 if (existing === undefined) return undefined;
182 const attrs = toAttrs(existing, env.project, env.region);
183 return (yield* ownedByAlchemy(id, existing.displayName))
184 ? attrs
185 : Unowned(attrs);
186 }),
187
188 list: () =>
189 Effect.gen(function* () {
190 const env = yield* GcpEnvironment.current;
191 const folders = yield* listFolders(env.project, env.region);
192 return folders
193 .filter((folder) => hasOwnershipMarker(folder.displayName))
194 .map((folder) => toAttrs(folder, env.project, env.region));
195 }),
196
197 reconcile: Effect.fn(function* ({ id, news, output }) {
198 const env = yield* GcpEnvironment.current;
199 const location = normalizeLocation(
200 news.location ?? output?.location,
201 env.region,
202 );
203 const ownership = yield* createInternalLabels(id);
204 const displayName = encodeOwnershipLine(ownership, news.displayName);
205 const containingFolder =
206 news.containingFolder !== undefined && news.containingFolder.length > 0
207 ? news.containingFolder.includes("/")
208 ? news.containingFolder
209 : expandParent(
210 news.containingFolder,
211 env.project,
212 location,
213 "folders",
214 )
215 : undefined;
216
217 let current = output?.name
218 ? yield* getByName(output.name)
219 : yield* findOwned(env.project, location, id, news.displayName);
220
221 if (current === undefined) {
222 const created = yield* retryTransient(
223 dataform.createProjectsLocationsFolders({
224 parent: locationParent(env.project, location),
225 body: {
226 displayName,
227 containingFolder,
228 },
229 }),
230 ).pipe(
231 Effect.catchTag("Conflict", () =>
232 findOwned(env.project, location, id, news.displayName),
233 ),
234 );
235 current = created ?? undefined;
236 if (current?.name) {
237 current = (yield* getByName(current.name)) ?? current;
238 }
239 }
240
241 if (current === undefined) {
242 return yield* new ProjectsLocationsFolderNotResolved({
243 name: output?.name ?? locationParent(env.project, location),
244 });
245 }
246
247 const currentName = current.name ?? "";
248 const displayChanged = !sameText(current.displayName, displayName);
249 if (displayChanged) {
250 current = yield* retryTransient(
251 dataform.patchProjectsLocationsFolders({
252 name: currentName,
253 updateMask: updateMaskOf("displayName"),
254 body: { displayName },
255 }),
256 );
257 }
258
259 const observedFolder = current.containingFolder ?? "";
260 const desiredFolder = containingFolder ?? "";
261 if (desiredFolder !== observedFolder) {
262 yield* retryTransient(
263 dataform.moveProjectsLocationsFolders({
264 name: currentName,
265 body: { destinationContainingFolder: desiredFolder },
266 }),
267 );
268 current = (yield* getByName(currentName)) ?? current;
269 }
270
271 return toAttrs(current, env.project, env.region);
272 }),
273
274 delete: Effect.fn(function* ({ output }) {
275 if (!output.name) return;
276 yield* retryTransient(deleteFolderTree(output.name));
277 yield* waitUntilGone(getByName(output.name));
278 }),
279 });