1import * as integrations from "@distilled.cloud/gcp/integrations_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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnership,
14 hasOwnershipMarker,
15 lastSegment,
16 locationOf,
17 locationParent,
18 normalizeLocation,
19 ownedByAlchemy,
20 parentOf,
21 parseOwnership,
22 sameText,
23 toResourceId,
24 updateMaskOf,
25} from "./ownership.ts";
26import type { TriggerConfig } from "./shared.ts";
27
28export type IntegrationsVersionProps = {
29 /**
30 * Integration id (the `{integration}` segment of
31 * `projects/{project}/locations/{location}/integrations/{integration}`).
32 * If omitted, a unique id is generated. Immutable — changing it
33 * replaces the version.
34 */
35 integrationId?: string;
36 /**
37 * Version id (the `{version}` segment). Server-assigned on create.
38 * Immutable — changing it replaces the version.
39 */
40 versionId?: string;
41 /**
42 * Location (`us-central1`, …). Immutable — changing it replaces the
43 * version. `US-CENTRAL1` is accepted and normalized to `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, else the profile
45 * region, else `us-central1`)
46 */
47 location?: string;
48 /**
49 * Human-readable description. Integration versions have no labels
50 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
51 * stripped from attributes.
52 */
53 description?: string;
54 /**
55 * User-defined label that annotates this version.
56 */
57 userLabel?: string;
58 /**
59 * Trigger configurations. A draft with no tasks is valid; add an API
60 * trigger to make the version executable.
61 */
62 triggerConfigs?: TriggerConfig[];
63 /**
64 * When true, create a new integration if `integrationId` does not
65 * exist. Ignored on update.
66 * @default true
67 */
68 newIntegration?: boolean;
69};
70
71export type IntegrationsVersion = Resource<
72 "GCP.Integrations.IntegrationsVersion",
73 IntegrationsVersionProps,
74 {
75 /** Full resource name `projects/{project}/locations/{location}/integrations/{integration}/versions/{version}`. */
76 name: string;
77 /** Version id (last path segment). */
78 versionId: string;
79 /** Parent integration id. */
80 integrationId: string;
81 /** Parent integration resource name. */
82 integration: string;
83 /** Location id. */
84 location: string;
85 /** Project id. */
86 project: string;
87 /** User description with the Alchemy ownership prefix stripped. */
88 description: string | undefined;
89 /** User-defined version label. */
90 userLabel: string | undefined;
91 /** Trigger configurations. */
92 triggerConfigs: TriggerConfig[] | undefined;
93 /** Server-reported status (`DRAFT`, `ACTIVE`, …). */
94 status: string | undefined;
95 /** Server-reported state. */
96 state: string | undefined;
97 /** Snapshot number. */
98 snapshotNumber: string | undefined;
99 /** RFC3339 creation timestamp. */
100 createTime: string | undefined;
101 /** RFC3339 last-update timestamp. */
102 updateTime: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * An Application Integration version (a draft or published snapshot of
110 * an integration).
111 *
112 * Versions have no labels field — Alchemy stamps ownership into the
113 * description. Location, integration id, and version id are immutable.
114 * Description, user label, and trigger configs update in place. Draft
115 * versions cannot be deleted via the versions API; Alchemy deletes the
116 * parent integration instead.
117 *
118 * ### Creating a Version
119 * **Example:** Empty draft with an API trigger
120 * ```typescript
121 * const version = yield* GCP.Integrations.IntegrationsVersion("Orders", {
122 * description: "order workflow",
123 * triggerConfigs: [
124 * {
125 * label: "API Trigger",
126 * triggerType: "API",
127 * triggerNumber: "1",
128 * triggerId: "api_trigger/orders",
129 * properties: { "Trigger name": "orders" },
130 * },
131 * ],
132 * });
133 * ```
134 *
135 * @resource
136 * @category Integrations
137 */
138export const IntegrationsVersion = Resource<IntegrationsVersion>(
139 "GCP.Integrations.IntegrationsVersion",
140);
141
142export class IntegrationsVersionNotResolved extends Data.TaggedError(
143 "GCP.Integrations.IntegrationsVersionNotResolved",
144)<{
145 name: string;
146}> {}
147
148const integrationOf = (name: string) => {
149 const parts = name.split("/");
150 const index = parts.indexOf("integrations");
151 return index >= 0 ? parts.slice(0, index + 2).join("/") : parentOf(name);
152};
153
154const integrationIdOf = (name: string) => {
155 const parts = name.split("/");
156 const index = parts.indexOf("integrations");
157 return index >= 0
158 ? (parts[index + 1] ?? lastSegment(name))
159 : lastSegment(name);
160};
161
162const resourceName = (
163 project: string,
164 location: string,
165 integrationId: string,
166 versionId: string,
167) =>
168 `${locationParent(project, location)}/integrations/${integrationId}/versions/${versionId}`;
169
170const triggersOf = (
171 configs:
172 | integrations.GoogleCloudIntegrationsV1alphaTriggerConfigList
173 | undefined,
174): TriggerConfig[] | undefined => {
175 if (configs === undefined) return undefined;
176 return configs.map((config) => ({
177 label: config.label,
178 triggerType: config.triggerType,
179 triggerNumber: config.triggerNumber,
180 triggerId: config.triggerId,
181 properties: config.properties,
182 description: config.description,
183 }));
184};
185
186const toAttrs = (
187 version: integrations.GoogleCloudIntegrationsV1alphaIntegrationVersion,
188 project: string,
189 region: string,
190) => {
191 const name = version.name ?? "";
192 const parsed = parseOwnership(version.description);
193 return {
194 name,
195 versionId: lastSegment(name),
196 integrationId: integrationIdOf(name),
197 integration: integrationOf(name),
198 location: locationOf(name, region),
199 project,
200 description: parsed.text,
201 userLabel: version.userLabel,
202 triggerConfigs: triggersOf(version.triggerConfigs),
203 status: version.status,
204 state: version.state,
205 snapshotNumber: version.snapshotNumber,
206 createTime: version.createTime,
207 updateTime: version.updateTime,
208 };
209};
210
211const getByName = (name: string) =>
212 name.length === 0
213 ? Effect.succeed(undefined)
214 : integrations
215 .getProjectsLocationsIntegrationsVersions({ name })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218const listAt = (parent: string, project: string, region: string) =>
219 integrations.listProjectsLocationsIntegrationsVersions
220 .pages({ parent, pageSize: 100 })
221 .pipe(
222 Stream.flatMap((page) =>
223 Stream.fromIterable(page.integrationVersions ?? []),
224 ),
225 Stream.filter((version) => hasOwnershipMarker(version.description)),
226 Stream.map((version) => toAttrs(version, project, region)),
227 Stream.runCollect,
228 Effect.map((chunk) => Array.from(chunk)),
229 Effect.catchTag("NotFound", () => Effect.succeed([])),
230 );
231
232const findOwned = (parent: string, id: string) =>
233 integrations.listProjectsLocationsIntegrationsVersions
234 .pages({ parent, pageSize: 100 })
235 .pipe(
236 Stream.flatMap((page) =>
237 Stream.fromIterable(page.integrationVersions ?? []),
238 ),
239 Stream.filterEffect((version) => ownedByAlchemy(id, version.description)),
240 Stream.runHead,
241 Effect.map((option) =>
242 option._tag === "Some" ? option.value : undefined,
243 ),
244 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
245 );
246
247const deleteIntegration = (name: string) =>
248 name.length === 0
249 ? Effect.void
250 : integrations.deleteProjectsLocationsIntegrations({ name }).pipe(
251 Effect.catchTag("NotFound", () => Effect.void),
252 Effect.catchTag("BadRequest", () => Effect.void),
253 Effect.catchTag("Conflict", () => Effect.void),
254 );
255
256export const IntegrationsVersionProvider = () =>
257 Provider.succeed(IntegrationsVersion, {
258 stables: [
259 "name",
260 "versionId",
261 "integrationId",
262 "integration",
263 "location",
264 "project",
265 "createTime",
266 ],
267
268 diff: Effect.fn(function* ({ news, olds, output }) {
269 if (!isResolved(news)) return undefined;
270 const env = yield* GcpEnvironment.current;
271 const previousLocation = olds?.location ?? output?.location;
272 const nextLocation = normalizeLocation(
273 news.location,
274 previousLocation ?? env.region,
275 );
276 if (
277 previousLocation !== undefined &&
278 normalizeLocation(previousLocation, env.region) !== nextLocation
279 ) {
280 return { action: "replace" as const, deleteFirst: false };
281 }
282 const previousIntegration = olds?.integrationId ?? output?.integrationId;
283 if (
284 previousIntegration !== undefined &&
285 news.integrationId !== undefined &&
286 news.integrationId !== previousIntegration
287 ) {
288 return { action: "replace" as const, deleteFirst: false };
289 }
290 const previousId = olds?.versionId ?? output?.versionId;
291 if (
292 previousId !== undefined &&
293 news.versionId !== undefined &&
294 news.versionId !== previousId
295 ) {
296 return { action: "replace" as const, deleteFirst: false };
297 }
298 return undefined;
299 }),
300
301 read: Effect.fn(function* ({ id, olds, output }) {
302 const env = yield* GcpEnvironment.current;
303 const integrationId = yield* toResourceId(
304 id,
305 olds?.integrationId,
306 output?.integrationId,
307 );
308 const versionId = olds?.versionId ?? output?.versionId ?? "";
309 const location = normalizeLocation(
310 olds?.location ?? output?.location,
311 env.region,
312 );
313 const name =
314 output?.name ??
315 (versionId.length > 0
316 ? resourceName(env.project, location, integrationId, versionId)
317 : "");
318 let existing = yield* getByName(name);
319 if (existing === undefined) {
320 existing = yield* findOwned(
321 `${locationParent(env.project, location)}/integrations/${integrationId}`,
322 id,
323 );
324 }
325 if (existing === undefined) return undefined;
326 const attrs = toAttrs(existing, env.project, env.region);
327 return (yield* ownedByAlchemy(id, existing.description))
328 ? attrs
329 : Unowned(attrs);
330 }),
331
332 list: () =>
333 Effect.gen(function* () {
334 const env = yield* GcpEnvironment.current;
335 return yield* listAt(
336 `${locationParent(env.project, env.region)}/integrations/-`,
337 env.project,
338 env.region,
339 );
340 }),
341
342 reconcile: Effect.fn(function* ({ id, news, output }) {
343 const env = yield* GcpEnvironment.current;
344 const location = normalizeLocation(
345 news.location ?? output?.location,
346 env.region,
347 );
348 const integrationId = yield* toResourceId(
349 id,
350 news.integrationId,
351 output?.integrationId,
352 );
353 const parent = `${locationParent(env.project, location)}/integrations/${integrationId}`;
354 const versionId = news.versionId ?? output?.versionId ?? "";
355 const name =
356 output?.name ??
357 (versionId.length > 0
358 ? resourceName(env.project, location, integrationId, versionId)
359 : parent);
360 const ownership = yield* createInternalLabels(id);
361 const description = encodeOwnership(ownership, news.description);
362 const triggerConfigs = news.triggerConfigs;
363 const newIntegration = news.newIntegration !== false;
364
365 let current = yield* getByName(output?.name ?? (versionId ? name : ""));
366 if (current === undefined) {
367 current = yield* findOwned(parent, id);
368 }
369
370 if (current === undefined) {
371 const tryCreate = (createNew: boolean) =>
372 integrations.createProjectsLocationsIntegrationsVersions({
373 parent,
374 newIntegration: createNew,
375 body: {
376 description,
377 userLabel: news.userLabel,
378 triggerConfigs,
379 },
380 });
381 const created = yield* tryCreate(newIntegration).pipe(
382 Effect.catchTag(["Conflict", "BadRequest"], () =>
383 tryCreate(false).pipe(
384 Effect.catchTag(["Conflict", "BadRequest"], () =>
385 findOwned(parent, id),
386 ),
387 ),
388 ),
389 );
390 current = created ?? (yield* findOwned(parent, id));
391 }
392
393 if (current === undefined) {
394 return yield* new IntegrationsVersionNotResolved({ name });
395 }
396
397 const currentName = current.name ?? name;
398 const descriptionChanged = (current.description ?? "") !== description;
399 const labelChanged = !sameText(current.userLabel, news.userLabel);
400 const triggersChanged = !jsonTriggersEqual(
401 triggersOf(current.triggerConfigs),
402 triggerConfigs,
403 );
404
405 if (descriptionChanged || labelChanged || triggersChanged) {
406 current =
407 yield* integrations.patchProjectsLocationsIntegrationsVersions({
408 name: currentName,
409 updateMask: updateMaskOf(
410 descriptionChanged ? "description" : undefined,
411 labelChanged ? "user_label" : undefined,
412 triggersChanged ? "trigger_configs" : undefined,
413 ),
414 body: {
415 name: currentName,
416 description,
417 userLabel: news.userLabel,
418 triggerConfigs,
419 },
420 });
421 }
422
423 return toAttrs(current, env.project, env.region);
424 }),
425
426 delete: Effect.fn(function* ({ output }) {
427 yield* integrations
428 .deleteProjectsLocationsIntegrationsVersions({ name: output.name })
429 .pipe(
430 Effect.catchTag("NotFound", () => Effect.void),
431 Effect.catchTag("BadRequest", () => Effect.void),
432 Effect.catchTag("Conflict", () => Effect.void),
433 );
434 yield* deleteIntegration(output.integration);
435 }),
436 });
437
438const jsonTriggersEqual = (
439 left: TriggerConfig[] | undefined,
440 right: TriggerConfig[] | undefined,
441) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);