1import * as bigqueryconnection from "@distilled.cloud/gcp/bigqueryconnection_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 alchemyLabelKeys,
13 createInternalLabels,
14 hasAlchemyLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17
18const MAX_NAME_LENGTH = 60;
19const MULTI_REGION = new Set(["us", "eu"]);
20
21export type SparkHistoryServerConfig = {
22 /**
23 * Existing Dataproc cluster used as a Spark History Server, as
24 * `projects/{project}/regions/{region}/clusters/{cluster}`.
25 */
26 dataprocCluster?: string;
27};
28
29export type MetastoreServiceConfig = {
30 /**
31 * Existing Dataproc Metastore service, as
32 * `projects/{project}/locations/{location}/services/{service}`.
33 */
34 metastoreService?: string;
35};
36
37export type SparkProperties = {
38 /** Spark History Server configuration. */
39 sparkHistoryServerConfig?: SparkHistoryServerConfig;
40 /** Dataproc Metastore service configuration. */
41 metastoreServiceConfig?: MetastoreServiceConfig;
42};
43
44export type CloudSqlCredential = {
45 /** Database username. */
46 username?: string;
47 /** Database password. Input-only — omitted from attributes. */
48 password?: string;
49};
50
51export type CloudSqlProperties = {
52 /**
53 * Cloud SQL instance id in the form `project:location:instance`.
54 */
55 instanceId?: string;
56 /** Database engine. */
57 type?: "DATABASE_TYPE_UNSPECIFIED" | "POSTGRES" | "MYSQL" | (string & {});
58 /** Database name. */
59 database?: string;
60 /** Cloud SQL credential. Input-only password. */
61 credential?: CloudSqlCredential;
62};
63
64export type CloudResourceProperties = {
65 /**
66 * Google-managed service account for this connection. Output-only —
67 * ignored on create.
68 */
69 serviceAccountId?: string;
70};
71
72export type AwsAccessRole = {
73 /** Customer AWS IAM role that trusts the Google-owned AWS identity. */
74 iamRoleId?: string;
75};
76
77export type AwsProperties = {
78 /** AWS IAM role assumption. */
79 accessRole?: AwsAccessRole;
80};
81
82export type CloudSpannerProperties = {
83 /**
84 * Cloud Spanner database as `project/instance/database`.
85 */
86 database?: string;
87 /** Fine-grained access control database role. */
88 databaseRole?: string;
89 /** Whether to read from Spanner in parallel. */
90 useParallelism?: boolean;
91 /**
92 * Deprecated. Prefer `useDataBoost`. Requires `useParallelism`.
93 */
94 useServerlessAnalytics?: boolean;
95 /** Execute via Spanner independent compute. Requires `useParallelism`. */
96 useDataBoost?: boolean;
97 /** Max parallelism per query. Requires `useParallelism` and `useDataBoost`. */
98 maxParallelism?: number;
99};
100
101export type SalesforceDataCloudProperties = {
102 /** Salesforce DataCloud instance URL. */
103 instanceUri?: string;
104 /** Salesforce tenant id. */
105 tenantId?: string;
106};
107
108export type AzureProperties = {
109 /** Customer Azure Active Directory tenant that hosts the data. */
110 customerTenantId?: string;
111 /** Redirect URL after consent during connection setup. */
112 redirectUri?: string;
113 /** Customer Azure AD application client id for federated auth. */
114 federatedApplicationClientId?: string;
115};
116
117export type ConnectorSecret = {
118 /** Input-only plaintext secret. */
119 plaintext?: string;
120};
121
122export type ConnectorUsernamePassword = {
123 /** Username. */
124 username?: string;
125 /** Password secret. */
126 password?: ConnectorSecret;
127};
128
129export type ConnectorParameterValue = {
130 int32Value?: number;
131 stringValue?: string;
132 boolValue?: boolean;
133 doubleValue?: number;
134 secretValue?: ConnectorSecret;
135};
136
137export type ConnectorAuthentication = {
138 usernamePassword?: ConnectorUsernamePassword;
139 parameters?: Record<string, ConnectorParameterValue | undefined>;
140};
141
142export type ConnectorNetwork = {
143 /** Private Service Connect network attachment. */
144 privateServiceConnect?: {
145 networkAttachment?: string;
146 };
147};
148
149export type ConnectorAsset = {
150 database?: string;
151 googleCloudResource?: string;
152};
153
154export type ConnectorEndpoint = {
155 /** Host and port as `hostname:port`. */
156 hostPort?: string;
157};
158
159export type ConnectorConfiguration = {
160 /**
161 * Connector id (for example `google-alloydb`). Immutable — changing it
162 * replaces the connection.
163 */
164 connectorId?: string;
165 authentication?: ConnectorAuthentication;
166 network?: ConnectorNetwork;
167 parameters?: Record<string, ConnectorParameterValue | undefined>;
168 asset?: ConnectorAsset;
169 endpoint?: ConnectorEndpoint;
170};
171
172export type ConnectionKind =
173 | "cloudResource"
174 | "cloudSql"
175 | "cloudSpanner"
176 | "spark"
177 | "aws"
178 | "azure"
179 | "salesforceDataCloud"
180 | "configuration";
181
182export type ConnectionProps = {
183 /**
184 * Connection id (the `{connection}` segment of
185 * `projects/{project}/locations/{location}/connections/{connection}`).
186 * If omitted, a unique name is generated from the stack, stage, and
187 * logical id. Must contain only letters, numbers, and underscores, start
188 * with a letter or underscore, and be at most 64 characters. Immutable —
189 * changing it replaces the connection.
190 */
191 connectionId?: string;
192 /**
193 * BigQuery connection location (`us-central1`, `US`, `EU`,
194 * `aws-us-east-1`, …). Immutable — changing it replaces the connection.
195 * Regional ids are lowercased; multi-regions `US` / `EU` stay uppercase.
196 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
197 */
198 location?: string;
199 /**
200 * User-friendly display name.
201 */
202 friendlyName?: string;
203 /**
204 * Human-readable description. Connections have no labels, so Alchemy
205 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
206 * in a `[alchemy …]` prefix for `list` / nuke.
207 */
208 description?: string;
209 /**
210 * Cloud KMS key used to encrypt credentials, as
211 * `projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`.
212 * Immutable — changing it replaces the connection.
213 */
214 kmsKeyName?: string;
215 /**
216 * Cloud resource connection (GCS, Bigtable, and other GCP resources).
217 * Default when no other connection type is set. Cheap to create — no
218 * extra resources required.
219 */
220 cloudResource?: CloudResourceProperties;
221 /**
222 * Cloud SQL connection. Requires an existing Cloud SQL instance,
223 * database, and credential.
224 */
225 cloudSql?: CloudSqlProperties;
226 /**
227 * Cloud Spanner connection. Requires an existing Spanner database.
228 */
229 cloudSpanner?: CloudSpannerProperties;
230 /**
231 * Apache Spark stored-procedure connection.
232 */
233 spark?: SparkProperties;
234 /**
235 * BigQuery Omni AWS connection. Location must be an AWS region such as
236 * `aws-us-east-1`.
237 */
238 aws?: AwsProperties;
239 /**
240 * BigQuery Omni Azure connection. Location must be an Azure region such
241 * as `azure-eastus2`.
242 */
243 azure?: AzureProperties;
244 /**
245 * Salesforce DataCloud connection. Partner projects only.
246 */
247 salesforceDataCloud?: SalesforceDataCloudProperties;
248 /**
249 * Connector-framework configuration (AlloyDB, MySQL, PostgreSQL, …).
250 * Mutually exclusive with the typed property blocks. `connectorId` is
251 * immutable.
252 */
253 configuration?: ConnectorConfiguration;
254};
255
256export type Connection = Resource<
257 "GCP.BigQueryConnection.Connection",
258 ConnectionProps,
259 {
260 /** Full resource name `projects/{project}/locations/{location}/connections/{connection}`. */
261 name: string;
262 /** Connection id (last path segment). */
263 connectionId: string;
264 /** Project id. */
265 project: string;
266 /** Location id (`us-central1`, `US`, …). */
267 location: string;
268 /** User-friendly display name. */
269 friendlyName: string | undefined;
270 /** User description with the Alchemy ownership prefix stripped. */
271 description: string | undefined;
272 /** Cloud KMS key used for credential encryption, if any. */
273 kmsKeyName: string | undefined;
274 /** True when a credential is configured. */
275 hasCredential: boolean;
276 /** RFC3339 creation timestamp (millis since epoch as a string). */
277 creationTime: string | undefined;
278 /** RFC3339 last-update timestamp (millis since epoch as a string). */
279 lastModifiedTime: string | undefined;
280 /** Connection type currently configured. */
281 kind: ConnectionKind;
282 /**
283 * Google-managed service account used by jobs that use this
284 * connection, if the type exposes one.
285 */
286 serviceAccountId: string | undefined;
287 /** Cloud resource properties, if this is a cloud-resource connection. */
288 cloudResource: CloudResourceProperties | undefined;
289 /** Cloud SQL properties (password omitted). */
290 cloudSql: CloudSqlProperties | undefined;
291 /** Cloud Spanner properties. */
292 cloudSpanner: CloudSpannerProperties | undefined;
293 /** Spark properties. */
294 spark: SparkProperties | undefined;
295 /** AWS properties. */
296 aws: AwsProperties | undefined;
297 /** Azure properties. */
298 azure: AzureProperties | undefined;
299 /** Salesforce DataCloud properties. */
300 salesforceDataCloud: SalesforceDataCloudProperties | undefined;
301 /** Connector-framework configuration (secrets omitted). */
302 configuration: ConnectorConfiguration | undefined;
303 },
304 never,
305 Providers
306>;
307
308/**
309 * A BigQuery Connection to an external data source.
310 *
311 * Connections have no resource labels. Alchemy stamps ownership into the
312 * description (`[alchemy alchemy-stack=… alchemy-stage=… alchemy-id=…]`)
313 * so `list` / `pnpm nuke:gcp` can find them.
314 *
315 * When no connection type is set, Alchemy creates a Cloud resource
316 * connection (`cloudResource: {}`) — the cheapest type, with no extra
317 * Cloud SQL / Spanner / AWS resources. `connectionId`, `location`,
318 * `kmsKeyName`, and the connection type are immutable.
319 *
320 * ### Creating a Connection
321 * **Example:** Generated Cloud resource connection
322 * ```typescript
323 * const gcs = yield* GCP.BigQueryConnection.Connection("Gcs", {});
324 * ```
325 *
326 * **Example:** Named Cloud resource connection
327 * ```typescript
328 * const gcs = yield* GCP.BigQueryConnection.Connection("Gcs", {
329 * connectionId: "app-gcs",
330 * location: "us-central1",
331 * friendlyName: "GCS access",
332 * description: "delegates to Cloud Storage",
333 * cloudResource: {},
334 * });
335 * ```
336 *
337 * ### Spark
338 * **Example:** Spark stored-procedure connection
339 * ```typescript
340 * const spark = yield* GCP.BigQueryConnection.Connection("Spark", {
341 * spark: {},
342 * });
343 * ```
344 *
345 * ### Cloud SQL
346 * Cloud SQL connections need a live instance, database, and password —
347 * skip them in cheap tests.
348 *
349 * **Example:** Postgres Cloud SQL connection
350 * ```typescript
351 * const sql = yield* GCP.BigQueryConnection.Connection("Sql", {
352 * location: "US",
353 * cloudSql: {
354 * instanceId: "my-project:us-central1:app-sql",
355 * database: "app",
356 * type: "POSTGRES",
357 * credential: { username: "app", password: "secret" },
358 * },
359 * });
360 * ```
361 *
362 * @resource
363 * @category BigQueryConnection
364 */
365export const Connection = Resource<Connection>(
366 "GCP.BigQueryConnection.Connection",
367);
368
369export class ConnectionNotResolved extends Data.TaggedError(
370 "GCP.BigQueryConnection.ConnectionNotResolved",
371)<{
372 name: string;
373}> {}
374
375const lastSegment = (value: string) => {
376 const trimmed = value.replace(/\/+$/, "");
377 const parts = trimmed.split("/");
378 return parts[parts.length - 1] || trimmed;
379};
380
381const normalizeLocation = (location: string | undefined, fallback: string) => {
382 const raw = lastSegment(location ?? fallback);
383 const lower = raw.toLowerCase();
384 return MULTI_REGION.has(lower) ? lower.toUpperCase() : lower;
385};
386
387const resourceName = (
388 project: string,
389 location: string,
390 connectionId: string,
391) => `projects/${project}/locations/${location}/connections/${connectionId}`;
392
393const parentOf = (project: string, location: string) =>
394 `projects/${project}/locations/${location}`;
395
396const parseName = (name: string) => {
397 const parts = name.split("/").filter((part) => part.length > 0);
398 const connectionsAt = parts.lastIndexOf("connections");
399 const locationsAt = parts.lastIndexOf("locations");
400 const projectsAt = parts.lastIndexOf("projects");
401 return {
402 project:
403 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
404 location:
405 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
406 connectionId:
407 connectionsAt >= 0 && parts[connectionsAt + 1]
408 ? parts[connectionsAt + 1]!
409 : lastSegment(name),
410 };
411};
412
413const toId = (
414 id: string,
415 connectionId: string | undefined,
416 existing?: string,
417) =>
418 Effect.gen(function* () {
419 if (connectionId !== undefined) return connectionId;
420 if (existing !== undefined) return existing;
421 const generated = yield* createPhysicalName({
422 id,
423 maxLength: MAX_NAME_LENGTH,
424 lowercase: true,
425 delimiter: "_",
426 });
427 const sanitized = generated.replaceAll("-", "_");
428 return /^[a-z_]/.test(sanitized)
429 ? sanitized
430 : `c${sanitized}`.slice(0, MAX_NAME_LENGTH);
431 });
432
433const encodeDescription = (
434 labels: Record<string, string>,
435 description: string | undefined,
436): string => {
437 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
438 return description ? `${marker}\n${description}` : marker;
439};
440
441const parseDescription = (
442 description: string | undefined,
443): {
444 labels: Record<string, string>;
445 description: string | undefined;
446} => {
447 if (!description?.startsWith("[alchemy ")) {
448 return { labels: {}, description };
449 }
450 const end = description.indexOf("]");
451 if (end < 0) return { labels: {}, description };
452 const labels: Record<string, string> = {};
453 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
454 const eq = part.indexOf("=");
455 if (eq > 0) {
456 labels[part.slice(0, eq)] = part.slice(eq + 1);
457 }
458 }
459 const rest = description.slice(end + 1).replace(/^\n/, "");
460 return { labels, description: rest.length > 0 ? rest : undefined };
461};
462
463const hasOwnershipMarker = (description: string | undefined) =>
464 Object.keys(parseDescription(description).labels).some((key) =>
465 key.startsWith("alchemy-"),
466 );
467
468const jsonEqual = (left: unknown, right: unknown) =>
469 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
470
471const specifiedKind = (value: {
472 cloudSql?: unknown;
473 cloudSpanner?: unknown;
474 spark?: unknown;
475 aws?: unknown;
476 azure?: unknown;
477 salesforceDataCloud?: unknown;
478 configuration?: unknown;
479 cloudResource?: unknown;
480}): ConnectionKind | undefined => {
481 if (value.cloudSql !== undefined) return "cloudSql";
482 if (value.cloudSpanner !== undefined) return "cloudSpanner";
483 if (value.spark !== undefined) return "spark";
484 if (value.aws !== undefined) return "aws";
485 if (value.azure !== undefined) return "azure";
486 if (value.salesforceDataCloud !== undefined) return "salesforceDataCloud";
487 if (value.configuration !== undefined) return "configuration";
488 if (value.cloudResource !== undefined) return "cloudResource";
489 return undefined;
490};
491
492const kindOf = (value: Parameters<typeof specifiedKind>[0]): ConnectionKind =>
493 specifiedKind(value) ?? "cloudResource";
494
495const toCloudSql = (
496 properties: bigqueryconnection.CloudSqlProperties | undefined,
497): CloudSqlProperties | undefined => {
498 if (properties === undefined) return undefined;
499 return {
500 instanceId: properties.instanceId,
501 type: properties.type,
502 database: properties.database,
503 credential: properties.credential
504 ? { username: properties.credential.username }
505 : undefined,
506 };
507};
508
509const toSpark = (
510 properties: bigqueryconnection.SparkProperties | undefined,
511): SparkProperties | undefined => {
512 if (properties === undefined) return undefined;
513 return {
514 sparkHistoryServerConfig: properties.sparkHistoryServerConfig,
515 metastoreServiceConfig: properties.metastoreServiceConfig,
516 };
517};
518
519const toAws = (
520 properties: bigqueryconnection.AwsProperties | undefined,
521): AwsProperties | undefined => {
522 if (properties === undefined) return undefined;
523 return {
524 accessRole: properties.accessRole
525 ? { iamRoleId: properties.accessRole.iamRoleId }
526 : undefined,
527 };
528};
529
530const toAzure = (
531 properties: bigqueryconnection.AzureProperties | undefined,
532): AzureProperties | undefined => {
533 if (properties === undefined) return undefined;
534 return {
535 customerTenantId: properties.customerTenantId,
536 redirectUri: properties.redirectUri,
537 federatedApplicationClientId: properties.federatedApplicationClientId,
538 };
539};
540
541const toSpanner = (
542 properties: bigqueryconnection.CloudSpannerProperties | undefined,
543): CloudSpannerProperties | undefined => {
544 if (properties === undefined) return undefined;
545 return {
546 database: properties.database,
547 databaseRole: properties.databaseRole,
548 useParallelism: properties.useParallelism,
549 useServerlessAnalytics: properties.useServerlessAnalytics,
550 useDataBoost: properties.useDataBoost,
551 maxParallelism: properties.maxParallelism,
552 };
553};
554
555const toSalesforce = (
556 properties: bigqueryconnection.SalesforceDataCloudProperties | undefined,
557): SalesforceDataCloudProperties | undefined => {
558 if (properties === undefined) return undefined;
559 return {
560 instanceUri: properties.instanceUri,
561 tenantId: properties.tenantId,
562 };
563};
564
565const toSecret = (
566 secret: bigqueryconnection.ConnectorConfigurationSecret | undefined,
567): ConnectorSecret | undefined => {
568 if (secret === undefined) return undefined;
569 return secret.plaintext !== undefined
570 ? { plaintext: secret.plaintext }
571 : undefined;
572};
573
574const toParameterMap = (
575 parameters:
576 | bigqueryconnection.ConnectorConfigurationParameterValueMap
577 | undefined,
578): Record<string, ConnectorParameterValue | undefined> | undefined => {
579 if (parameters === undefined) return undefined;
580 return Object.fromEntries(
581 Object.entries(parameters).map(([key, value]) => [
582 key,
583 value
584 ? {
585 int32Value: value.int32Value,
586 stringValue: value.stringValue,
587 boolValue: value.boolValue,
588 doubleValue: value.doubleValue,
589 secretValue: toSecret(value.secretValue),
590 }
591 : undefined,
592 ]),
593 );
594};
595
596const toConfiguration = (
597 configuration: bigqueryconnection.ConnectorConfiguration | undefined,
598): ConnectorConfiguration | undefined => {
599 if (configuration === undefined) return undefined;
600 return {
601 connectorId: configuration.connectorId,
602 authentication: configuration.authentication
603 ? {
604 usernamePassword: configuration.authentication.usernamePassword
605 ? {
606 username:
607 configuration.authentication.usernamePassword.username,
608 password: toSecret(
609 configuration.authentication.usernamePassword.password,
610 ),
611 }
612 : undefined,
613 parameters: toParameterMap(configuration.authentication.parameters),
614 }
615 : undefined,
616 network: configuration.network,
617 parameters: toParameterMap(configuration.parameters),
618 asset: configuration.asset,
619 endpoint: configuration.endpoint,
620 };
621};
622
623const toCloudResource = (
624 properties: bigqueryconnection.CloudResourceProperties | undefined,
625 kind: ConnectionKind,
626): CloudResourceProperties | undefined => {
627 if (properties === undefined) {
628 return kind === "cloudResource" ? {} : undefined;
629 }
630 return { serviceAccountId: properties.serviceAccountId };
631};
632
633const serviceAccountOf = (
634 connection: bigqueryconnection.Connection,
635): string | undefined =>
636 connection.cloudResource?.serviceAccountId ??
637 connection.cloudSql?.serviceAccountId ??
638 connection.spark?.serviceAccountId ??
639 connection.configuration?.authentication?.serviceAccount ??
640 connection.aws?.accessRole?.identity ??
641 connection.azure?.identity ??
642 connection.salesforceDataCloud?.identity;
643
644const toAttrs = (
645 connection: bigqueryconnection.Connection,
646 project: string,
647) => {
648 const name = connection.name ?? "";
649 const parsed = parseName(name);
650 const kind = kindOf(connection);
651 const parsedDescription = parseDescription(connection.description);
652 return {
653 name,
654 connectionId: parsed.connectionId,
655 project: parsed.project || project,
656 location: parsed.location,
657 friendlyName: connection.friendlyName,
658 description: parsedDescription.description,
659 kmsKeyName: connection.kmsKeyName,
660 hasCredential: connection.hasCredential === true,
661 creationTime: connection.creationTime,
662 lastModifiedTime: connection.lastModifiedTime,
663 kind,
664 serviceAccountId: serviceAccountOf(connection),
665 cloudResource: toCloudResource(connection.cloudResource, kind),
666 cloudSql: toCloudSql(connection.cloudSql),
667 cloudSpanner: toSpanner(connection.cloudSpanner),
668 spark: toSpark(connection.spark),
669 aws: toAws(connection.aws),
670 azure: toAzure(connection.azure),
671 salesforceDataCloud: toSalesforce(connection.salesforceDataCloud),
672 configuration: toConfiguration(connection.configuration),
673 };
674};
675
676const writableSpark = (properties: SparkProperties | undefined) => {
677 if (properties === undefined) return undefined;
678 return {
679 sparkHistoryServerConfig: properties.sparkHistoryServerConfig,
680 metastoreServiceConfig: properties.metastoreServiceConfig,
681 };
682};
683
684const writableAws = (properties: AwsProperties | undefined) => {
685 if (properties === undefined) return undefined;
686 return {
687 accessRole: properties.accessRole
688 ? { iamRoleId: properties.accessRole.iamRoleId }
689 : undefined,
690 };
691};
692
693const writableAzure = (properties: AzureProperties | undefined) => {
694 if (properties === undefined) return undefined;
695 return {
696 customerTenantId: properties.customerTenantId,
697 redirectUri: properties.redirectUri,
698 federatedApplicationClientId: properties.federatedApplicationClientId,
699 };
700};
701
702const writableSpanner = (properties: CloudSpannerProperties | undefined) => {
703 if (properties === undefined) return undefined;
704 return {
705 database: properties.database,
706 databaseRole: properties.databaseRole,
707 useParallelism: properties.useParallelism,
708 useServerlessAnalytics: properties.useServerlessAnalytics,
709 useDataBoost: properties.useDataBoost,
710 maxParallelism: properties.maxParallelism,
711 };
712};
713
714const writableSalesforce = (
715 properties: SalesforceDataCloudProperties | undefined,
716) => {
717 if (properties === undefined) return undefined;
718 return {
719 instanceUri: properties.instanceUri,
720 tenantId: properties.tenantId,
721 };
722};
723
724const writableConfiguration = (
725 configuration: ConnectorConfiguration | undefined,
726) => {
727 if (configuration === undefined) return undefined;
728 return {
729 connectorId: configuration.connectorId,
730 authentication: configuration.authentication,
731 network: configuration.network,
732 parameters: configuration.parameters,
733 asset: configuration.asset,
734 endpoint: configuration.endpoint,
735 };
736};
737
738const toCreateBody = (
739 news: ConnectionProps,
740 ownership: Record<string, string>,
741): bigqueryconnection.Connection => {
742 const kind = kindOf(news);
743 const body: bigqueryconnection.Connection = {
744 friendlyName: news.friendlyName,
745 description: encodeDescription(ownership, news.description),
746 kmsKeyName: news.kmsKeyName,
747 };
748 switch (kind) {
749 case "cloudSql":
750 body.cloudSql = news.cloudSql;
751 break;
752 case "cloudSpanner":
753 body.cloudSpanner = news.cloudSpanner;
754 break;
755 case "spark":
756 body.spark = news.spark ?? {};
757 break;
758 case "aws":
759 body.aws = news.aws;
760 break;
761 case "azure":
762 body.azure = news.azure;
763 break;
764 case "salesforceDataCloud":
765 body.salesforceDataCloud = news.salesforceDataCloud;
766 break;
767 case "configuration":
768 body.configuration = news.configuration;
769 break;
770 default:
771 body.cloudResource = {};
772 break;
773 }
774 return body;
775};
776
777const getByName = (name: string) =>
778 bigqueryconnection
779 .getProjectsLocationsConnections({ name })
780 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
781
782const listOwnedAt = (parent: string, project: string) =>
783 bigqueryconnection.listProjectsLocationsConnections
784 .pages({
785 parent,
786 pageSize: 1000,
787 })
788 .pipe(
789 Stream.flatMap((page) => Stream.fromIterable(page.connections ?? [])),
790 Stream.filter((connection) => hasOwnershipMarker(connection.description)),
791 Stream.map((connection) => toAttrs(connection, project)),
792 Stream.runCollect,
793 Effect.map((chunk) => Array.from(chunk)),
794 Effect.catchTag("NotFound", () => Effect.succeed([])),
795 );
796
797export const ConnectionProvider = () =>
798 Provider.succeed(Connection, {
799 stables: [
800 "name",
801 "connectionId",
802 "project",
803 "location",
804 "kind",
805 "kmsKeyName",
806 "creationTime",
807 ],
808
809 diff: Effect.fn(function* ({ news, olds, output }) {
810 const env = yield* GcpEnvironment.current;
811 if (!isResolved(news)) return undefined;
812
813 const previousId = olds?.connectionId ?? output?.connectionId;
814 const nextId = news.connectionId ?? previousId;
815 const idChanged =
816 previousId !== undefined &&
817 nextId !== undefined &&
818 nextId !== previousId;
819
820 const previousLocation = normalizeLocation(
821 olds?.location ?? output?.location,
822 env.region,
823 );
824 const nextLocation = normalizeLocation(
825 news.location ?? output?.location,
826 env.region,
827 );
828 const locationChanged = previousLocation !== nextLocation;
829
830 const previousKms = olds?.kmsKeyName ?? output?.kmsKeyName ?? "";
831 const nextKms = news.kmsKeyName ?? previousKms;
832 const kmsChanged = nextKms !== previousKms;
833
834 const previousKind =
835 specifiedKind(olds ?? {}) ??
836 specifiedKind(output ?? {}) ??
837 "cloudResource";
838 const nextKind = specifiedKind(news) ?? previousKind;
839 const kindChanged = previousKind !== nextKind;
840
841 const previousConnector =
842 olds?.configuration?.connectorId ?? output?.configuration?.connectorId;
843 const nextConnector =
844 news.configuration?.connectorId ?? previousConnector;
845 const connectorChanged =
846 previousConnector !== undefined &&
847 nextConnector !== undefined &&
848 nextConnector !== previousConnector;
849
850 if (
851 !idChanged &&
852 !locationChanged &&
853 !kmsChanged &&
854 !kindChanged &&
855 !connectorChanged
856 ) {
857 return undefined;
858 }
859 return {
860 action: "replace" as const,
861 deleteFirst: !idChanged && !locationChanged,
862 };
863 }),
864
865 read: Effect.fn(function* ({ id, olds, output }) {
866 const env = yield* GcpEnvironment.current;
867 const connectionId = yield* toId(
868 id,
869 olds?.connectionId,
870 output?.connectionId,
871 );
872 const location = normalizeLocation(
873 olds?.location ?? output?.location,
874 env.region,
875 );
876 const name =
877 output?.name ?? resourceName(env.project, location, connectionId);
878 const existing = yield* getByName(name);
879 if (existing === undefined) return undefined;
880 const attrs = toAttrs(existing, env.project);
881 return (yield* hasAlchemyLabels(
882 id,
883 parseDescription(existing.description).labels,
884 ))
885 ? attrs
886 : Unowned(attrs);
887 }),
888
889 list: () =>
890 Effect.gen(function* () {
891 const env = yield* GcpEnvironment.current;
892 const wildcard = yield* listOwnedAt(
893 parentOf(env.project, "-"),
894 env.project,
895 );
896 if (wildcard.length > 0) return wildcard;
897 const fallback = yield* Effect.forEach(
898 [...new Set(["us-central1", env.region, "US", "EU", "us-east1"])],
899 (location) =>
900 listOwnedAt(parentOf(env.project, location), env.project),
901 { concurrency: 4 },
902 );
903 const seen = new Set<string>();
904 return fallback.flat().filter((connection) => {
905 if (seen.has(connection.name)) return false;
906 seen.add(connection.name);
907 return true;
908 });
909 }),
910
911 reconcile: Effect.fn(function* ({ id, news, output }) {
912 const env = yield* GcpEnvironment.current;
913 const connectionId = yield* toId(
914 id,
915 news.connectionId,
916 output?.connectionId,
917 );
918 const location = normalizeLocation(
919 news.location ?? output?.location,
920 env.region,
921 );
922 const name = resourceName(env.project, location, connectionId);
923 const parent = parentOf(env.project, location);
924 const ownership = yield* createInternalLabels(id);
925 const desiredDescription = encodeDescription(ownership, news.description);
926
927 let current = yield* getByName(output?.name ?? name);
928
929 if (current === undefined) {
930 const created = yield* bigqueryconnection
931 .createProjectsLocationsConnections({
932 parent,
933 connectionId,
934 body: toCreateBody(news, ownership),
935 })
936 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
937 current = created ?? undefined;
938 }
939
940 if (current === undefined) {
941 return yield* new ConnectionNotResolved({ name });
942 }
943
944 const updateMask: string[] = [];
945 const body: bigqueryconnection.Connection = {};
946
947 if ((current.friendlyName ?? "") !== (news.friendlyName ?? "")) {
948 updateMask.push("friendlyName");
949 body.friendlyName = news.friendlyName;
950 }
951 if ((current.description ?? "") !== desiredDescription) {
952 updateMask.push("description");
953 body.description = desiredDescription;
954 }
955
956 const kind = kindOf(news);
957 if (kind === "cloudSql" && news.cloudSql !== undefined) {
958 const observedSql = {
959 instanceId: current.cloudSql?.instanceId,
960 type: current.cloudSql?.type,
961 database: current.cloudSql?.database,
962 };
963 const desiredSql = {
964 instanceId: news.cloudSql.instanceId,
965 type: news.cloudSql.type,
966 database: news.cloudSql.database,
967 };
968 if (!jsonEqual(observedSql, desiredSql)) {
969 updateMask.push("cloudSql");
970 body.cloudSql = news.cloudSql;
971 }
972 }
973 if (
974 kind === "cloudSpanner" &&
975 news.cloudSpanner !== undefined &&
976 !jsonEqual(
977 writableSpanner(toSpanner(current.cloudSpanner)),
978 writableSpanner(news.cloudSpanner),
979 )
980 ) {
981 updateMask.push("cloudSpanner");
982 body.cloudSpanner = news.cloudSpanner;
983 }
984 if (
985 kind === "spark" &&
986 !jsonEqual(
987 writableSpark(toSpark(current.spark)),
988 writableSpark(news.spark ?? {}),
989 )
990 ) {
991 updateMask.push("spark");
992 body.spark = news.spark ?? {};
993 }
994 if (
995 kind === "aws" &&
996 news.aws !== undefined &&
997 !jsonEqual(writableAws(toAws(current.aws)), writableAws(news.aws))
998 ) {
999 updateMask.push("aws");
1000 body.aws = news.aws;
1001 }
1002 if (
1003 kind === "azure" &&
1004 news.azure !== undefined &&
1005 !jsonEqual(
1006 writableAzure(toAzure(current.azure)),
1007 writableAzure(news.azure),
1008 )
1009 ) {
1010 updateMask.push("azure");
1011 body.azure = news.azure;
1012 }
1013 if (
1014 kind === "salesforceDataCloud" &&
1015 news.salesforceDataCloud !== undefined &&
1016 !jsonEqual(
1017 writableSalesforce(toSalesforce(current.salesforceDataCloud)),
1018 writableSalesforce(news.salesforceDataCloud),
1019 )
1020 ) {
1021 updateMask.push("salesforceDataCloud");
1022 body.salesforceDataCloud = news.salesforceDataCloud;
1023 }
1024 if (
1025 kind === "configuration" &&
1026 news.configuration !== undefined &&
1027 !jsonEqual(
1028 writableConfiguration(toConfiguration(current.configuration)),
1029 writableConfiguration(news.configuration),
1030 )
1031 ) {
1032 updateMask.push("configuration");
1033 body.configuration = news.configuration;
1034 }
1035
1036 if (updateMask.length > 0) {
1037 const patched = yield* bigqueryconnection
1038 .patchProjectsLocationsConnections({
1039 name,
1040 updateMask: updateMask.join(","),
1041 body,
1042 })
1043 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
1044 if (patched === undefined) {
1045 const created = yield* bigqueryconnection
1046 .createProjectsLocationsConnections({
1047 parent,
1048 connectionId,
1049 body: toCreateBody(news, ownership),
1050 })
1051 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
1052 current = created ?? undefined;
1053 } else {
1054 current = patched;
1055 }
1056 }
1057
1058 if (current === undefined) {
1059 return yield* new ConnectionNotResolved({ name });
1060 }
1061
1062 const latest = (yield* getByName(current.name ?? name)) ?? current;
1063 return toAttrs(latest, env.project);
1064 }),
1065
1066 delete: Effect.fn(function* ({ output }) {
1067 yield* bigqueryconnection
1068 .deleteProjectsLocationsConnections({ name: output.name })
1069 .pipe(Effect.catchTag("NotFound", () => Effect.void));
1070 }),
1071 });