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 instanceNameOf,
14 listAlchemyInstances,
15 logicalViewName,
16 MAX_LOGICAL_VIEW_ID_LENGTH,
17 parentOwned,
18 parseResourceName,
19 toPhysicalId,
20 waitForOperation,
21 collectPages,
22} from "./operations.ts";
23
24export type InstancesLogicalViewProps = {
25 /**
26 * Parent instance. Full name `projects/{project}/instances/{instance}`
27 * or the instance id. Immutable — changing it replaces the view.
28 */
29 instance: string;
30 /**
31 * Logical view id (the `{logical_view}` segment of
32 * `.../instances/{instance}/logicalViews/{logical_view}`). If omitted,
33 * a unique name is generated from the stack, stage, and logical id.
34 * Must be unique among table ids and view ids in the instance.
35 * Immutable — changing it replaces the view.
36 */
37 logicalViewId?: string;
38 /**
39 * GoogleSQL SELECT query that defines the view. `SELECT *` is not
40 * allowed. Must reference a table in the same instance.
41 */
42 query: string;
43 /**
44 * Protect the view from Admin API deletes. Disabled automatically on
45 * destroy.
46 * @default false
47 */
48 deletionProtection?: boolean;
49};
50
51export type InstancesLogicalView = Resource<
52 "GCP.Bigtable.InstancesLogicalView",
53 InstancesLogicalViewProps,
54 {
55 /** Full resource name `.../instances/{instance}/logicalViews/{logical_view}`. */
56 name: string;
57 /** Logical view id (last path segment). */
58 logicalViewId: string;
59 /** Parent instance resource name. */
60 instance: string;
61 /** Parent instance id. */
62 instanceId: string;
63 /** Project id. */
64 project: string;
65 /** Defining SELECT query. */
66 query: string | undefined;
67 /** Whether Admin API deletes are blocked. */
68 deletionProtection: boolean;
69 /** Server etag. */
70 etag: string | undefined;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * A Cloud Bigtable logical view — a SQL SELECT over a table that can
78 * be queried as a virtual table.
79 *
80 * The parent instance must already exist and the query cannot use
81 * `SELECT *`. Changing `logicalViewId` or `instance` replaces the view.
82 * `query` and `deletionProtection` update in place.
83 *
84 * Logical views have no labels field. Alchemy treats a view as owned
85 * when its parent instance carries Alchemy labels, so `list` /
86 * `pnpm nuke:gcp` can find it.
87 *
88 * ### Creating a Logical View
89 * **Example:** View over one table
90 * ```typescript
91 * const users = yield* GCP.Bigtable.Table("Users", {
92 * instance: instance.name,
93 * columnFamilies: { cf: {} },
94 * });
95 * const view = yield* GCP.Bigtable.InstancesLogicalView("Active", {
96 * instance: instance.name,
97 * query: "SELECT _key FROM users",
98 * });
99 * ```
100 *
101 * @resource
102 * @category Bigtable
103 */
104export const InstancesLogicalView = Resource<InstancesLogicalView>(
105 "GCP.Bigtable.InstancesLogicalView",
106);
107
108export class LogicalViewNotResolved extends Data.TaggedError(
109 "GCP.Bigtable.LogicalViewNotResolved",
110)<{
111 name: string;
112}> {}
113
114export class LogicalViewStillExists extends Data.TaggedError(
115 "GCP.Bigtable.LogicalViewStillExists",
116)<{
117 name: string;
118}> {}
119
120const toId = (
121 id: string,
122 logicalViewId: string | undefined,
123 existing?: string,
124) => toPhysicalId(id, logicalViewId, existing, MAX_LOGICAL_VIEW_ID_LENGTH);
125
126const queryOf = (value: string | undefined) => (value ?? "").trim();
127
128const toAttrs = (view: bigtable.LogicalView, project: string) => {
129 const name = view.name ?? "";
130 const parsed = parseResourceName(name);
131 return {
132 name,
133 logicalViewId: parsed.logicalViewId,
134 instance: parsed.instance,
135 instanceId: parsed.instanceId,
136 project: parsed.project || project,
137 query: view.query,
138 deletionProtection: view.deletionProtection === true,
139 etag: view.etag,
140 };
141};
142
143const getByName = (name: string) =>
144 bigtable
145 .getProjectsInstancesLogicalViews({ name })
146 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
147
148const waitUntilExists = (name: string) =>
149 getByName(name).pipe(
150 Effect.flatMap((view) =>
151 view
152 ? Effect.succeed(view)
153 : Effect.fail(new LogicalViewNotResolved({ name })),
154 ),
155 Effect.retry({
156 while: (error) => error._tag === "GCP.Bigtable.LogicalViewNotResolved",
157 times: 8,
158 schedule: Schedule.spaced("1 second"),
159 }),
160 );
161
162const waitUntilGone = (name: string) =>
163 getByName(name).pipe(
164 Effect.flatMap((view) =>
165 view === undefined
166 ? Effect.void
167 : Effect.fail(new LogicalViewStillExists({ name })),
168 ),
169 Effect.retry({
170 while: (error) => error._tag === "GCP.Bigtable.LogicalViewStillExists",
171 times: 8,
172 schedule: Schedule.spaced("2 seconds"),
173 }),
174 );
175
176const unprotect = (name: string, etag: string | undefined) =>
177 Effect.gen(function* () {
178 const patched = yield* bigtable.patchProjectsInstancesLogicalViews({
179 name,
180 updateMask: "deletion_protection",
181 body: { deletionProtection: false, etag },
182 });
183 yield* waitForOperation(patched);
184 });
185
186export const InstancesLogicalViewProvider = () =>
187 Provider.succeed(InstancesLogicalView, {
188 stables: ["name", "logicalViewId", "instance", "instanceId", "project"],
189
190 diff: Effect.fn(function* ({ news, olds, output }) {
191 if (!isResolved(news)) return undefined;
192 const previousId = olds?.logicalViewId ?? output?.logicalViewId;
193 const nextId = news.logicalViewId ?? previousId;
194 const previousInstance = instanceIdOf(
195 olds?.instance ?? output?.instance ?? output?.instanceId ?? "",
196 );
197 const nextInstance = instanceIdOf(news.instance);
198 if (
199 (previousId !== undefined &&
200 nextId !== undefined &&
201 previousId !== nextId) ||
202 (previousInstance.length > 0 && previousInstance !== nextInstance)
203 ) {
204 return { action: "replace" as const };
205 }
206 return undefined;
207 }),
208
209 read: Effect.fn(function* ({ id, olds, output }) {
210 const env = yield* GcpEnvironment.current;
211 const logicalViewId = yield* toId(
212 id,
213 olds?.logicalViewId,
214 output?.logicalViewId,
215 );
216 const instanceRef = olds?.instance ?? output?.instance;
217 const name =
218 output?.name ??
219 (instanceRef
220 ? logicalViewName(
221 env.project,
222 instanceIdOf(instanceRef),
223 logicalViewId,
224 )
225 : undefined);
226 if (name === undefined) return undefined;
227 const existing = yield* getByName(name);
228 if (existing === undefined) return undefined;
229 const attrs = toAttrs(existing, env.project);
230 return (yield* parentOwned(attrs.instance)) ? attrs : Unowned(attrs);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const instances = (yield* listAlchemyInstances(env.project)).filter(
237 (instance): instance is bigtable.Instance & { name: string } =>
238 typeof instance.name === "string" && instance.name.length > 0,
239 );
240 const pages = yield* Effect.forEach(
241 instances,
242 (instance) =>
243 collectPages(
244 bigtable.listProjectsInstancesLogicalViews.pages({
245 parent: instance.name,
246 pageSize: 1000,
247 }),
248 (page) => page.logicalViews,
249 ).pipe(
250 Effect.catchTag("NotFound", () =>
251 Effect.succeed([] as bigtable.LogicalView[]),
252 ),
253 ),
254 { concurrency: 4 },
255 );
256 return pages.flat().map((view) => toAttrs(view, env.project));
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output }) {
260 const env = yield* GcpEnvironment.current;
261 const logicalViewId = yield* toId(
262 id,
263 news.logicalViewId,
264 output?.logicalViewId,
265 );
266 const parent = instanceNameOf(env.project, news.instance);
267 const name = `${parent}/logicalViews/${logicalViewId}`;
268 const desiredProtection = news.deletionProtection === true;
269 const query = news.query;
270
271 let current = yield* getByName(output?.name ?? name);
272
273 if (current === undefined) {
274 const created = yield* bigtable
275 .createProjectsInstancesLogicalViews({
276 parent,
277 logicalViewId,
278 body: {
279 query,
280 deletionProtection: desiredProtection,
281 },
282 })
283 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
284 if (created !== undefined) {
285 yield* waitForOperation(created, { alreadyExistsOk: true });
286 }
287 current = yield* waitUntilExists(name);
288 }
289
290 const queryChanged = queryOf(current.query) !== queryOf(query);
291 const protectionChanged =
292 (current.deletionProtection === true) !== desiredProtection;
293 if (queryChanged || protectionChanged) {
294 const mask = [
295 queryChanged ? "query" : undefined,
296 protectionChanged ? "deletion_protection" : undefined,
297 ].filter((field): field is string => field !== undefined);
298 const patched = yield* bigtable
299 .patchProjectsInstancesLogicalViews({
300 name,
301 updateMask: mask.join(","),
302 body: {
303 query,
304 deletionProtection: desiredProtection,
305 etag: current.etag,
306 },
307 })
308 .pipe(
309 Effect.retry({
310 while: (error) => error._tag === "Conflict",
311 times: 8,
312 schedule: Schedule.spaced("1 second"),
313 }),
314 );
315 yield* waitForOperation(patched);
316 current = yield* getByName(name);
317 if (current === undefined) {
318 return yield* new LogicalViewNotResolved({ name });
319 }
320 }
321
322 return toAttrs(current, env.project);
323 }),
324
325 delete: Effect.fn(function* ({ output }) {
326 const current = yield* getByName(output.name);
327 if (current === undefined) return;
328 if (current.deletionProtection === true) {
329 yield* unprotect(output.name, current.etag);
330 }
331 yield* bigtable
332 .deleteProjectsInstancesLogicalViews({
333 name: output.name,
334 })
335 .pipe(
336 Effect.catchTag("NotFound", () => Effect.void),
337 Effect.retry({
338 while: (error) => error._tag === "Conflict",
339 times: 8,
340 schedule: Schedule.spaced("2 seconds"),
341 }),
342 );
343 yield* waitUntilGone(output.name);
344 }),
345 });