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 listListings,
19 namedOf,
20 normalizeLocation,
21 ownedById,
22 ownershipLabels,
23 parseDescription,
24 parseResourceName,
25 replaceOnIdentity,
26 retryTransient,
27 sameBool,
28 sameJson,
29 sameText,
30 toPhysicalId,
31 updateMaskOf,
32} from "./internal.ts";
33
34export type ListingDataProvider = analyticshub.DataProvider;
35export type ListingPublisher = analyticshub.Publisher;
36export type ListingRestrictedExportConfig = analyticshub.RestrictedExportConfig;
37export type ListingStoredProcedureConfig = analyticshub.StoredProcedureConfig;
38export type ListingBigQueryDatasetSource = analyticshub.BigQueryDatasetSource;
39export type ListingPubSubTopicSource = analyticshub.PubSubTopicSource;
40export type ListingDiscoveryType =
41 | analyticshub.ListingDiscoveryTypeEnum
42 | (string & {});
43export type ListingCategory =
44 | analyticshub.ListingCategoriesItemEnum
45 | (string & {});
46
47export type DataExchangesListingProps = {
48 /**
49 * Parent data exchange. Full name
50 * `projects/{project}/locations/{location}/dataExchanges/{dataExchange}`
51 * or the exchange id (combined with `location`). Immutable — changing
52 * it replaces the listing.
53 */
54 dataExchange: string;
55 /**
56 * Listing id (the `{listing}` segment of
57 * `.../dataExchanges/{dataExchange}/listings/{listing}`). If omitted, a
58 * unique id is generated. Letters, numbers, and underscores; max 100
59 * bytes. Immutable — changing it replaces the listing.
60 */
61 listingId?: string;
62 /**
63 * Location used when `dataExchange` is a bare id.
64 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
65 */
66 location?: string;
67 /**
68 * Human-readable display name. Max 63 bytes. Defaults to the listing
69 * id.
70 */
71 displayName?: string;
72 /**
73 * Short description (max 2000 bytes). Listings have no labels field,
74 * so Alchemy ownership is stored in a `[alchemy …]` prefix and
75 * stripped from attributes.
76 */
77 description?: string;
78 /**
79 * Documentation describing the listing.
80 */
81 documentation?: string;
82 /**
83 * Email or URL of the primary point of contact. Max 1000 bytes.
84 */
85 primaryContact?: string;
86 /**
87 * Email or URL subscribers can use to request access. Max 1000 bytes.
88 */
89 requestAccess?: string;
90 /**
91 * Base64-encoded icon. Max 3.0 MiB encoded.
92 */
93 icon?: string;
94 /**
95 * Shared BigQuery dataset. Provide `dataset` as
96 * `projects/{project}/datasets/{dataset}` or a dataset id. The dataset
97 * location must match the exchange. Immutable — changing the dataset
98 * replaces the listing.
99 */
100 bigqueryDataset?: ListingBigQueryDatasetSource;
101 /**
102 * Shared Pub/Sub topic. Provide `topic` as
103 * `projects/{project}/topics/{topic}`. Immutable — changing the topic
104 * replaces the listing.
105 */
106 pubsubTopic?: ListingPubSubTopicSource;
107 /**
108 * Details of the data provider who owns the source data.
109 */
110 dataProvider?: ListingDataProvider;
111 /**
112 * Details of the publisher who owns the listing.
113 */
114 publisher?: ListingPublisher;
115 /**
116 * Categories (up to five).
117 */
118 categories?: ListingCategory[];
119 /**
120 * Discovery visibility (`DISCOVERY_TYPE_PRIVATE`,
121 * `DISCOVERY_TYPE_PUBLIC`).
122 */
123 discoveryType?: ListingDiscoveryType;
124 /**
125 * Restricted export configuration propagated to linked datasets.
126 */
127 restrictedExportConfig?: ListingRestrictedExportConfig;
128 /**
129 * Stored-procedure sharing configuration for linked datasets.
130 */
131 storedProcedureConfig?: ListingStoredProcedureConfig;
132 /**
133 * When true, query jobs against linked datasets record the subscriber
134 * email in the logs.
135 * @default false
136 */
137 logLinkedDatasetQueryUserEmail?: boolean;
138 /**
139 * When true, the listing exposes metadata only and cannot be
140 * subscribed to. Immutable on some exchanges.
141 * @default false
142 */
143 allowOnlyMetadataSharing?: boolean;
144 /**
145 * When true, deleting a commercial listing is allowed. Commercial
146 * listings refuse delete unless this is set.
147 * @default false
148 */
149 deleteCommercial?: boolean;
150};
151
152export type DataExchangesListing = Resource<
153 "GCP.AnalyticsHub.DataExchangesListing",
154 DataExchangesListingProps,
155 {
156 /** Full resource name. */
157 name: string;
158 /** Listing id (last path segment). */
159 listingId: string;
160 /** Parent data exchange resource name. */
161 dataExchange: string;
162 /** Project id. */
163 project: string;
164 /** Location id. */
165 location: string;
166 /** Display name. */
167 displayName: string | undefined;
168 /** User description (Alchemy ownership marker stripped). */
169 description: string | undefined;
170 /** Documentation. */
171 documentation: string | undefined;
172 /** Primary contact. */
173 primaryContact: string | undefined;
174 /** Request-access URL or email. */
175 requestAccess: string | undefined;
176 /** Base64-encoded icon. */
177 icon: string | undefined;
178 /** Shared BigQuery dataset source. */
179 bigqueryDataset: ListingBigQueryDatasetSource | undefined;
180 /** Shared Pub/Sub topic source. */
181 pubsubTopic: ListingPubSubTopicSource | undefined;
182 /** Data provider. */
183 dataProvider: ListingDataProvider | undefined;
184 /** Publisher. */
185 publisher: ListingPublisher | undefined;
186 /** Categories. */
187 categories: ListingCategory[] | undefined;
188 /** Discovery type. */
189 discoveryType: string | undefined;
190 /** Restricted export configuration. */
191 restrictedExportConfig: ListingRestrictedExportConfig | undefined;
192 /** Stored-procedure configuration. */
193 storedProcedureConfig: ListingStoredProcedureConfig | undefined;
194 /** Whether linked-dataset query emails are logged. */
195 logLinkedDatasetQueryUserEmail: boolean;
196 /** Whether the listing is metadata-only. */
197 allowOnlyMetadataSharing: boolean;
198 /** Shared asset type (`BIGQUERY_DATASET`, `PUBSUB_TOPIC`). */
199 resourceType: string | undefined;
200 /** Listing lifecycle state. */
201 state: string | undefined;
202 },
203 never,
204 Providers
205>;
206
207/**
208 * An Analytics Hub listing published into a data exchange. Subscribers
209 * get a linked BigQuery dataset or a Pub/Sub subscription pointing at
210 * the shared source.
211 *
212 * Parent exchange, location, listing id, and the source dataset/topic
213 * are immutable. Display name, description, documentation, contacts,
214 * categories, and export settings update in place.
215 *
216 * Listings have no labels. Alchemy stamps ownership into the
217 * description so `list` / `pnpm nuke:gcp` can find them.
218 *
219 * ### Creating a Listing
220 * **Example:** Share a BigQuery dataset
221 * ```typescript
222 * const dataset = yield* GCP.BigQuery.Dataset("Source", {
223 * location: "us-central1",
224 * forceDestroy: true,
225 * });
226 * const exchange = yield* GCP.AnalyticsHub.DataExchange("Marketplace", {
227 * displayName: "Marketplace",
228 * });
229 * const listing = yield* GCP.AnalyticsHub.DataExchangesListing("Orders", {
230 * dataExchange: exchange.name,
231 * displayName: "Orders",
232 * bigqueryDataset: { dataset: dataset.name },
233 * });
234 * ```
235 *
236 * **Example:** Share a Pub/Sub topic
237 * ```typescript
238 * const listing = yield* GCP.AnalyticsHub.DataExchangesListing("Events", {
239 * dataExchange: exchange.name,
240 * displayName: "Events",
241 * pubsubTopic: { topic: topic.name },
242 * });
243 * ```
244 *
245 * @resource
246 * @category AnalyticsHub
247 */
248export const DataExchangesListing = Resource<DataExchangesListing>(
249 "GCP.AnalyticsHub.DataExchangesListing",
250);
251
252const parentExchange = (
253 dataExchange: string,
254 project: string,
255 location: string,
256) => expandParent(dataExchange, project, location, "dataExchanges");
257
258const resourceName = (dataExchange: string, listingId: string) =>
259 `${dataExchange}/listings/${listingId}`;
260
261const datasetName = (value: string | undefined, project: string) => {
262 if (value === undefined || value.length === 0) return undefined;
263 if (value.includes("/")) return value.replace(/\/+$/, "");
264 return `projects/${project}/datasets/${value}`;
265};
266
267const topicName = (value: string | undefined, project: string) => {
268 if (value === undefined || value.length === 0) return undefined;
269 if (value.includes("/")) return value.replace(/\/+$/, "");
270 return `projects/${project}/topics/${value}`;
271};
272
273const desiredBigQuery = (
274 source: ListingBigQueryDatasetSource | undefined,
275 project: string,
276): ListingBigQueryDatasetSource | undefined => {
277 if (source === undefined) return undefined;
278 return {
279 ...source,
280 dataset: datasetName(source.dataset, project),
281 };
282};
283
284const desiredPubSub = (
285 source: ListingPubSubTopicSource | undefined,
286 project: string,
287): ListingPubSubTopicSource | undefined => {
288 if (source === undefined) return undefined;
289 return {
290 ...source,
291 topic: topicName(source.topic, project),
292 };
293};
294
295const sourceIdentity = (
296 bigqueryDataset: ListingBigQueryDatasetSource | undefined,
297 pubsubTopic: ListingPubSubTopicSource | undefined,
298) => `${bigqueryDataset?.dataset ?? ""}|${pubsubTopic?.topic ?? ""}`;
299
300const toAttrs = (listing: analyticshub.Listing, project: string) => {
301 const name = listing.name ?? "";
302 const parsed = parseResourceName(name, "listings");
303 const { description } = parseDescription(listing.description);
304 return {
305 name,
306 listingId: parsed.id,
307 dataExchange: parsed.parent,
308 project: parsed.project || project,
309 location: parsed.location,
310 displayName: listing.displayName,
311 description,
312 documentation: listing.documentation,
313 primaryContact: listing.primaryContact,
314 requestAccess: listing.requestAccess,
315 icon: listing.icon,
316 bigqueryDataset: listing.bigqueryDataset,
317 pubsubTopic: listing.pubsubTopic,
318 dataProvider: listing.dataProvider,
319 publisher: listing.publisher,
320 categories: listing.categories,
321 discoveryType: listing.discoveryType,
322 restrictedExportConfig: listing.restrictedExportConfig,
323 storedProcedureConfig: listing.storedProcedureConfig,
324 logLinkedDatasetQueryUserEmail:
325 listing.logLinkedDatasetQueryUserEmail === true,
326 allowOnlyMetadataSharing: listing.allowOnlyMetadataSharing === true,
327 resourceType: listing.resourceType,
328 state: listing.state,
329 };
330};
331
332const getByName = (name: string) =>
333 Effect.gen(function* () {
334 if (name.length === 0) return undefined;
335 const parsed = parseResourceName(name, "listings");
336 const items = yield* listListings(parsed.parent);
337 return items.find((item) => (item.name ?? "") === name);
338 });
339
340export const DataExchangesListingProvider = () =>
341 Provider.succeed(DataExchangesListing, {
342 nuke: {
343 dependsOn: ["GCP.AnalyticsHub.DataExchange", "GCP.BigQuery.Dataset"],
344 },
345 stables: ["name", "listingId", "dataExchange", "project", "location"],
346
347 diff: Effect.fn(function* ({ news, olds, output }) {
348 if (!isResolved(news)) return undefined;
349 const env = yield* GcpEnvironment.current;
350 const location = normalizeLocation(
351 news.location ?? olds?.location ?? output?.location,
352 env.region,
353 );
354 const nextSource = sourceIdentity(
355 desiredBigQuery(news.bigqueryDataset, env.project),
356 desiredPubSub(news.pubsubTopic, env.project),
357 );
358 const previousSource = sourceIdentity(
359 olds?.bigqueryDataset ?? output?.bigqueryDataset,
360 olds?.pubsubTopic ?? output?.pubsubTopic,
361 );
362 return replaceOnIdentity({
363 previousId: olds?.listingId ?? output?.listingId,
364 nextId: news.listingId,
365 previousLocation: normalizeLocation(
366 olds?.location ?? output?.location,
367 env.region,
368 ),
369 nextLocation: location,
370 previousParent: olds?.dataExchange ?? output?.dataExchange,
371 nextParent: parentExchange(news.dataExchange, env.project, location),
372 extra:
373 previousSource.length > 1 &&
374 nextSource.length > 1 &&
375 previousSource !== nextSource,
376 });
377 }),
378
379 read: Effect.fn(function* ({ id, olds, output }) {
380 const env = yield* GcpEnvironment.current;
381 const location = normalizeLocation(
382 olds?.location ?? output?.location,
383 env.region,
384 );
385 const listingId = yield* toPhysicalId(
386 id,
387 olds?.listingId,
388 output?.listingId,
389 );
390 const dataExchange =
391 olds?.dataExchange !== undefined
392 ? parentExchange(olds.dataExchange, env.project, location)
393 : (output?.dataExchange ?? "");
394 const name =
395 output?.name ??
396 (dataExchange ? resourceName(dataExchange, listingId) : "");
397 const existing = yield* getByName(name);
398 if (existing === undefined) return undefined;
399 const attrs = toAttrs(existing, env.project);
400 return (yield* ownedById(id, existing.description))
401 ? attrs
402 : Unowned(attrs);
403 }),
404
405 list: () =>
406 Effect.gen(function* () {
407 const env = yield* GcpEnvironment.current;
408 const exchanges = yield* listExchangesInProject(
409 env.project,
410 env.region,
411 );
412 const listings = yield* listChildResources(
413 namedOf(exchanges),
414 listListings,
415 );
416 return listings
417 .filter((item) => hasOwnershipMarker(item.description))
418 .map((item) => toAttrs(item, env.project));
419 }),
420
421 reconcile: Effect.fn(function* ({ id, news, output }) {
422 const env = yield* GcpEnvironment.current;
423 const location = normalizeLocation(
424 news.location ?? output?.location,
425 env.region,
426 );
427 const dataExchange = parentExchange(
428 news.dataExchange,
429 env.project,
430 location,
431 );
432 const listingId = yield* toPhysicalId(
433 id,
434 news.listingId,
435 output?.listingId,
436 );
437 const name = output?.name ?? resourceName(dataExchange, listingId);
438 const ownership = yield* ownershipLabels(id);
439 const desiredDescription = encodeDescription(ownership, news.description);
440 const displayName = displayNameOf(news.displayName, listingId);
441 const bigqueryDataset = desiredBigQuery(
442 news.bigqueryDataset,
443 env.project,
444 );
445 const pubsubTopic = desiredPubSub(news.pubsubTopic, env.project);
446 const body: analyticshub.Listing = {
447 displayName,
448 description: desiredDescription,
449 };
450 if (news.documentation !== undefined) {
451 body.documentation = news.documentation;
452 }
453 if (news.primaryContact !== undefined) {
454 body.primaryContact = news.primaryContact;
455 }
456 if (news.requestAccess !== undefined) {
457 body.requestAccess = news.requestAccess;
458 }
459 if (news.icon !== undefined) {
460 body.icon = news.icon;
461 }
462 if (bigqueryDataset !== undefined) {
463 body.bigqueryDataset = bigqueryDataset;
464 }
465 if (pubsubTopic !== undefined) {
466 body.pubsubTopic = pubsubTopic;
467 }
468 if (news.dataProvider !== undefined) {
469 body.dataProvider = news.dataProvider;
470 }
471 if (news.publisher !== undefined) {
472 body.publisher = news.publisher;
473 }
474 if (news.categories !== undefined) {
475 body.categories = news.categories;
476 }
477 if (news.discoveryType !== undefined) {
478 body.discoveryType = news.discoveryType;
479 }
480 if (news.restrictedExportConfig !== undefined) {
481 body.restrictedExportConfig = news.restrictedExportConfig;
482 }
483 if (news.storedProcedureConfig !== undefined) {
484 body.storedProcedureConfig = news.storedProcedureConfig;
485 }
486 if (news.logLinkedDatasetQueryUserEmail === true) {
487 body.logLinkedDatasetQueryUserEmail = true;
488 }
489 if (news.allowOnlyMetadataSharing === true) {
490 body.allowOnlyMetadataSharing = true;
491 }
492
493 let current = yield* getByName(name);
494
495 if (current === undefined) {
496 const created = yield* retryTransient(
497 analyticshub.createProjectsLocationsDataExchangesListings({
498 parent: dataExchange,
499 listingId,
500 body,
501 }),
502 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
503 current = created ?? undefined;
504 }
505
506 if (current === undefined) {
507 return yield* new AnalyticshubNotResolved({ name });
508 }
509
510 const currentName = current.name ?? name;
511 const displayChanged = !sameText(current.displayName, displayName);
512 const descriptionChanged = !sameText(
513 current.description,
514 desiredDescription,
515 );
516 const documentationChanged = !sameText(
517 current.documentation,
518 news.documentation,
519 );
520 const contactChanged = !sameText(
521 current.primaryContact,
522 news.primaryContact,
523 );
524 const requestChanged = !sameText(
525 current.requestAccess,
526 news.requestAccess,
527 );
528 const iconChanged = !sameText(current.icon, news.icon);
529 const dataProviderChanged =
530 news.dataProvider !== undefined &&
531 !sameJson(current.dataProvider, news.dataProvider);
532 const publisherChanged =
533 news.publisher !== undefined &&
534 !sameJson(current.publisher, news.publisher);
535 const categoriesChanged =
536 news.categories !== undefined &&
537 !sameJson(current.categories, news.categories);
538 const discoveryChanged =
539 news.discoveryType !== undefined &&
540 !sameText(current.discoveryType, news.discoveryType);
541 const restrictedChanged =
542 news.restrictedExportConfig !== undefined &&
543 !sameJson(
544 {
545 enabled: current.restrictedExportConfig?.enabled,
546 restrictQueryResult:
547 current.restrictedExportConfig?.restrictQueryResult,
548 },
549 {
550 enabled: news.restrictedExportConfig.enabled,
551 restrictQueryResult:
552 news.restrictedExportConfig.restrictQueryResult,
553 },
554 );
555 const storedChanged =
556 news.storedProcedureConfig !== undefined &&
557 !sameBool(
558 current.storedProcedureConfig?.enabled,
559 news.storedProcedureConfig.enabled,
560 );
561 const emailChanged =
562 news.logLinkedDatasetQueryUserEmail !== undefined &&
563 !sameBool(
564 current.logLinkedDatasetQueryUserEmail,
565 news.logLinkedDatasetQueryUserEmail,
566 );
567 const metadataChanged =
568 news.allowOnlyMetadataSharing !== undefined &&
569 !sameBool(
570 current.allowOnlyMetadataSharing,
571 news.allowOnlyMetadataSharing,
572 );
573 const selectedChanged =
574 bigqueryDataset?.selectedResources !== undefined &&
575 !sameJson(
576 current.bigqueryDataset?.selectedResources,
577 bigqueryDataset.selectedResources,
578 );
579 const replicaChanged =
580 bigqueryDataset?.replicaLocations !== undefined &&
581 !sameJson(
582 current.bigqueryDataset?.replicaLocations,
583 bigqueryDataset.replicaLocations,
584 );
585 const restrictedPolicyChanged =
586 bigqueryDataset?.restrictedExportPolicy !== undefined &&
587 !sameJson(
588 current.bigqueryDataset?.restrictedExportPolicy,
589 bigqueryDataset.restrictedExportPolicy,
590 );
591 const affinityChanged =
592 pubsubTopic?.dataAffinityRegions !== undefined &&
593 !sameJson(
594 current.pubsubTopic?.dataAffinityRegions,
595 pubsubTopic.dataAffinityRegions,
596 );
597
598 if (
599 displayChanged ||
600 descriptionChanged ||
601 documentationChanged ||
602 contactChanged ||
603 requestChanged ||
604 iconChanged ||
605 dataProviderChanged ||
606 publisherChanged ||
607 categoriesChanged ||
608 discoveryChanged ||
609 restrictedChanged ||
610 storedChanged ||
611 emailChanged ||
612 metadataChanged ||
613 selectedChanged ||
614 replicaChanged ||
615 restrictedPolicyChanged ||
616 affinityChanged
617 ) {
618 current = yield* retryTransient(
619 analyticshub.patchProjectsLocationsDataExchangesListings({
620 name: currentName,
621 updateMask: updateMaskOf(
622 displayChanged ? "displayName" : undefined,
623 descriptionChanged ? "description" : undefined,
624 documentationChanged ? "documentation" : undefined,
625 contactChanged ? "primaryContact" : undefined,
626 requestChanged ? "requestAccess" : undefined,
627 iconChanged ? "icon" : undefined,
628 dataProviderChanged ? "dataProvider" : undefined,
629 publisherChanged ? "publisher" : undefined,
630 categoriesChanged ? "categories" : undefined,
631 discoveryChanged ? "discoveryType" : undefined,
632 restrictedChanged ? "restrictedExportConfig" : undefined,
633 storedChanged ? "storedProcedureConfig" : undefined,
634 emailChanged ? "logLinkedDatasetQueryUserEmail" : undefined,
635 metadataChanged ? "allowOnlyMetadataSharing" : undefined,
636 selectedChanged ? "bigqueryDataset.selectedResources" : undefined,
637 replicaChanged ? "bigqueryDataset.replicaLocations" : undefined,
638 restrictedPolicyChanged
639 ? "bigqueryDataset.restrictedExportPolicy"
640 : undefined,
641 affinityChanged ? "pubsubTopic.dataAffinityRegions" : undefined,
642 ),
643 body: { name: currentName, ...body },
644 }),
645 );
646 }
647
648 return toAttrs(current, env.project);
649 }),
650
651 delete: Effect.fn(function* ({ output }) {
652 yield* deleteRetry(
653 analyticshub.deleteProjectsLocationsDataExchangesListings({
654 name: output.name,
655 deleteCommercial: true,
656 }),
657 );
658 }),
659 });