1import * as bigqueryreservation from "@distilled.cloud/gcp/bigqueryreservation_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 compact,
14 listLocations,
15 hasOwnershipMarker,
16 lastSegment,
17 normalizeLocation,
18 ownedByAlchemy,
19 parentOf,
20 parseResourceName,
21 toResourceId,
22} from "./internal.ts";
23
24const DEFAULT_EDITION = "ENTERPRISE";
25
26export type CapacityCommitmentPlan =
27 | bigqueryreservation.CapacityCommitmentPlanEnum
28 | (string & {});
29
30export type CapacityCommitmentEdition =
31 | bigqueryreservation.CapacityCommitmentEditionEnum
32 | (string & {});
33
34export type CapacityCommitmentRenewalPlan =
35 | bigqueryreservation.CapacityCommitmentRenewalPlanEnum
36 | (string & {});
37
38export type CapacityCommitmentProps = {
39 /**
40 * Capacity commitment id (the `{capacityCommitment}` segment of
41 * `projects/{project}/locations/{location}/capacityCommitments/{capacityCommitment}`).
42 * If omitted, a unique name is generated from the stack, stage, and
43 * logical id. Must contain only lowercase letters, digits, or dashes;
44 * start with a letter; not end with a dash; max 64 characters.
45 * Commitments have no labels field, so Alchemy stamps ownership into
46 * this id (`alch---…`) for `list` / nuke. Immutable — changing it
47 * replaces the commitment. Split/merge drops a caller-assigned id.
48 */
49 capacityCommitmentId?: string;
50 /**
51 * BigQuery location (`us-central1`, `US`, `EU`, …). Immutable —
52 * changing it replaces the commitment. Multi-regions `US` and `EU`
53 * stay uppercase; regional ids are lowercased.
54 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
55 */
56 location?: string;
57 /**
58 * Number of slots in this commitment. Immutable — changing it
59 * replaces the commitment. Edition commitments are typically sold in
60 * 50-slot increments (legacy flat-rate in 100-slot increments).
61 */
62 slotCount: string;
63 /**
64 * Commitment plan (`FLEX`, `MONTHLY`, `ANNUAL`, `THREE_YEAR`, and
65 * the `*_FLAT_RATE` variants). `FLEX` (or `FLEX_FLAT_RATE`) can be
66 * deleted about 60 seconds after it becomes `ACTIVE`. Longer plans
67 * cannot be deleted until they expire or are converted to a shorter
68 * plan after expiry. Plan may be updated in place only to a longer
69 * committed period.
70 */
71 plan: CapacityCommitmentPlan;
72 /**
73 * Plan this commitment converts to after `commitmentEndTime`. Only
74 * applicable for `ANNUAL` and `TRIAL` commitments.
75 */
76 renewalPlan?: CapacityCommitmentRenewalPlan;
77 /**
78 * Reservation edition. Immutable — changing it replaces the
79 * commitment. `STANDARD` cannot purchase commitments; use
80 * `ENTERPRISE` or `ENTERPRISE_PLUS`.
81 * @default "ENTERPRISE"
82 */
83 edition?: CapacityCommitmentEdition;
84 /**
85 * Place the commitment in the organization's DR secondary region
86 * (multi-region `US` or `EU` only). Preview; the project must be
87 * allow-listed. Immutable — changing it replaces the commitment.
88 */
89 multiRegionAuxiliary?: boolean;
90 /**
91 * When true, fail the create if another project in the organization
92 * already has a capacity commitment.
93 * @default false
94 */
95 enforceSingleAdminProjectPerOrg?: boolean;
96};
97
98export type CapacityCommitment = Resource<
99 "GCP.BigQueryReservation.CapacityCommitment",
100 CapacityCommitmentProps,
101 {
102 /** Full resource name `projects/{project}/locations/{location}/capacityCommitments/{capacityCommitment}`. */
103 name: string;
104 /** Capacity commitment id (last path segment), including the Alchemy prefix. */
105 capacityCommitmentId: string;
106 /** Project id. */
107 project: string;
108 /** Location id (`us-central1`, `US`, …). */
109 location: string;
110 /** Number of slots. */
111 slotCount: string | undefined;
112 /** Commitment plan. */
113 plan: string | undefined;
114 /** Renewal plan, if set. */
115 renewalPlan: string | undefined;
116 /** Reservation edition. */
117 edition: string | undefined;
118 /** Commitment state (`PENDING`, `ACTIVE`, `FAILED`). */
119 state: string | undefined;
120 /** Whether this is a legacy flat-rate commitment. */
121 isFlatRate: boolean;
122 /** Whether this is a multi-region auxiliary commitment. */
123 multiRegionAuxiliary: boolean;
124 /** RFC3339 start of the current commitment period. */
125 commitmentStartTime: string | undefined;
126 /** RFC3339 end of the current commitment period. */
127 commitmentEndTime: string | undefined;
128 /** Failure reason when `state` is `FAILED`. */
129 failureStatus: bigqueryreservation.Status | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A BigQuery capacity commitment — purchased slot capacity with a
137 * committed period of usage.
138 *
139 * Commitments have no labels, so Alchemy stamps ownership into the
140 * commitment id (`alch---…`) so `list` / `pnpm nuke:gcp` can find them.
141 * Changing `capacityCommitmentId`, `location`, `slotCount`, `edition`,
142 * or `multiRegionAuxiliary` replaces the commitment. `plan` and
143 * `renewalPlan` update in place (plan can only lengthen).
144 *
145 * `FLEX` commitments can be deleted about 60 seconds after they become
146 * `ACTIVE`. Annual and three-year commitments cannot be deleted until
147 * they expire.
148 *
149 * ### Creating a Capacity Commitment
150 * **Example:** Flex Enterprise slots
151 * ```typescript
152 * const commit = yield* GCP.BigQueryReservation.CapacityCommitment("Flex", {
153 * location: "US",
154 * slotCount: "50",
155 * plan: "FLEX_FLAT_RATE",
156 * edition: "ENTERPRISE",
157 * });
158 * ```
159 *
160 * **Example:** Annual commitment with flex renewal
161 * ```typescript
162 * const commit = yield* GCP.BigQueryReservation.CapacityCommitment("Year", {
163 * location: "US",
164 * slotCount: "100",
165 * plan: "ANNUAL",
166 * renewalPlan: "FLEX",
167 * edition: "ENTERPRISE",
168 * });
169 * ```
170 *
171 * @resource
172 * @category BigQueryReservation
173 */
174export const CapacityCommitment = Resource<CapacityCommitment>(
175 "GCP.BigQueryReservation.CapacityCommitment",
176);
177
178export class CapacityCommitmentNotResolved extends Data.TaggedError(
179 "GCP.BigQueryReservation.CapacityCommitmentNotResolved",
180)<{
181 name: string;
182}> {}
183
184const resourceName = (
185 project: string,
186 location: string,
187 capacityCommitmentId: string,
188) =>
189 `projects/${project}/locations/${location}/capacityCommitments/${capacityCommitmentId}`;
190
191const normalizeEdition = (edition: string | undefined) => {
192 const value = (edition ?? DEFAULT_EDITION).toUpperCase();
193 return value === "EDITION_UNSPECIFIED" ? DEFAULT_EDITION : value;
194};
195
196const normalizePlan = (plan: string) => plan.toUpperCase();
197
198const toAttrs = (
199 current: bigqueryreservation.CapacityCommitment,
200 project: string,
201) => {
202 const name = current.name ?? "";
203 const parsed = parseResourceName(name, "capacityCommitments");
204 return {
205 name,
206 capacityCommitmentId: parsed.resourceId,
207 project: parsed.project || project,
208 location: parsed.location,
209 slotCount: current.slotCount,
210 plan: current.plan,
211 renewalPlan: current.renewalPlan,
212 edition: current.edition,
213 state: current.state,
214 isFlatRate: current.isFlatRate === true,
215 multiRegionAuxiliary: current.multiRegionAuxiliary === true,
216 commitmentStartTime: current.commitmentStartTime,
217 commitmentEndTime: current.commitmentEndTime,
218 failureStatus: current.failureStatus,
219 };
220};
221
222const getByName = (name: string) =>
223 bigqueryreservation
224 .getProjectsLocationsCapacityCommitments({ name })
225 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
226
227const waitUntilSettled = (name: string) =>
228 getByName(name).pipe(
229 Effect.repeat({
230 schedule: Schedule.spaced("2 seconds"),
231 until: (row) => row === undefined || row.state !== "PENDING",
232 times: 10,
233 }),
234 );
235
236const listOwnedAt = (project: string, location: string) =>
237 bigqueryreservation.listProjectsLocationsCapacityCommitments
238 .pages({
239 parent: parentOf(project, location),
240 pageSize: 1000,
241 })
242 .pipe(
243 Stream.flatMap((page) =>
244 Stream.fromIterable(page.capacityCommitments ?? []),
245 ),
246 Stream.filter((item) => hasOwnershipMarker(lastSegment(item.name ?? ""))),
247 Stream.map((item) => toAttrs(item, project)),
248 Stream.runCollect,
249 Effect.map((chunk) => Array.from(chunk)),
250 Effect.catchTag("NotFound", () => Effect.succeed([])),
251 );
252
253const toBody = (
254 news: CapacityCommitmentProps,
255 edition: string,
256): bigqueryreservation.CapacityCommitment =>
257 compact({
258 slotCount: news.slotCount,
259 plan: news.plan,
260 edition,
261 renewalPlan: news.renewalPlan,
262 multiRegionAuxiliary: news.multiRegionAuxiliary,
263 });
264
265const stillCommitted = (
266 error: bigqueryreservation.DeleteProjectsLocationsCapacityCommitmentsError,
267) => error._tag === "Conflict" || error._tag === "CapacityCommitmentNotExpired";
268
269export const CapacityCommitmentProvider = () =>
270 Provider.succeed(CapacityCommitment, {
271 stables: [
272 "name",
273 "capacityCommitmentId",
274 "project",
275 "location",
276 "edition",
277 "slotCount",
278 "commitmentStartTime",
279 ],
280
281 diff: Effect.fn(function* ({ id, news, olds, output }) {
282 if (!isResolved(news)) return undefined;
283 const env = yield* GcpEnvironment.current;
284
285 const previousId =
286 olds?.capacityCommitmentId ?? output?.capacityCommitmentId;
287 const nextId = yield* toResourceId(
288 id,
289 news.capacityCommitmentId,
290 previousId,
291 );
292 const previousLocation = normalizeLocation(
293 olds?.location ?? output?.location ?? env.region,
294 );
295 const nextLocation = normalizeLocation(
296 news.location ?? output?.location ?? env.region,
297 );
298 const previousEdition = normalizeEdition(
299 olds?.edition ?? output?.edition,
300 );
301 const nextEdition = normalizeEdition(news.edition ?? previousEdition);
302 const previousAuxiliary =
303 olds?.multiRegionAuxiliary ?? output?.multiRegionAuxiliary ?? false;
304 const nextAuxiliary = news.multiRegionAuxiliary ?? previousAuxiliary;
305 const previousSlots = olds?.slotCount ?? output?.slotCount;
306 const nextSlots = news.slotCount ?? previousSlots;
307
308 const replace =
309 (previousId !== undefined && nextId !== previousId) ||
310 previousLocation !== nextLocation ||
311 previousEdition !== nextEdition ||
312 previousAuxiliary !== nextAuxiliary ||
313 (previousSlots !== undefined &&
314 nextSlots !== undefined &&
315 previousSlots !== nextSlots);
316
317 if (!replace) return undefined;
318 return {
319 action: "replace" as const,
320 deleteFirst:
321 previousLocation === nextLocation &&
322 previousId !== undefined &&
323 nextId === previousId,
324 };
325 }),
326
327 read: Effect.fn(function* ({ id, olds, output }) {
328 const env = yield* GcpEnvironment.current;
329 const capacityCommitmentId = yield* toResourceId(
330 id,
331 olds?.capacityCommitmentId,
332 output?.capacityCommitmentId,
333 );
334 const location = normalizeLocation(
335 olds?.location ?? output?.location ?? env.region,
336 );
337 const name =
338 output?.name ??
339 resourceName(env.project, location, capacityCommitmentId);
340 const existing = yield* getByName(name);
341 if (existing === undefined) return undefined;
342 const attrs = toAttrs(existing, env.project);
343 return (yield* ownedByAlchemy(id, lastSegment(existing.name ?? "")))
344 ? attrs
345 : Unowned(attrs);
346 }),
347
348 list: () =>
349 Effect.gen(function* () {
350 const env = yield* GcpEnvironment.current;
351 const pages = yield* Effect.forEach(
352 listLocations(env.region),
353 (location) => listOwnedAt(env.project, location),
354 { concurrency: 4 },
355 );
356 const byName = new Map<string, ReturnType<typeof toAttrs>>();
357 for (const item of pages.flat()) {
358 byName.set(item.name, item);
359 }
360 return Array.from(byName.values());
361 }),
362
363 reconcile: Effect.fn(function* ({ id, news, output }) {
364 const env = yield* GcpEnvironment.current;
365 const capacityCommitmentId = yield* toResourceId(
366 id,
367 news.capacityCommitmentId,
368 output?.capacityCommitmentId,
369 );
370 const location = normalizeLocation(
371 news.location ?? output?.location ?? env.region,
372 );
373 const name = resourceName(env.project, location, capacityCommitmentId);
374 const parent = parentOf(env.project, location);
375 const edition = normalizeEdition(news.edition ?? output?.edition);
376 const createBody = toBody(news, edition);
377
378 let current = yield* getByName(output?.name ?? name);
379
380 if (current === undefined) {
381 const created = yield* bigqueryreservation
382 .createProjectsLocationsCapacityCommitments({
383 parent,
384 capacityCommitmentId,
385 enforceSingleAdminProjectPerOrg:
386 news.enforceSingleAdminProjectPerOrg,
387 body: createBody,
388 })
389 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
390 current = created ?? undefined;
391 if (current?.state === "PENDING") {
392 current = (yield* waitUntilSettled(current.name ?? name)) ?? current;
393 }
394 }
395
396 if (current === undefined) {
397 return yield* new CapacityCommitmentNotResolved({ name });
398 }
399
400 const desiredPlan = normalizePlan(news.plan);
401 const observedPlan = normalizePlan(current.plan ?? desiredPlan);
402 const planChanged = observedPlan !== desiredPlan;
403 const renewalChanged =
404 news.renewalPlan !== undefined &&
405 (current.renewalPlan ?? "") !== news.renewalPlan;
406
407 const updateMask = [
408 planChanged ? "plan" : undefined,
409 renewalChanged ? "renewalPlan" : undefined,
410 ].filter((field): field is string => field !== undefined);
411
412 if (updateMask.length > 0) {
413 current =
414 yield* bigqueryreservation.patchProjectsLocationsCapacityCommitments({
415 name: current.name ?? name,
416 updateMask: updateMask.join(","),
417 body: compact({
418 plan: news.plan,
419 renewalPlan: news.renewalPlan,
420 }),
421 });
422 }
423
424 return toAttrs(current, env.project);
425 }),
426
427 delete: Effect.fn(function* ({ output }) {
428 yield* bigqueryreservation
429 .deleteProjectsLocationsCapacityCommitments({
430 name: output.name,
431 force: true,
432 })
433 .pipe(
434 Effect.retry({
435 while: stillCommitted,
436 times: 8,
437 schedule: Schedule.spaced("8 seconds"),
438 }),
439 Effect.catchTag("NotFound", () => Effect.void),
440 );
441 }),
442 });