1import * as analyticshub from "@distilled.cloud/gcp/analyticshub_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 AnalyticshubNotResolved,
12 deleteRetry,
13 displayNameOf,
14 encodeDescription,
15 hasOwnershipMarker,
16 listExchangesInProject,
17 listListings,
18 listQueryTemplates,
19 locationParent,
20 missingGet,
21 normalizeLocation,
22 ownedById,
23 parseDescription,
24 parseResourceName,
25 replaceOnIdentity,
26 retryTransient,
27 sameBool,
28 sameText,
29 sharingKind,
30 toPhysicalId,
31 updateMaskOf,
32 ownershipLabels,
33} from "./internal.ts";
34
35export type SharingEnvironmentConfig = analyticshub.SharingEnvironmentConfig;
36export type DataExchangeDiscoveryType =
37 | analyticshub.DataExchangeDiscoveryTypeEnum
38 | (string & {});
39
40export type DataExchangeProps = {
41 /**
42 * Data exchange id (the `{dataExchange}` segment of
43 * `projects/{project}/locations/{location}/dataExchanges/{dataExchange}`).
44 * If omitted, a unique id is generated. Must contain only letters,
45 * numbers, and underscores; max 100 bytes. Immutable — changing it
46 * replaces the exchange.
47 */
48 dataExchangeId?: string;
49 /**
50 * BigQuery location (`us-central1`, `US`, `EU`, …). Immutable —
51 * changing it replaces the exchange.
52 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
53 */
54 location?: string;
55 /**
56 * Human-readable display name. Letters, numbers, underscores, dashes,
57 * spaces, and ampersands. Max 63 bytes. Defaults to the exchange id.
58 */
59 displayName?: string;
60 /**
61 * Human-readable description (max 2000 bytes). Analytics Hub data
62 * exchanges have no labels field, so Alchemy ownership is stored in a
63 * `[alchemy …]` prefix and stripped from attributes.
64 */
65 description?: string;
66 /**
67 * Documentation describing the data exchange.
68 */
69 documentation?: string;
70 /**
71 * Email or URL of the primary point of contact. Max 1000 bytes.
72 */
73 primaryContact?: string;
74 /**
75 * Base64-encoded icon. Max 3.0 MiB encoded.
76 */
77 icon?: string;
78 /**
79 * When true, query jobs against linked datasets record the subscriber
80 * email in the logs.
81 * @default false
82 */
83 logLinkedDatasetQueryUserEmail?: boolean;
84 /**
85 * Discovery visibility for listings under this exchange
86 * (`DISCOVERY_TYPE_PRIVATE`, `DISCOVERY_TYPE_PUBLIC`). Updating this
87 * also overwrites `discoveryType` on every listing in the exchange.
88 */
89 discoveryType?: DataExchangeDiscoveryType;
90 /**
91 * Sharing environment. Set `dcrExchangeConfig` for a data clean room,
92 * or `defaultExchangeConfig` for a standard exchange. Switching kinds
93 * replaces the exchange.
94 */
95 sharingEnvironmentConfig?: SharingEnvironmentConfig;
96};
97
98export type DataExchange = Resource<
99 "GCP.AnalyticsHub.DataExchange",
100 DataExchangeProps,
101 {
102 /** Full resource name. */
103 name: string;
104 /** Data exchange id (last path segment). */
105 dataExchangeId: string;
106 /** Project id. */
107 project: string;
108 /** Location id. */
109 location: string;
110 /** Display name. */
111 displayName: string | undefined;
112 /** User description (Alchemy ownership marker stripped). */
113 description: string | undefined;
114 /** Documentation. */
115 documentation: string | undefined;
116 /** Primary contact. */
117 primaryContact: string | undefined;
118 /** Base64-encoded icon. */
119 icon: string | undefined;
120 /** Whether linked-dataset query emails are logged. */
121 logLinkedDatasetQueryUserEmail: boolean;
122 /** Discovery type. */
123 discoveryType: string | undefined;
124 /** Sharing environment. */
125 sharingEnvironmentConfig: SharingEnvironmentConfig | undefined;
126 /** Number of listings in the exchange. */
127 listingCount: number | undefined;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A BigQuery Analytics Hub data exchange — a container for listings
135 * that share datasets or Pub/Sub topics with subscribers.
136 *
137 * Location and id are immutable. Display name, description,
138 * documentation, contact, discovery type, and email logging update in
139 * place. Switching between a default exchange and a data clean room
140 * replaces the exchange.
141 *
142 * Data exchanges have no labels. Alchemy stamps
143 * `alchemy-stack` / `alchemy-stage` / `alchemy-id` into the description
144 * so `list` and `pnpm nuke:gcp` can identify owned exchanges.
145 *
146 * ### Creating a Data Exchange
147 * **Example:** Generated id
148 * ```typescript
149 * const exchange = yield* GCP.AnalyticsHub.DataExchange("Marketplace", {
150 * displayName: "Marketplace",
151 * description: "shared datasets",
152 * });
153 * ```
154 *
155 * **Example:** Named exchange with a contact
156 * ```typescript
157 * const exchange = yield* GCP.AnalyticsHub.DataExchange("Marketplace", {
158 * dataExchangeId: "marketplace",
159 * location: "us-central1",
160 * displayName: "Marketplace",
161 * primaryContact: "data@example.com",
162 * });
163 * ```
164 *
165 * ### Data Clean Rooms
166 * **Example:** Create a data clean room exchange
167 * ```typescript
168 * const dcr = yield* GCP.AnalyticsHub.DataExchange("CleanRoom", {
169 * displayName: "Clean Room",
170 * sharingEnvironmentConfig: { dcrExchangeConfig: {} },
171 * });
172 * ```
173 *
174 * @resource
175 * @category AnalyticsHub
176 */
177export const DataExchange = Resource<DataExchange>(
178 "GCP.AnalyticsHub.DataExchange",
179);
180
181const resourceName = (
182 project: string,
183 location: string,
184 dataExchangeId: string,
185) => `${locationParent(project, location)}/dataExchanges/${dataExchangeId}`;
186
187const toAttrs = (exchange: analyticshub.DataExchange, project: string) => {
188 const name = exchange.name ?? "";
189 const parsed = parseResourceName(name, "dataExchanges");
190 const { description } = parseDescription(exchange.description);
191 return {
192 name,
193 dataExchangeId: parsed.id,
194 project: parsed.project || project,
195 location: parsed.location,
196 displayName: exchange.displayName,
197 description,
198 documentation: exchange.documentation,
199 primaryContact: exchange.primaryContact,
200 icon: exchange.icon,
201 logLinkedDatasetQueryUserEmail:
202 exchange.logLinkedDatasetQueryUserEmail === true,
203 discoveryType: exchange.discoveryType,
204 sharingEnvironmentConfig: exchange.sharingEnvironmentConfig,
205 listingCount: exchange.listingCount,
206 };
207};
208
209const getByName = missingGet;
210
211export const DataExchangeProvider = () =>
212 Provider.succeed(DataExchange, {
213 nuke: {
214 dependsOn: ["GCP.BigQuery.Dataset"],
215 },
216 stables: ["name", "dataExchangeId", "project", "location"],
217
218 diff: Effect.fn(function* ({ news, olds, output }) {
219 if (!isResolved(news)) return undefined;
220 const env = yield* GcpEnvironment.current;
221 const previousKind = sharingKind(
222 olds?.sharingEnvironmentConfig ?? output?.sharingEnvironmentConfig,
223 );
224 const nextKind =
225 news.sharingEnvironmentConfig !== undefined
226 ? sharingKind(news.sharingEnvironmentConfig)
227 : previousKind;
228 return replaceOnIdentity({
229 previousId: olds?.dataExchangeId ?? output?.dataExchangeId,
230 nextId: news.dataExchangeId,
231 previousLocation: normalizeLocation(
232 olds?.location ?? output?.location,
233 env.region,
234 ),
235 nextLocation: normalizeLocation(
236 news.location ?? olds?.location ?? output?.location,
237 env.region,
238 ),
239 extra:
240 news.sharingEnvironmentConfig !== undefined &&
241 nextKind !== previousKind,
242 });
243 }),
244
245 read: Effect.fn(function* ({ id, olds, output }) {
246 const env = yield* GcpEnvironment.current;
247 const location = normalizeLocation(
248 olds?.location ?? output?.location,
249 env.region,
250 );
251 const dataExchangeId = yield* toPhysicalId(
252 id,
253 olds?.dataExchangeId,
254 output?.dataExchangeId,
255 );
256 const name =
257 output?.name ?? resourceName(env.project, location, dataExchangeId);
258 const existing = yield* getByName(name);
259 if (existing === undefined) return undefined;
260 const attrs = toAttrs(existing, env.project);
261 return (yield* ownedById(id, existing.description))
262 ? attrs
263 : Unowned(attrs);
264 }),
265
266 list: () =>
267 Effect.gen(function* () {
268 const env = yield* GcpEnvironment.current;
269 const items = yield* listExchangesInProject(env.project, env.region);
270 return items
271 .filter((item) => hasOwnershipMarker(item.description))
272 .map((item) => toAttrs(item, env.project));
273 }),
274
275 reconcile: Effect.fn(function* ({ id, news, output }) {
276 const env = yield* GcpEnvironment.current;
277 const location = normalizeLocation(
278 news.location ?? output?.location,
279 env.region,
280 );
281 const parent = locationParent(env.project, location);
282 const dataExchangeId = yield* toPhysicalId(
283 id,
284 news.dataExchangeId,
285 output?.dataExchangeId,
286 );
287 const name =
288 output?.name ?? resourceName(env.project, location, dataExchangeId);
289 const ownership = yield* ownershipLabels(id);
290 const desiredDescription = encodeDescription(ownership, news.description);
291 const displayName = displayNameOf(news.displayName, dataExchangeId);
292 const body: analyticshub.DataExchange = {
293 displayName,
294 description: desiredDescription,
295 documentation: news.documentation,
296 primaryContact: news.primaryContact,
297 icon: news.icon,
298 logLinkedDatasetQueryUserEmail:
299 news.logLinkedDatasetQueryUserEmail === true ? true : undefined,
300 discoveryType: news.discoveryType,
301 sharingEnvironmentConfig: news.sharingEnvironmentConfig,
302 };
303
304 let current = yield* getByName(name);
305
306 if (current === undefined) {
307 const created = yield* retryTransient(
308 analyticshub.createProjectsLocationsDataExchanges({
309 parent,
310 dataExchangeId,
311 body,
312 }),
313 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
314 current = created ?? undefined;
315 }
316
317 if (current === undefined) {
318 return yield* new AnalyticshubNotResolved({ name });
319 }
320
321 const currentName = current.name ?? name;
322 const displayChanged = !sameText(current.displayName, displayName);
323 const descriptionChanged = !sameText(
324 current.description,
325 desiredDescription,
326 );
327 const documentationChanged = !sameText(
328 current.documentation,
329 news.documentation,
330 );
331 const contactChanged = !sameText(
332 current.primaryContact,
333 news.primaryContact,
334 );
335 const iconChanged = !sameText(current.icon, news.icon);
336 const emailChanged =
337 news.logLinkedDatasetQueryUserEmail !== undefined &&
338 !sameBool(
339 current.logLinkedDatasetQueryUserEmail,
340 news.logLinkedDatasetQueryUserEmail,
341 );
342 const discoveryChanged =
343 news.discoveryType !== undefined &&
344 !sameText(current.discoveryType, news.discoveryType);
345 const sharingChanged =
346 news.sharingEnvironmentConfig !== undefined &&
347 sharingKind(current.sharingEnvironmentConfig) !==
348 sharingKind(news.sharingEnvironmentConfig);
349
350 if (
351 displayChanged ||
352 descriptionChanged ||
353 documentationChanged ||
354 contactChanged ||
355 iconChanged ||
356 emailChanged ||
357 discoveryChanged ||
358 sharingChanged
359 ) {
360 current = yield* retryTransient(
361 analyticshub.patchProjectsLocationsDataExchanges({
362 name: currentName,
363 updateMask: updateMaskOf(
364 displayChanged ? "displayName" : undefined,
365 descriptionChanged ? "description" : undefined,
366 documentationChanged ? "documentation" : undefined,
367 contactChanged ? "primaryContact" : undefined,
368 iconChanged ? "icon" : undefined,
369 emailChanged ? "logLinkedDatasetQueryUserEmail" : undefined,
370 discoveryChanged ? "discoveryType" : undefined,
371 sharingChanged ? "sharingEnvironmentConfig" : undefined,
372 ),
373 body: { name: currentName, ...body },
374 }),
375 );
376 }
377
378 return toAttrs(current, env.project);
379 }),
380
381 delete: Effect.fn(function* ({ output }) {
382 const listings = yield* listListings(output.name);
383 yield* Effect.forEach(
384 listings.filter((listing) => (listing.name ?? "").length > 0),
385 (listing) =>
386 deleteRetry(
387 analyticshub.deleteProjectsLocationsDataExchangesListings({
388 name: listing.name!,
389 deleteCommercial: true,
390 }),
391 ),
392 { concurrency: 2 },
393 );
394 yield* listListings(output.name).pipe(
395 Effect.repeat({
396 schedule: Schedule.spaced("2 seconds"),
397 until: (items) => items.length === 0,
398 times: 12,
399 }),
400 );
401 const templates = yield* listQueryTemplates(output.name);
402 yield* Effect.forEach(
403 templates.filter((template) => (template.name ?? "").length > 0),
404 (template) =>
405 deleteRetry(
406 analyticshub.deleteProjectsLocationsDataExchangesQueryTemplates({
407 name: template.name!,
408 }),
409 ),
410 { concurrency: 2 },
411 );
412 yield* deleteRetry(
413 analyticshub.deleteProjectsLocationsDataExchanges({
414 name: output.name,
415 }),
416 );
417 }),
418 });