1import * as bigtable from "@distilled.cloud/gcp/bigtableadmin_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
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 instanceNameOf,
13 listAlchemyInstances,
14 MAX_APP_PROFILE_ID_LENGTH,
15 parentOwned,
16 parseResourceName,
17 toPhysicalId,
18 waitForOperation,
19 collectPages,
20} from "./operations.ts";
21
22export type SingleClusterRouting = {
23 /** Cluster id to route all requests to. */
24 clusterId: string;
25 /**
26 * Allow `CheckAndMutateRow` and `ReadModifyWriteRow` on this profile.
27 * @default false
28 */
29 allowTransactionalWrites?: boolean;
30};
31
32export type MultiClusterRouting = {
33 /**
34 * Cluster ids to route to. Empty means every cluster in the instance.
35 */
36 clusterIds?: string[];
37};
38
39export type StandardIsolation = {
40 /** Request priority (`PRIORITY_LOW`, `PRIORITY_MEDIUM`, `PRIORITY_HIGH`). */
41 priority?: bigtable.StandardIsolationPriorityEnum | (string & {});
42};
43
44export type AppProfileProps = {
45 /**
46 * Parent instance. Full name `projects/{project}/instances/{instance}`
47 * or the instance id. Immutable — changing it replaces the app profile.
48 */
49 instance: string;
50 /**
51 * App profile id (the `{appProfile}` segment of
52 * `.../instances/{instance}/appProfiles/{appProfile}`). If omitted, a
53 * unique name is generated from the stack, stage, and logical id. Must
54 * be 1-50 characters. Immutable — changing it replaces the app profile.
55 */
56 appProfileId?: string;
57 /**
58 * Long-form description of the use case.
59 */
60 description?: string;
61 /**
62 * Route all traffic to one cluster. Mutually exclusive with
63 * `multiClusterRouting`.
64 */
65 singleClusterRouting?: SingleClusterRouting;
66 /**
67 * Route to the nearest eligible cluster. Default when neither routing
68 * policy is set.
69 */
70 multiClusterRouting?: MultiClusterRouting;
71 /**
72 * Standard isolation (request priority).
73 */
74 standardIsolation?: StandardIsolation;
75 /**
76 * Ignore safety checks on create, update, and delete.
77 * @default true
78 */
79 ignoreWarnings?: boolean;
80};
81
82export type AppProfile = Resource<
83 "GCP.Bigtable.AppProfile",
84 AppProfileProps,
85 {
86 /** Full resource name `projects/{project}/instances/{instance}/appProfiles/{appProfile}`. */
87 name: string;
88 /** App profile id (last path segment). */
89 appProfileId: string;
90 /** Parent instance resource name. */
91 instance: string;
92 /** Instance id. */
93 instanceId: string;
94 /** Project id. */
95 project: string;
96 /** Description. */
97 description: string | undefined;
98 /** Single-cluster routing, if set. */
99 singleClusterRouting: SingleClusterRouting | undefined;
100 /** Multi-cluster routing, if set. */
101 multiClusterRouting: MultiClusterRouting | undefined;
102 /** Standard isolation, if set. */
103 standardIsolation: StandardIsolation | undefined;
104 /** Server etag. */
105 etag: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * A Cloud Bigtable app profile — how client traffic is routed across
113 * clusters in an instance.
114 *
115 * The parent instance must already exist. Every instance has a built-in
116 * `default` profile; this resource creates additional profiles.
117 * Changing `appProfileId` or `instance` replaces the profile.
118 * Description, routing, and isolation update in place.
119 *
120 * App profiles have no labels field. Alchemy treats a profile as owned
121 * when its parent instance carries Alchemy labels, so `list` /
122 * `pnpm nuke:gcp` can find it. The built-in `default` profile is never
123 * listed.
124 *
125 * ### Creating an App Profile
126 * **Example:** Multi-cluster routing
127 * ```typescript
128 * const instance = yield* GCP.Bigtable.Instance("Data", {});
129 * const profile = yield* GCP.Bigtable.AppProfile("Analytics", {
130 * instance: instance.name,
131 * description: "analytics reads",
132 * multiClusterRouting: {},
133 * });
134 * ```
135 *
136 * **Example:** Single-cluster routing
137 * ```typescript
138 * const profile = yield* GCP.Bigtable.AppProfile("Writes", {
139 * instance: instance.name,
140 * singleClusterRouting: {
141 * clusterId: "cluster",
142 * allowTransactionalWrites: true,
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 * @category Bigtable
149 */
150export const AppProfile = Resource<AppProfile>("GCP.Bigtable.AppProfile");
151
152export class AppProfileNotResolved extends Data.TaggedError(
153 "GCP.Bigtable.AppProfileNotResolved",
154)<{
155 name: string;
156}> {}
157
158const DEFAULT_APP_PROFILE_ID = "default";
159
160const toId = (
161 id: string,
162 appProfileId: string | undefined,
163 existing?: string,
164) => toPhysicalId(id, appProfileId, existing, MAX_APP_PROFILE_ID_LENGTH);
165
166const routingOf = (profile: bigtable.AppProfile | AppProfileProps) => {
167 const single =
168 "singleClusterRouting" in profile
169 ? profile.singleClusterRouting
170 : undefined;
171 const multi =
172 "multiClusterRoutingUseAny" in profile
173 ? profile.multiClusterRoutingUseAny
174 : "multiClusterRouting" in profile
175 ? profile.multiClusterRouting
176 : undefined;
177 return { single, multi };
178};
179
180const routingKey = (profile: bigtable.AppProfile | AppProfileProps) => {
181 const { single, multi } = routingOf(profile);
182 if (single) {
183 return JSON.stringify({
184 kind: "single",
185 clusterId: single.clusterId,
186 tx: single.allowTransactionalWrites === true,
187 });
188 }
189 return JSON.stringify({
190 kind: "multi",
191 clusterIds: [...(multi?.clusterIds ?? [])].sort(),
192 });
193};
194
195const isolationKey = (
196 isolation: StandardIsolation | bigtable.StandardIsolation | undefined,
197) => JSON.stringify({ priority: (isolation?.priority ?? "").toUpperCase() });
198
199const toAttrs = (profile: bigtable.AppProfile, project: string) => {
200 const name = profile.name ?? "";
201 const parsed = parseResourceName(name);
202 const single = profile.singleClusterRouting
203 ? {
204 clusterId: profile.singleClusterRouting.clusterId ?? "",
205 allowTransactionalWrites:
206 profile.singleClusterRouting.allowTransactionalWrites === true,
207 }
208 : undefined;
209 const multi = profile.multiClusterRoutingUseAny
210 ? { clusterIds: profile.multiClusterRoutingUseAny.clusterIds }
211 : undefined;
212 return {
213 name,
214 appProfileId: parsed.appProfileId,
215 instance: parsed.instance,
216 instanceId: parsed.instanceId,
217 project: parsed.project || project,
218 description: profile.description,
219 singleClusterRouting: single,
220 multiClusterRouting: multi,
221 standardIsolation: profile.standardIsolation
222 ? { priority: profile.standardIsolation.priority }
223 : undefined,
224 etag: profile.etag,
225 };
226};
227
228const getByName = (name: string) =>
229 bigtable
230 .getProjectsInstancesAppProfiles({ name })
231 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
232
233const toBody = (news: AppProfileProps): bigtable.AppProfile => {
234 const body: bigtable.AppProfile = {
235 description: news.description,
236 standardIsolation: news.standardIsolation,
237 };
238 if (news.singleClusterRouting !== undefined) {
239 body.singleClusterRouting = news.singleClusterRouting;
240 } else {
241 body.multiClusterRoutingUseAny = {
242 clusterIds: news.multiClusterRouting?.clusterIds,
243 };
244 }
245 return body;
246};
247
248export const AppProfileProvider = () =>
249 Provider.succeed(AppProfile, {
250 stables: ["name", "appProfileId", "instance", "instanceId", "project"],
251
252 diff: Effect.fn(function* ({ news, olds, output }) {
253 if (!isResolved(news)) return undefined;
254 const previousId = olds?.appProfileId ?? output?.appProfileId;
255 const nextId = news.appProfileId ?? previousId;
256 const previousInstance = olds?.instance ?? output?.instance;
257 const previousInstanceId = previousInstance
258 ? parseResourceName(
259 previousInstance.includes("/instances/")
260 ? previousInstance
261 : `projects/_/instances/${previousInstance}`,
262 ).instanceId
263 : output?.instanceId;
264 const nextInstanceId = parseResourceName(
265 news.instance.includes("/instances/")
266 ? news.instance
267 : `projects/_/instances/${news.instance}`,
268 ).instanceId;
269 if (
270 (previousId !== undefined &&
271 nextId !== undefined &&
272 previousId !== nextId) ||
273 (previousInstanceId !== undefined &&
274 previousInstanceId !== nextInstanceId)
275 ) {
276 return { action: "replace" as const };
277 }
278 return undefined;
279 }),
280
281 read: Effect.fn(function* ({ id, olds, output }) {
282 const env = yield* GcpEnvironment.current;
283 const appProfileId = yield* toId(
284 id,
285 olds?.appProfileId,
286 output?.appProfileId,
287 );
288 const instanceRef = olds?.instance ?? output?.instance;
289 const name =
290 output?.name ??
291 (instanceRef
292 ? `${instanceNameOf(env.project, instanceRef)}/appProfiles/${appProfileId}`
293 : undefined);
294 if (name === undefined) return undefined;
295 const existing = yield* getByName(name);
296 if (existing === undefined) return undefined;
297 const attrs = toAttrs(existing, env.project);
298 return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs);
299 }),
300
301 list: () =>
302 Effect.gen(function* () {
303 const env = yield* GcpEnvironment.current;
304 const owned = new Set(
305 (yield* listAlchemyInstances(env.project)).map(
306 (instance) => instance.name ?? "",
307 ),
308 );
309 const appProfiles = yield* collectPages(
310 bigtable.listProjectsInstancesAppProfiles.pages({
311 parent: `projects/${env.project}/instances/-`,
312 pageSize: 1000,
313 }),
314 (page) => page.appProfiles,
315 );
316 return appProfiles
317 .filter((profile) => {
318 const parsed = parseResourceName(profile.name ?? "");
319 return (
320 parsed.appProfileId !== DEFAULT_APP_PROFILE_ID &&
321 owned.has(parsed.instance)
322 );
323 })
324 .map((profile) => toAttrs(profile, env.project));
325 }),
326
327 reconcile: Effect.fn(function* ({ id, news, output }) {
328 const env = yield* GcpEnvironment.current;
329 const appProfileId = yield* toId(
330 id,
331 news.appProfileId,
332 output?.appProfileId,
333 );
334 const parent = instanceNameOf(env.project, news.instance);
335 const name = `${parent}/appProfiles/${appProfileId}`;
336 const ignoreWarnings = news.ignoreWarnings ?? true;
337 const desired = toBody(news);
338
339 let current = yield* getByName(name);
340
341 if (current === undefined) {
342 const created = yield* bigtable
343 .createProjectsInstancesAppProfiles({
344 parent,
345 appProfileId,
346 ignoreWarnings,
347 body: desired,
348 })
349 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
350 current = created ?? undefined;
351 }
352
353 if (current === undefined) {
354 return yield* new AppProfileNotResolved({ name });
355 }
356
357 const descriptionChanged =
358 (news.description ?? "") !== (current.description ?? "");
359 const routingChanged = routingKey(news) !== routingKey(current);
360 const isolationChanged =
361 news.standardIsolation !== undefined &&
362 isolationKey(news.standardIsolation) !==
363 isolationKey(current.standardIsolation);
364
365 if (descriptionChanged || routingChanged || isolationChanged) {
366 const mask = [
367 descriptionChanged ? "description" : undefined,
368 routingChanged
369 ? news.singleClusterRouting !== undefined
370 ? "single_cluster_routing"
371 : "multi_cluster_routing_use_any"
372 : undefined,
373 isolationChanged ? "standard_isolation" : undefined,
374 ].filter((field): field is string => field !== undefined);
375 const patched = yield* bigtable
376 .patchProjectsInstancesAppProfiles({
377 name,
378 updateMask: mask.join(","),
379 ignoreWarnings,
380 body: { ...desired, etag: current.etag },
381 })
382 .pipe(
383 Effect.retry({
384 while: (error) => error._tag === "Conflict",
385 times: 8,
386 schedule: Schedule.spaced("1 second"),
387 }),
388 );
389 yield* waitForOperation(patched);
390 current = yield* getByName(name);
391 if (current === undefined) {
392 return yield* new AppProfileNotResolved({ name });
393 }
394 }
395
396 return toAttrs(current, env.project);
397 }),
398
399 delete: Effect.fn(function* ({ output, olds }) {
400 const ignoreWarnings = olds?.ignoreWarnings ?? true;
401 yield* bigtable
402 .deleteProjectsInstancesAppProfiles({
403 name: output.name,
404 ignoreWarnings,
405 })
406 .pipe(Effect.catchTag("NotFound", () => Effect.void));
407 }),
408 });