1import * as bigqueryreservation from "@distilled.cloud/gcp/bigqueryreservation_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 listLocations,
13 hasOwnershipMarker,
14 lastSegment,
15 normalizeLocation,
16 ownedByAlchemy,
17 parentOf,
18 parseResourceName,
19 toResourceId,
20} from "./internal.ts";
21
22export type ReservationGroupProps = {
23 /**
24 * Reservation group id (the `{reservationGroup}` segment of
25 * `projects/{project}/locations/{location}/reservationGroups/{reservationGroup}`).
26 * If omitted, a unique name is generated from the stack, stage, and
27 * logical id. Must contain only lowercase letters, digits, or dashes;
28 * start with a letter; not end with a dash; max 64 characters.
29 * Groups have no labels field, so Alchemy stamps ownership into this
30 * id (`alch---…`) for `list` / nuke. Immutable — changing it replaces
31 * the group.
32 */
33 reservationGroupId?: string;
34 /**
35 * BigQuery location (`us-central1`, `US`, `EU`, …). Immutable —
36 * changing it replaces the group. Multi-regions `US` and `EU` stay
37 * uppercase; regional ids are lowercased.
38 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
39 */
40 location?: string;
41};
42
43export type ReservationGroup = Resource<
44 "GCP.BigQueryReservation.ReservationGroup",
45 ReservationGroupProps,
46 {
47 /** Full resource name `projects/{project}/locations/{location}/reservationGroups/{reservationGroup}`. */
48 name: string;
49 /** Reservation group id (last path segment), including the Alchemy prefix. */
50 reservationGroupId: string;
51 /** Project id. */
52 project: string;
53 /** Location id (`us-central1`, `US`, …). */
54 location: string;
55 },
56 never,
57 Providers
58>;
59
60/**
61 * A BigQuery reservation group — a named container for reservations.
62 *
63 * Groups have no labels or description, so Alchemy stamps ownership into
64 * the group id (`alch---…`) so `list` / `pnpm nuke:gcp` can find them.
65 * There is no update API; `reservationGroupId` and `location` are
66 * identity and changing them replaces the group.
67 *
68 * ### Creating a Reservation Group
69 * **Example:** Generated id
70 * ```typescript
71 * const group = yield* GCP.BigQueryReservation.ReservationGroup("Team", {});
72 * ```
73 *
74 * **Example:** Explicit id and multi-region location
75 * ```typescript
76 * const group = yield* GCP.BigQueryReservation.ReservationGroup("Team", {
77 * reservationGroupId: "analytics-prod",
78 * location: "US",
79 * });
80 * ```
81 *
82 * @resource
83 * @category BigQueryReservation
84 */
85export const ReservationGroup = Resource<ReservationGroup>(
86 "GCP.BigQueryReservation.ReservationGroup",
87);
88
89export class ReservationGroupNotResolved extends Data.TaggedError(
90 "GCP.BigQueryReservation.ReservationGroupNotResolved",
91)<{
92 name: string;
93}> {}
94
95const resourceName = (
96 project: string,
97 location: string,
98 reservationGroupId: string,
99) =>
100 `projects/${project}/locations/${location}/reservationGroups/${reservationGroupId}`;
101
102const toAttrs = (
103 current: bigqueryreservation.ReservationGroup,
104 project: string,
105) => {
106 const name = current.name ?? "";
107 const parsed = parseResourceName(name, "reservationGroups");
108 return {
109 name,
110 reservationGroupId: parsed.resourceId,
111 project: parsed.project || project,
112 location: parsed.location,
113 };
114};
115
116const getByName = (name: string) =>
117 bigqueryreservation
118 .getProjectsLocationsReservationGroups({ name })
119 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
120
121const listOwnedAt = (project: string, location: string) =>
122 bigqueryreservation.listProjectsLocationsReservationGroups
123 .pages({
124 parent: parentOf(project, location),
125 pageSize: 1000,
126 })
127 .pipe(
128 Stream.flatMap((page) =>
129 Stream.fromIterable(page.reservationGroups ?? []),
130 ),
131 Stream.filter((item) => hasOwnershipMarker(lastSegment(item.name ?? ""))),
132 Stream.map((item) => toAttrs(item, project)),
133 Stream.runCollect,
134 Effect.map((chunk) => Array.from(chunk)),
135 Effect.catchTag("NotFound", () => Effect.succeed([])),
136 );
137
138export const ReservationGroupProvider = () =>
139 Provider.succeed(ReservationGroup, {
140 stables: ["name", "reservationGroupId", "project", "location"],
141
142 diff: Effect.fn(function* ({ id, news, olds, output }) {
143 if (!isResolved(news)) return undefined;
144 const env = yield* GcpEnvironment.current;
145
146 const previousId = olds?.reservationGroupId ?? output?.reservationGroupId;
147 const nextId = yield* toResourceId(
148 id,
149 news.reservationGroupId,
150 previousId,
151 );
152 const previousLocation = normalizeLocation(
153 olds?.location ?? output?.location ?? env.region,
154 );
155 const nextLocation = normalizeLocation(
156 news.location ?? output?.location ?? env.region,
157 );
158
159 const replace =
160 (previousId !== undefined && nextId !== previousId) ||
161 previousLocation !== nextLocation;
162
163 if (!replace) return undefined;
164 return {
165 action: "replace" as const,
166 deleteFirst:
167 previousLocation === nextLocation &&
168 previousId !== undefined &&
169 nextId === previousId,
170 };
171 }),
172
173 read: Effect.fn(function* ({ id, olds, output }) {
174 const env = yield* GcpEnvironment.current;
175 const reservationGroupId = yield* toResourceId(
176 id,
177 olds?.reservationGroupId,
178 output?.reservationGroupId,
179 );
180 const location = normalizeLocation(
181 olds?.location ?? output?.location ?? env.region,
182 );
183 const name =
184 output?.name ?? resourceName(env.project, location, reservationGroupId);
185 const existing = yield* getByName(name);
186 if (existing === undefined) return undefined;
187 const attrs = toAttrs(existing, env.project);
188 return (yield* ownedByAlchemy(id, lastSegment(existing.name ?? "")))
189 ? attrs
190 : Unowned(attrs);
191 }),
192
193 list: () =>
194 Effect.gen(function* () {
195 const env = yield* GcpEnvironment.current;
196 const pages = yield* Effect.forEach(
197 listLocations(env.region),
198 (location) => listOwnedAt(env.project, location),
199 { concurrency: 4 },
200 );
201 const byName = new Map<string, ReturnType<typeof toAttrs>>();
202 for (const item of pages.flat()) {
203 byName.set(item.name, item);
204 }
205 return Array.from(byName.values());
206 }),
207
208 reconcile: Effect.fn(function* ({ id, news, output }) {
209 const env = yield* GcpEnvironment.current;
210 const reservationGroupId = yield* toResourceId(
211 id,
212 news.reservationGroupId,
213 output?.reservationGroupId,
214 );
215 const location = normalizeLocation(
216 news.location ?? output?.location ?? env.region,
217 );
218 const name = resourceName(env.project, location, reservationGroupId);
219 const parent = parentOf(env.project, location);
220
221 let current = yield* getByName(output?.name ?? name);
222
223 if (current === undefined) {
224 const created = yield* bigqueryreservation
225 .createProjectsLocationsReservationGroups({
226 parent,
227 reservationGroupId,
228 body: {},
229 })
230 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
231 current = created ?? undefined;
232 }
233
234 if (current === undefined) {
235 return yield* new ReservationGroupNotResolved({ name });
236 }
237
238 return toAttrs(current, env.project);
239 }),
240
241 delete: Effect.fn(function* ({ output }) {
242 yield* bigqueryreservation
243 .deleteProjectsLocationsReservationGroups({ name: output.name })
244 .pipe(Effect.catchTag("NotFound", () => Effect.void));
245 }),
246 });