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 type { Providers } from "../Providers.ts";
10import {
11 expandRepository,
12 forEachOwnedRepository,
13 hasAlchemyLabelMap,
14 listWorkspaces,
15 normalizeLocation,
16 parseResourceName,
17 replaceOnIdentity,
18 retryTransient,
19 toPhysicalId,
20 waitUntilExists,
21 waitUntilGone,
22} from "./internal.ts";
23
24export type RepositoriesWorkspaceProps = {
25 /**
26 * Parent repository. Full name
27 * `projects/{project}/locations/{location}/repositories/{repository}`
28 * or the repository id (combined with `location`). Immutable —
29 * changing it replaces the workspace.
30 */
31 repository: string;
32 /**
33 * Region used when `repository` is a bare id.
34 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
35 */
36 location?: string;
37 /**
38 * Workspace id (the `{workspace}` segment). If omitted, a unique
39 * RFC1035 name is generated. Immutable — changing it replaces the
40 * workspace. Workspaces have no labels field; ownership is the
41 * generated physical id and the parent repository's Alchemy labels.
42 */
43 workspaceId?: string;
44 /**
45 * When true, the workspace is deleted instead of moved if its
46 * repository is moved. Immutable after create.
47 * @default false
48 */
49 disableMoves?: boolean;
50};
51
52export type RepositoriesWorkspace = Resource<
53 "GCP.Dataform.RepositoriesWorkspace",
54 RepositoriesWorkspaceProps,
55 {
56 /** Full resource name `.../repositories/{repository}/workspaces/{workspace}`. */
57 name: string;
58 /** Workspace id (last path segment). */
59 workspaceId: string;
60 /** Parent repository resource name. */
61 repository: string;
62 /** Project id. */
63 project: string;
64 /** Location id. */
65 location: string;
66 /** Whether the workspace is deleted on repository move. */
67 disableMoves: boolean;
68 /** Whether the workspace is user-scoped. */
69 userScoped: boolean | undefined;
70 /** RFC3339 creation timestamp. */
71 createTime: string | undefined;
72 },
73 never,
74 Providers
75>;
76
77/**
78 * A Dataform Git workspace inside a repository.
79 *
80 * Workspaces have no update API. Changing `workspaceId`, `repository`,
81 * `location`, or `disableMoves` replaces the workspace. Ownership for
82 * `list` / nuke is the parent repository's Alchemy labels.
83 *
84 * ### Creating a Workspace
85 * **Example:** Generated name
86 * ```typescript
87 * const workspace = yield* GCP.Dataform.RepositoriesWorkspace("Dev", {
88 * repository: repo.name,
89 * });
90 * ```
91 *
92 * **Example:** Named workspace
93 * ```typescript
94 * const workspace = yield* GCP.Dataform.RepositoriesWorkspace("Dev", {
95 * repository: repo.name,
96 * workspaceId: "dev",
97 * });
98 * ```
99 *
100 * @resource
101 * @category Dataform
102 */
103export const RepositoriesWorkspace = Resource<RepositoriesWorkspace>(
104 "GCP.Dataform.RepositoriesWorkspace",
105);
106
107export class RepositoriesWorkspaceNotResolved extends Data.TaggedError(
108 "GCP.Dataform.RepositoriesWorkspaceNotResolved",
109)<{
110 name: string;
111}> {}
112
113const resourceName = (repository: string, workspaceId: string) =>
114 `${repository}/workspaces/${workspaceId}`;
115
116const toAttrs = (
117 workspace: dataform.Workspace,
118 project: string,
119 region: string,
120) => {
121 const name = workspace.name ?? "";
122 const parsed = parseResourceName(name, "workspaces", region);
123 return {
124 name,
125 workspaceId: parsed.id,
126 repository: parsed.parent,
127 project: parsed.project || project,
128 location: parsed.location,
129 disableMoves: workspace.disableMoves === true,
130 userScoped: workspace.privateResourceMetadata?.userScoped,
131 createTime: workspace.createTime,
132 };
133};
134
135const getByName = (name: string) =>
136 name.length === 0 || name.includes("//")
137 ? Effect.succeed(undefined)
138 : dataform
139 .getProjectsLocationsRepositoriesWorkspaces({ name })
140 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
141
142export const RepositoriesWorkspaceProvider = () =>
143 Provider.succeed(RepositoriesWorkspace, {
144 stables: [
145 "name",
146 "workspaceId",
147 "repository",
148 "project",
149 "location",
150 "createTime",
151 ],
152
153 diff: Effect.fn(function* ({ news, olds, output }) {
154 if (!isResolved(news)) return undefined;
155 const env = yield* GcpEnvironment.current;
156 const location = normalizeLocation(
157 news.location ?? olds?.location ?? output?.location,
158 env.region,
159 );
160 const previousRepo = olds?.repository ?? output?.repository;
161 const nextRepo = expandRepository(news.repository, env.project, location);
162 const previousMoves = olds?.disableMoves ?? output?.disableMoves;
163 return replaceOnIdentity({
164 previousId: olds?.workspaceId ?? output?.workspaceId,
165 nextId: news.workspaceId ?? olds?.workspaceId ?? output?.workspaceId,
166 previousLocation: olds?.location ?? output?.location,
167 nextLocation: news.location ?? olds?.location ?? output?.location,
168 previousParent: previousRepo,
169 nextParent: nextRepo,
170 extra:
171 previousMoves !== undefined &&
172 news.disableMoves !== undefined &&
173 news.disableMoves !== previousMoves,
174 });
175 }),
176
177 read: Effect.fn(function* ({ id, olds, output }) {
178 const env = yield* GcpEnvironment.current;
179 const location = normalizeLocation(
180 olds?.location ?? output?.location,
181 env.region,
182 );
183 const repository = expandRepository(
184 olds?.repository ??
185 output?.repository ??
186 parseResourceName(output?.name ?? "", "workspaces", env.region)
187 .parent,
188 env.project,
189 location,
190 );
191 const workspaceId = yield* toPhysicalId(
192 id,
193 olds?.workspaceId,
194 output?.workspaceId,
195 );
196 const name = output?.name ?? resourceName(repository, workspaceId);
197 const existing = yield* getByName(name);
198 if (existing === undefined) return undefined;
199 const attrs = toAttrs(existing, env.project, env.region);
200 const parent = yield* dataform
201 .getProjectsLocationsRepositories({ name: attrs.repository })
202 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
203 if (parent === undefined) return Unowned(attrs);
204 return hasAlchemyLabelMap(parent.labels) ? attrs : Unowned(attrs);
205 }),
206
207 list: () =>
208 Effect.gen(function* () {
209 const env = yield* GcpEnvironment.current;
210 const workspaces = yield* forEachOwnedRepository(
211 env.project,
212 env.region,
213 (repo) => listWorkspaces(repo.name ?? ""),
214 );
215 return workspaces.map((item) => toAttrs(item, env.project, env.region));
216 }),
217
218 reconcile: Effect.fn(function* ({ id, news, output }) {
219 const env = yield* GcpEnvironment.current;
220 const location = normalizeLocation(
221 news.location ?? output?.location,
222 env.region,
223 );
224 const repository = expandRepository(
225 news.repository,
226 env.project,
227 location,
228 );
229 const workspaceId = yield* toPhysicalId(
230 id,
231 news.workspaceId,
232 output?.workspaceId,
233 );
234 const name = resourceName(repository, workspaceId);
235
236 let current = yield* getByName(output?.name ?? name);
237
238 if (current === undefined) {
239 const created = yield* retryTransient(
240 dataform.createProjectsLocationsRepositoriesWorkspaces({
241 parent: repository,
242 workspaceId,
243 body: {
244 disableMoves: news.disableMoves === true ? true : undefined,
245 },
246 }),
247 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
248 current = created ?? undefined;
249 if (current === undefined) {
250 current = yield* waitUntilExists(getByName(name), name);
251 }
252 }
253
254 if (current === undefined) {
255 return yield* new RepositoriesWorkspaceNotResolved({ name });
256 }
257
258 return toAttrs(current, env.project, env.region);
259 }),
260
261 delete: Effect.fn(function* ({ output }) {
262 if (!output.name) return;
263 yield* retryTransient(
264 dataform.deleteProjectsLocationsRepositoriesWorkspaces({
265 name: output.name,
266 }),
267 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
268 yield* waitUntilGone(getByName(output.name));
269 }),
270 });