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 instanceIdOf,
13 listAlchemyTables,
14 MAX_AUTHORIZED_VIEW_ID_LENGTH,
15 parentOwned,
16 parseResourceName,
17 tableIdOf,
18 tableNameOf,
19 toPhysicalId,
20 waitForOperation,
21 collectPages,
22} from "./operations.ts";
23
24export type FamilySubsets = {
25 /** Exact column qualifiers included in the view (base64 bytes). */
26 qualifiers?: string[];
27 /**
28 * Qualifier prefixes included in the view. The empty string includes
29 * every qualifier in the family.
30 */
31 qualifierPrefixes?: string[];
32};
33
34export type SubsetView = {
35 /**
36 * Row-key prefixes included in the view (base64 bytes). The empty
37 * string includes every row.
38 */
39 rowPrefixes?: string[];
40 /** Column families included in the view, keyed by family id. */
41 familySubsets?: Record<string, FamilySubsets>;
42};
43
44export type InstancesTablesAuthorizedViewProps = {
45 /**
46 * Parent instance. Full name `projects/{project}/instances/{instance}`
47 * or the instance id. Immutable — changing it replaces the view.
48 */
49 instance: string;
50 /**
51 * Parent table. Full name
52 * `projects/{project}/instances/{instance}/tables/{table}` or the table
53 * id. Immutable — changing it replaces the view.
54 */
55 table: string;
56 /**
57 * Authorized view id (the `{authorized_view}` segment of
58 * `.../tables/{table}/authorizedViews/{authorized_view}`). If omitted,
59 * a unique name is generated from the stack, stage, and logical id.
60 * Immutable — changing it replaces the view.
61 */
62 authorizedViewId?: string;
63 /**
64 * Subset of the table this view exposes.
65 */
66 subsetView?: SubsetView;
67 /**
68 * Protect the view (and its table/instance) from Admin API deletes.
69 * Disabled automatically on destroy.
70 * @default false
71 */
72 deletionProtection?: boolean;
73 /**
74 * Ignore safety checks on update.
75 * @default true
76 */
77 ignoreWarnings?: boolean;
78};
79
80export type InstancesTablesAuthorizedView = Resource<
81 "GCP.Bigtable.InstancesTablesAuthorizedView",
82 InstancesTablesAuthorizedViewProps,
83 {
84 /** Full resource name `.../tables/{table}/authorizedViews/{authorized_view}`. */
85 name: string;
86 /** Authorized view id (last path segment). */
87 authorizedViewId: string;
88 /** Parent table resource name. */
89 table: string;
90 /** Parent table id. */
91 tableId: string;
92 /** Parent instance resource name. */
93 instance: string;
94 /** Parent instance id. */
95 instanceId: string;
96 /** Project id. */
97 project: string;
98 /** Subset of the table currently exposed. */
99 subsetView: SubsetView | undefined;
100 /** Whether Admin API deletes are blocked. */
101 deletionProtection: boolean;
102 /** Server etag. */
103 etag: string | undefined;
104 },
105 never,
106 Providers
107>;
108
109/**
110 * A Cloud Bigtable authorized view — a named subset of a table used for
111 * fine-grained IAM.
112 *
113 * The parent instance and table must already exist. Changing
114 * `authorizedViewId`, `instance`, or `table` replaces the view.
115 * `subsetView` and `deletionProtection` update in place.
116 *
117 * Authorized views have no labels field. Alchemy treats a view as owned
118 * when its parent instance carries Alchemy labels, so `list` /
119 * `pnpm nuke:gcp` can find it.
120 *
121 * ### Creating an Authorized View
122 * **Example:** All rows of one column family
123 * ```typescript
124 * const view = yield* GCP.Bigtable.InstancesTablesAuthorizedView("Public", {
125 * instance: instance.name,
126 * table: table.name,
127 * subsetView: {
128 * rowPrefixes: [""],
129 * familySubsets: {
130 * cf: { qualifierPrefixes: [""] },
131 * },
132 * },
133 * });
134 * ```
135 *
136 * @resource
137 * @category Bigtable
138 */
139export const InstancesTablesAuthorizedView =
140 Resource<InstancesTablesAuthorizedView>(
141 "GCP.Bigtable.InstancesTablesAuthorizedView",
142 );
143
144export class AuthorizedViewNotResolved extends Data.TaggedError(
145 "GCP.Bigtable.AuthorizedViewNotResolved",
146)<{
147 name: string;
148}> {}
149
150export class AuthorizedViewStillExists extends Data.TaggedError(
151 "GCP.Bigtable.AuthorizedViewStillExists",
152)<{
153 name: string;
154}> {}
155
156const toId = (
157 id: string,
158 authorizedViewId: string | undefined,
159 existing?: string,
160) =>
161 toPhysicalId(id, authorizedViewId, existing, MAX_AUTHORIZED_VIEW_ID_LENGTH);
162
163const familyOf = (
164 family:
165 | FamilySubsets
166 | bigtable.GoogleBigtableAdminV2AuthorizedViewFamilySubsets,
167): FamilySubsets => ({
168 qualifiers: family.qualifiers,
169 qualifierPrefixes: family.qualifierPrefixes,
170});
171
172const subsetOf = (
173 view:
174 | SubsetView
175 | bigtable.GoogleBigtableAdminV2AuthorizedViewSubsetView
176 | undefined,
177): SubsetView | undefined => {
178 if (view === undefined) return undefined;
179 const familySubsets: Record<string, FamilySubsets> = {};
180 for (const [id, family] of Object.entries(view.familySubsets ?? {})) {
181 if (family === undefined) continue;
182 familySubsets[id] = familyOf(family);
183 }
184 return {
185 rowPrefixes: view.rowPrefixes,
186 familySubsets:
187 Object.keys(familySubsets).length > 0 ? familySubsets : undefined,
188 };
189};
190
191const subsetKey = (view: SubsetView | undefined) => {
192 const families = Object.entries(view?.familySubsets ?? {})
193 .sort(([left], [right]) => left.localeCompare(right))
194 .map(([id, family]) => [
195 id,
196 {
197 qualifierPrefixes: [...(family?.qualifierPrefixes ?? [])].sort(),
198 qualifiers: [...(family?.qualifiers ?? [])].sort(),
199 },
200 ]);
201 return JSON.stringify({
202 rowPrefixes: [...(view?.rowPrefixes ?? [])].sort(),
203 familySubsets: families,
204 });
205};
206
207const toAttrs = (view: bigtable.AuthorizedView, project: string) => {
208 const name = view.name ?? "";
209 const parsed = parseResourceName(name);
210 return {
211 name,
212 authorizedViewId: parsed.authorizedViewId,
213 table: parsed.table,
214 tableId: parsed.tableId,
215 instance: parsed.instance,
216 instanceId: parsed.instanceId,
217 project: parsed.project || project,
218 subsetView: subsetOf(view.subsetView),
219 deletionProtection: view.deletionProtection === true,
220 etag: view.etag,
221 };
222};
223
224const getByName = (name: string) =>
225 bigtable
226 .getProjectsInstancesTablesAuthorizedViews({ name, view: "FULL" })
227 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
228
229const waitUntilExists = (name: string) =>
230 getByName(name).pipe(
231 Effect.flatMap((view) =>
232 view
233 ? Effect.succeed(view)
234 : Effect.fail(new AuthorizedViewNotResolved({ name })),
235 ),
236 Effect.retry({
237 while: (error) => error._tag === "GCP.Bigtable.AuthorizedViewNotResolved",
238 times: 8,
239 schedule: Schedule.spaced("1 second"),
240 }),
241 );
242
243const waitUntilGone = (name: string) =>
244 getByName(name).pipe(
245 Effect.flatMap((view) =>
246 view === undefined
247 ? Effect.void
248 : Effect.fail(new AuthorizedViewStillExists({ name })),
249 ),
250 Effect.retry({
251 while: (error) => error._tag === "GCP.Bigtable.AuthorizedViewStillExists",
252 times: 8,
253 schedule: Schedule.spaced("2 seconds"),
254 }),
255 );
256
257const unprotect = (
258 name: string,
259 etag: string | undefined,
260 ignoreWarnings: boolean,
261) =>
262 Effect.gen(function* () {
263 const patched = yield* bigtable.patchProjectsInstancesTablesAuthorizedViews(
264 {
265 name,
266 updateMask: "deletion_protection",
267 ignoreWarnings,
268 body: { deletionProtection: false, etag },
269 },
270 );
271 yield* waitForOperation(patched);
272 });
273
274export const InstancesTablesAuthorizedViewProvider = () =>
275 Provider.succeed(InstancesTablesAuthorizedView, {
276 stables: [
277 "name",
278 "authorizedViewId",
279 "table",
280 "tableId",
281 "instance",
282 "instanceId",
283 "project",
284 ],
285
286 diff: Effect.fn(function* ({ news, olds, output }) {
287 if (!isResolved(news)) return undefined;
288 const previousId = olds?.authorizedViewId ?? output?.authorizedViewId;
289 const nextId = news.authorizedViewId ?? previousId;
290 const previousInstance = instanceIdOf(
291 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
292 );
293 const nextInstance = instanceIdOf(news.instance);
294 const previousTable = tableIdOf(
295 olds?.table ?? output?.table ?? output?.tableId ?? "",
296 );
297 const nextTable = tableIdOf(news.table);
298 if (
299 (previousId !== undefined &&
300 nextId !== undefined &&
301 previousId !== nextId) ||
302 (previousInstance.length > 0 && previousInstance !== nextInstance) ||
303 (previousTable.length > 0 && previousTable !== nextTable)
304 ) {
305 return { action: "replace" as const };
306 }
307 return undefined;
308 }),
309
310 read: Effect.fn(function* ({ id, olds, output }) {
311 const env = yield* GcpEnvironment.current;
312 const authorizedViewId = yield* toId(
313 id,
314 olds?.authorizedViewId,
315 output?.authorizedViewId,
316 );
317 const instanceRef = olds?.instance ?? output?.instance;
318 const tableRef = olds?.table ?? output?.table;
319 const name =
320 output?.name ??
321 (instanceRef && tableRef
322 ? `${tableNameOf(env.project, instanceRef, tableRef)}/authorizedViews/${authorizedViewId}`
323 : undefined);
324 if (name === undefined) return undefined;
325 const existing = yield* getByName(name);
326 if (existing === undefined) return undefined;
327 const attrs = toAttrs(existing, env.project);
328 return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs);
329 }),
330
331 list: () =>
332 Effect.gen(function* () {
333 const env = yield* GcpEnvironment.current;
334 const tables = (yield* listAlchemyTables(env.project)).filter(
335 (table): table is bigtable.Table & { name: string } =>
336 typeof table.name === "string" && table.name.length > 0,
337 );
338 const pages = yield* Effect.forEach(
339 tables,
340 (table) =>
341 collectPages(
342 bigtable.listProjectsInstancesTablesAuthorizedViews.pages({
343 parent: table.name,
344 pageSize: 1000,
345 }),
346 (page) => page.authorizedViews,
347 ).pipe(
348 Effect.catchTag("NotFound", () =>
349 Effect.succeed([] as bigtable.AuthorizedView[]),
350 ),
351 ),
352 { concurrency: 4 },
353 );
354 return pages.flat().map((view) => toAttrs(view, env.project));
355 }),
356
357 reconcile: Effect.fn(function* ({ id, news, output }) {
358 const env = yield* GcpEnvironment.current;
359 const authorizedViewId = yield* toId(
360 id,
361 news.authorizedViewId,
362 output?.authorizedViewId,
363 );
364 const parent = tableNameOf(env.project, news.instance, news.table);
365 const name = `${parent}/authorizedViews/${authorizedViewId}`;
366 const desiredProtection = news.deletionProtection === true;
367 const ignoreWarnings = news.ignoreWarnings ?? true;
368 const desiredSubset = subsetOf(news.subsetView);
369
370 let current = yield* getByName(output?.name ?? name);
371
372 if (current === undefined) {
373 const created = yield* bigtable
374 .createProjectsInstancesTablesAuthorizedViews({
375 parent,
376 authorizedViewId,
377 body: {
378 subsetView: desiredSubset,
379 deletionProtection: desiredProtection,
380 },
381 })
382 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
383 if (created !== undefined) {
384 yield* waitForOperation(created, { alreadyExistsOk: true });
385 }
386 current = yield* waitUntilExists(name);
387 }
388
389 const observedSubset = subsetOf(current.subsetView);
390 const subsetChanged =
391 news.subsetView !== undefined &&
392 subsetKey(observedSubset) !== subsetKey(desiredSubset);
393 const protectionChanged =
394 (current.deletionProtection === true) !== desiredProtection;
395
396 if (subsetChanged || protectionChanged) {
397 const mask = [
398 subsetChanged ? "subset_view" : undefined,
399 protectionChanged ? "deletion_protection" : undefined,
400 ].filter((field): field is string => field !== undefined);
401 const patched = yield* bigtable
402 .patchProjectsInstancesTablesAuthorizedViews({
403 name,
404 updateMask: mask.join(","),
405 ignoreWarnings,
406 body: {
407 subsetView: desiredSubset,
408 deletionProtection: desiredProtection,
409 etag: current.etag,
410 },
411 })
412 .pipe(
413 Effect.retry({
414 while: (error) => error._tag === "Conflict",
415 times: 8,
416 schedule: Schedule.spaced("1 second"),
417 }),
418 );
419 yield* waitForOperation(patched);
420 current = yield* getByName(name);
421 if (current === undefined) {
422 return yield* new AuthorizedViewNotResolved({ name });
423 }
424 }
425
426 return toAttrs(current, env.project);
427 }),
428
429 delete: Effect.fn(function* ({ output, olds }) {
430 const ignoreWarnings = olds?.ignoreWarnings ?? true;
431 const current = yield* getByName(output.name);
432 if (current === undefined) return;
433 if (current.deletionProtection === true) {
434 yield* unprotect(output.name, current.etag, ignoreWarnings);
435 }
436 yield* bigtable
437 .deleteProjectsInstancesTablesAuthorizedViews({
438 name: output.name,
439 })
440 .pipe(
441 Effect.catchTag("NotFound", () => Effect.void),
442 Effect.retry({
443 while: (error) => error._tag === "Conflict",
444 times: 8,
445 schedule: Schedule.spaced("2 seconds"),
446 }),
447 );
448 yield* waitUntilGone(output.name);
449 }),
450 });