1import * as pubsublite from "@distilled.cloud/gcp/pubsublite_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { GcpEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 DEFAULT_THROUGHPUT_CAPACITY,
11 ResourceNotResolved,
12 fieldMask,
13 getReservation,
14 hasOwnershipMarker,
15 ignoreMissing,
16 listOwnedReservations,
17 normalizeLocation,
18 ownedByAlchemy,
19 parentOf,
20 parseName,
21 replaceOnIdentity,
22 resourceName,
23 retryInUse,
24 sameText,
25 toResourceId,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type AdminReservationProps = {
30 /**
31 * Reservation id (the `{reservation}` segment of
32 * `projects/{project}/locations/{location}/reservations/{reservation}`).
33 * If omitted, a unique name is generated from the stack, stage, and
34 * logical id. Pub/Sub Lite has no labels field, so Alchemy stamps
35 * ownership into a `+alc.{stack}.{stage}.{id}` suffix. Immutable —
36 * changing it replaces the reservation.
37 */
38 reservationId?: string;
39 /**
40 * Region (`us-central1`, `us-east1`, …). Reservations are regional.
41 * Immutable — changing it replaces the reservation. `US-CENTRAL1` is
42 * accepted and normalized to `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Reserved throughput capacity. Each unit is 1 MiB/s of published
48 * messages or 2 MiB/s of subscribed messages.
49 * @default "4"
50 */
51 throughputCapacity?: string | number;
52};
53
54export type AdminReservation = Resource<
55 "GCP.PubSubLite.AdminReservation",
56 AdminReservationProps,
57 {
58 /** Full resource name `projects/{project}/locations/{location}/reservations/{reservation}`. */
59 name: string;
60 /** Reservation id (last path segment, including the Alchemy ownership suffix). */
61 reservationId: string;
62 /** Project id. */
63 project: string;
64 /** Region id (`us-central1`, …). */
65 location: string;
66 /** Reserved throughput capacity. */
67 throughputCapacity: string | undefined;
68 },
69 never,
70 Providers
71>;
72
73/**
74 * A Pub/Sub Lite throughput reservation — a regional pool of publish
75 * and subscribe capacity that topics can consume.
76 *
77 * Pub/Sub Lite reservations have no labels. Alchemy stamps
78 * `alchemy-stack` / `alchemy-stage` / `alchemy-id` into the reservation
79 * id so `list` / `pnpm nuke:gcp` can find them. `reservationId` and
80 * `location` replace the reservation; `throughputCapacity` updates in
81 * place.
82 *
83 * ### Creating a Reservation
84 * **Example:** Generated name
85 * ```typescript
86 * const reservation = yield* GCP.PubSubLite.AdminReservation("Capacity", {
87 * throughputCapacity: "4",
88 * });
89 * ```
90 *
91 * **Example:** Explicit id and region
92 * ```typescript
93 * const reservation = yield* GCP.PubSubLite.AdminReservation("Capacity", {
94 * reservationId: "analytics",
95 * location: "us-central1",
96 * throughputCapacity: "8",
97 * });
98 * ```
99 *
100 * ### Updating a Reservation
101 * Re-declare the same logical id with changed props; the engine keeps the
102 * physical resource and updates it in place.
103 *
104 * **Example:** Scale throughput
105 * ```typescript
106 * const reservation = yield* GCP.PubSubLite.AdminReservation("Capacity", {
107 * location: "us-central1",
108 * throughputCapacity: "16",
109 * });
110 * ```
111 *
112 * @resource
113 * @category PubSubLite
114 */
115export const AdminReservation = Resource<AdminReservation>(
116 "GCP.PubSubLite.AdminReservation",
117);
118
119const COLLECTION = "reservations";
120
121const capacityOf = (value: string | number | undefined) =>
122 value === undefined || value === ""
123 ? DEFAULT_THROUGHPUT_CAPACITY
124 : String(value);
125
126const toAttrs = (
127 reservation: pubsublite.Reservation,
128 project: string,
129): AdminReservation["Attributes"] => {
130 const name = reservation.name ?? "";
131 const parsed = parseName(name, COLLECTION);
132 return {
133 name,
134 reservationId: parsed.id,
135 project: parsed.project || project,
136 location: parsed.location,
137 throughputCapacity: reservation.throughputCapacity,
138 };
139};
140
141export const AdminReservationProvider = () =>
142 Provider.succeed(AdminReservation, {
143 stables: ["name", "reservationId", "project", "location"],
144
145 diff: Effect.fn(function* ({ news, olds, output }) {
146 if (!isResolved(news)) return undefined;
147 const env = yield* GcpEnvironment.current;
148 return replaceOnIdentity({
149 previousId: olds?.reservationId ?? output?.reservationId,
150 nextId:
151 news.reservationId ?? olds?.reservationId ?? output?.reservationId,
152 previousLocation: normalizeLocation(
153 olds?.location ?? output?.location,
154 env.region,
155 ),
156 nextLocation: normalizeLocation(
157 news.location ?? olds?.location ?? output?.location,
158 env.region,
159 ),
160 });
161 }),
162
163 read: Effect.fn(function* ({ id, olds, output }) {
164 const env = yield* GcpEnvironment.current;
165 const reservationId = yield* toResourceId(
166 id,
167 olds?.reservationId,
168 output?.reservationId,
169 );
170 const location = normalizeLocation(
171 olds?.location ?? output?.location,
172 env.region,
173 );
174 const name =
175 output?.name ??
176 resourceName(env.project, location, COLLECTION, reservationId);
177 const existing = yield* getReservation(name);
178 if (existing === undefined) return undefined;
179 const attrs = toAttrs(existing, env.project);
180 const owned = yield* ownedByAlchemy(id, attrs.reservationId);
181 if (owned) return attrs;
182 return hasOwnershipMarker(attrs.reservationId)
183 ? undefined
184 : Unowned(attrs);
185 }),
186
187 list: () =>
188 Effect.gen(function* () {
189 const env = yield* GcpEnvironment.current;
190 const items = yield* listOwnedReservations();
191 return items.map((item) => toAttrs(item, env.project));
192 }),
193
194 reconcile: Effect.fn(function* ({ id, news, output }) {
195 const env = yield* GcpEnvironment.current;
196 const reservationId = yield* toResourceId(
197 id,
198 news.reservationId,
199 output?.reservationId,
200 );
201 const location = normalizeLocation(
202 news.location ?? output?.location,
203 env.region,
204 );
205 const name = resourceName(
206 env.project,
207 location,
208 COLLECTION,
209 reservationId,
210 );
211 const throughputCapacity = capacityOf(news.throughputCapacity);
212
213 let current = yield* getReservation(output?.name ?? name);
214
215 if (current === undefined) {
216 const created = yield* pubsublite
217 .createAdminProjectsLocationsReservations({
218 parent: parentOf(env.project, location),
219 reservationId,
220 body: { throughputCapacity },
221 })
222 .pipe(Effect.catchTag("Conflict", () => getReservation(name)));
223 current = created ?? undefined;
224 }
225
226 if (current === undefined) {
227 return yield* new ResourceNotResolved({ name });
228 }
229
230 const observedName = current.name ?? name;
231 const mask = fieldMask([
232 !sameText(current.throughputCapacity, throughputCapacity) &&
233 "throughputCapacity",
234 ]);
235 if (mask.length > 0) {
236 current = yield* pubsublite.patchAdminProjectsLocationsReservations({
237 name: observedName,
238 updateMask: mask,
239 body: {
240 name: observedName,
241 throughputCapacity,
242 },
243 });
244 }
245
246 return toAttrs(current, env.project);
247 }),
248
249 delete: Effect.fn(function* ({ output }) {
250 yield* retryInUse(
251 ignoreMissing(
252 pubsublite.deleteAdminProjectsLocationsReservations({
253 name: output.name,
254 }),
255 ),
256 );
257 yield* waitUntilGone(getReservation(output.name));
258 }),
259 });