1import * as analyticshub from "@distilled.cloud/gcp/analyticshub_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 AnalyticshubNotResolved,
11 displayNameOf,
12 encodeDescription,
13 expandParent,
14 deleteRetry,
15 hasOwnershipMarker,
16 listChildResources,
17 listExchangesInProject,
18 listQueryTemplates,
19 namedOf,
20 normalizeLocation,
21 ownedById,
22 ownershipLabels,
23 parseDescription,
24 parseResourceName,
25 replaceOnIdentity,
26 retryTransient,
27 sameText,
28 toPhysicalId,
29 updateMaskOf,
30} from "./internal.ts";
31
32export type QueryTemplateRoutine = analyticshub.Routine;
33export type QueryTemplateState =
34 | analyticshub.QueryTemplateStateEnum
35 | (string & {});
36
37export type DataExchangesQueryTemplateProps = {
38 /**
39 * Parent data exchange. Full name
40 * `projects/{project}/locations/{location}/dataExchanges/{dataExchange}`
41 * or the exchange id (combined with `location`). Query templates live
42 * in data clean room exchanges. Immutable — changing the parent
43 * replaces the template.
44 */
45 dataExchange: string;
46 /**
47 * Query template id (the `{queryTemplate}` segment of
48 * `.../dataExchanges/{dataExchange}/queryTemplates/{queryTemplate}`).
49 * If omitted, a unique id is generated. Letters, numbers, and
50 * underscores; max 100 bytes. Immutable — changing it replaces the
51 * template.
52 */
53 queryTemplateId?: string;
54 /**
55 * Location used when `dataExchange` is a bare id.
56 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
57 */
58 location?: string;
59 /**
60 * Human-readable display name. Max 63 bytes. Defaults to the template
61 * id. Immutable — changing it replaces the template.
62 */
63 displayName?: string;
64 /**
65 * Short description (max 2000 bytes). Query templates have no labels
66 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
67 * stripped from attributes.
68 */
69 description?: string;
70 /**
71 * Documentation describing the query template.
72 */
73 documentation?: string;
74 /**
75 * Email or URL of the primary point of contact. Max 1000 bytes.
76 */
77 primaryContact?: string;
78 /**
79 * Email or URL of the proposer. Max 1000 bytes. Will be deprecated in
80 * favor of `primaryContact`.
81 */
82 proposer?: string;
83 /**
84 * Table-valued function associated with the template.
85 */
86 routine?: QueryTemplateRoutine;
87};
88
89export type DataExchangesQueryTemplate = Resource<
90 "GCP.AnalyticsHub.DataExchangesQueryTemplate",
91 DataExchangesQueryTemplateProps,
92 {
93 /** Full resource name. */
94 name: string;
95 /** Query template id (last path segment). */
96 queryTemplateId: string;
97 /** Parent data exchange resource name. */
98 dataExchange: string;
99 /** Project id. */
100 project: string;
101 /** Location id. */
102 location: string;
103 /** Display name. */
104 displayName: string | undefined;
105 /** User description (Alchemy ownership marker stripped). */
106 description: string | undefined;
107 /** Documentation. */
108 documentation: string | undefined;
109 /** Primary contact. */
110 primaryContact: string | undefined;
111 /** Proposer contact. */
112 proposer: string | undefined;
113 /** Associated routine. */
114 routine: QueryTemplateRoutine | undefined;
115 /** Lifecycle state (`DRAFTED`, `PENDING`, `APPROVED`, …). */
116 state: string | undefined;
117 /** RFC3339 creation timestamp. */
118 createTime: string | undefined;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * An Analytics Hub query template — a shared table-valued function
128 * defined by contributors in a data clean room.
129 *
130 * Parent exchange, location, template id, and display name are
131 * immutable. Description, documentation, contacts, and the routine
132 * body update in place. Create the parent exchange with
133 * `sharingEnvironmentConfig: { dcrExchangeConfig: {} }`.
134 *
135 * Query templates have no labels. Alchemy stamps ownership into the
136 * description so `list` / `pnpm nuke:gcp` can find them.
137 *
138 * ### Creating a Query Template
139 * **Example:** Draft TVF in a data clean room
140 * ```typescript
141 * const dcr = yield* GCP.AnalyticsHub.DataExchange("CleanRoom", {
142 * displayName: "Clean Room",
143 * sharingEnvironmentConfig: { dcrExchangeConfig: {} },
144 * });
145 * const template = yield* GCP.AnalyticsHub.DataExchangesQueryTemplate(
146 * "Counts",
147 * {
148 * dataExchange: dcr.name,
149 * displayName: "Counts",
150 * routine: {
151 * routineType: "TABLE_VALUED_FUNCTION",
152 * definitionBody: "SELECT 1 AS n",
153 * },
154 * },
155 * );
156 * ```
157 *
158 * @resource
159 * @category AnalyticsHub
160 */
161export const DataExchangesQueryTemplate = Resource<DataExchangesQueryTemplate>(
162 "GCP.AnalyticsHub.DataExchangesQueryTemplate",
163);
164
165const parentExchange = (
166 dataExchange: string,
167 project: string,
168 location: string,
169) => expandParent(dataExchange, project, location, "dataExchanges");
170
171const resourceName = (dataExchange: string, queryTemplateId: string) =>
172 `${dataExchange}/queryTemplates/${queryTemplateId}`;
173
174const desiredRoutine = (
175 queryTemplateId: string,
176 routine: QueryTemplateRoutine | undefined,
177): QueryTemplateRoutine | undefined => {
178 if (routine === undefined) return undefined;
179 const body = routine.definitionBody ?? "";
180 const definitionBody =
181 body.startsWith(`${queryTemplateId}(`) ||
182 body.startsWith(`${queryTemplateId} `)
183 ? body
184 : `${queryTemplateId}() AS (${body})`;
185 return { ...routine, definitionBody };
186};
187
188const toAttrs = (template: analyticshub.QueryTemplate, project: string) => {
189 const name = template.name ?? "";
190 const parsed = parseResourceName(name, "queryTemplates");
191 const { description } = parseDescription(template.description);
192 return {
193 name,
194 queryTemplateId: parsed.id,
195 dataExchange: parsed.parent,
196 project: parsed.project || project,
197 location: parsed.location,
198 displayName: template.displayName,
199 description,
200 documentation: template.documentation,
201 primaryContact: template.primaryContact,
202 proposer: template.proposer,
203 routine: template.routine,
204 state: template.state,
205 createTime: template.createTime,
206 updateTime: template.updateTime,
207 };
208};
209
210const getByName = (name: string) =>
211 Effect.gen(function* () {
212 if (name.length === 0) return undefined;
213 const parsed = parseResourceName(name, "queryTemplates");
214 const items = yield* listQueryTemplates(parsed.parent);
215 return items.find((item) => (item.name ?? "") === name);
216 });
217
218export const DataExchangesQueryTemplateProvider = () =>
219 Provider.succeed(DataExchangesQueryTemplate, {
220 stables: [
221 "name",
222 "queryTemplateId",
223 "dataExchange",
224 "project",
225 "location",
226 "createTime",
227 "displayName",
228 ],
229
230 diff: Effect.fn(function* ({ news, olds, output }) {
231 if (!isResolved(news)) return undefined;
232 const env = yield* GcpEnvironment.current;
233 const location = normalizeLocation(
234 news.location ?? olds?.location ?? output?.location,
235 env.region,
236 );
237 const previousDisplay = olds?.displayName ?? output?.displayName;
238 const nextDisplay = news.displayName;
239 const previousDocs = olds?.documentation ?? output?.documentation;
240 const nextDocs = news.documentation;
241 return replaceOnIdentity({
242 previousId: olds?.queryTemplateId ?? output?.queryTemplateId,
243 nextId: news.queryTemplateId,
244 previousLocation: normalizeLocation(
245 olds?.location ?? output?.location,
246 env.region,
247 ),
248 nextLocation: location,
249 previousParent: olds?.dataExchange ?? output?.dataExchange,
250 nextParent: parentExchange(news.dataExchange, env.project, location),
251 extra:
252 (previousDisplay !== undefined &&
253 nextDisplay !== undefined &&
254 previousDisplay !== nextDisplay) ||
255 (previousDocs !== undefined &&
256 nextDocs !== undefined &&
257 previousDocs !== nextDocs),
258 });
259 }),
260
261 read: Effect.fn(function* ({ id, olds, output }) {
262 const env = yield* GcpEnvironment.current;
263 const location = normalizeLocation(
264 olds?.location ?? output?.location,
265 env.region,
266 );
267 const queryTemplateId = yield* toPhysicalId(
268 id,
269 olds?.queryTemplateId,
270 output?.queryTemplateId,
271 );
272 const dataExchange =
273 olds?.dataExchange !== undefined
274 ? parentExchange(olds.dataExchange, env.project, location)
275 : (output?.dataExchange ?? "");
276 const name =
277 output?.name ??
278 (dataExchange ? resourceName(dataExchange, queryTemplateId) : "");
279 const existing = yield* getByName(name);
280 if (existing === undefined) return undefined;
281 const attrs = toAttrs(existing, env.project);
282 return (yield* ownedById(id, existing.description))
283 ? attrs
284 : Unowned(attrs);
285 }),
286
287 list: () =>
288 Effect.gen(function* () {
289 const env = yield* GcpEnvironment.current;
290 const exchanges = yield* listExchangesInProject(
291 env.project,
292 env.region,
293 );
294 const templates = yield* listChildResources(
295 namedOf(exchanges),
296 listQueryTemplates,
297 );
298 return templates
299 .filter((item) => hasOwnershipMarker(item.description))
300 .map((item) => toAttrs(item, env.project));
301 }),
302
303 reconcile: Effect.fn(function* ({ id, news, output }) {
304 const env = yield* GcpEnvironment.current;
305 const location = normalizeLocation(
306 news.location ?? output?.location,
307 env.region,
308 );
309 const dataExchange = parentExchange(
310 news.dataExchange,
311 env.project,
312 location,
313 );
314 const queryTemplateId = yield* toPhysicalId(
315 id,
316 news.queryTemplateId,
317 output?.queryTemplateId,
318 );
319 const name = output?.name ?? resourceName(dataExchange, queryTemplateId);
320 const ownership = yield* ownershipLabels(id);
321 const desiredDescription = encodeDescription(ownership, news.description);
322 const displayName = displayNameOf(news.displayName, queryTemplateId);
323 const body: analyticshub.QueryTemplate = {
324 displayName,
325 description: desiredDescription,
326 };
327 if (news.documentation !== undefined) {
328 body.documentation = news.documentation;
329 }
330 if (news.primaryContact !== undefined) {
331 body.primaryContact = news.primaryContact;
332 }
333 if (news.proposer !== undefined) {
334 body.proposer = news.proposer;
335 }
336 const routine = desiredRoutine(queryTemplateId, news.routine);
337 if (routine !== undefined) {
338 body.routine = routine;
339 }
340
341 let current = yield* getByName(name);
342
343 if (current === undefined) {
344 const created = yield* retryTransient(
345 analyticshub.createProjectsLocationsDataExchangesQueryTemplates({
346 parent: dataExchange,
347 queryTemplateId,
348 body,
349 }),
350 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
351 current = created ?? undefined;
352 }
353
354 if (current === undefined) {
355 return yield* new AnalyticshubNotResolved({ name });
356 }
357
358 const currentName = current.name ?? name;
359 const descriptionChanged = !sameText(
360 current.description,
361 desiredDescription,
362 );
363 const contactChanged =
364 news.primaryContact !== undefined &&
365 !sameText(current.primaryContact, news.primaryContact);
366 const proposerChanged =
367 news.proposer !== undefined &&
368 !sameText(current.proposer, news.proposer);
369 const routineChanged =
370 routine !== undefined &&
371 (!sameText(current.routine?.routineType, routine.routineType) ||
372 !sameText(current.routine?.definitionBody, routine.definitionBody));
373
374 if (
375 descriptionChanged ||
376 contactChanged ||
377 proposerChanged ||
378 routineChanged
379 ) {
380 current = yield* retryTransient(
381 analyticshub.patchProjectsLocationsDataExchangesQueryTemplates({
382 name: currentName,
383 updateMask: updateMaskOf(
384 descriptionChanged ? "description" : undefined,
385 contactChanged ? "primaryContact" : undefined,
386 proposerChanged ? "proposer" : undefined,
387 routineChanged ? "routine" : undefined,
388 ),
389 body: { name: currentName, ...body },
390 }),
391 );
392 }
393
394 return toAttrs(current, env.project);
395 }),
396
397 delete: Effect.fn(function* ({ output }) {
398 yield* deleteRetry(
399 analyticshub.deleteProjectsLocationsDataExchangesQueryTemplates({
400 name: output.name,
401 }),
402 );
403 }),
404 });