1import * as integrations from "@distilled.cloud/gcp/integrations_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import { findTemplateByDescription, listTemplates } from "./internal.ts";
12import {
13 encodeOwnership,
14 fingerprint,
15 hasOwnershipMarker,
16 lastSegment,
17 locationOf,
18 locationParent,
19 MAX_TEMPLATE_DESCRIPTION_LENGTH,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseOwnership,
23 withProjectId,
24 replaceOnIdentity,
25 sameText,
26 toResourceId,
27 updateMaskOf,
28} from "./ownership.ts";
29
30export type TemplateCategory =
31 | "CATEGORY_UNSPECIFIED"
32 | "AI_MACHINE_LEARNING"
33 | "BUSINESS_INTELLIGENCE"
34 | "COLLABORATION"
35 | "CUSTOMER_SERVICE"
36 | "DATABASES"
37 | "DEVOPS_IT"
38 | "CONTENT_AND_FILES"
39 | "FINANCE_AND_ACCOUNTING"
40 | "HUMAN_RESOURCES"
41 | "OPERATIONS"
42 | "PRODUCT_PROJECT_MANAGEMENT"
43 | "PRODUCTIVITY"
44 | "SALES_AND_MARKETING"
45 | "UNIVERSAL_CONNECTORS"
46 | "UTILITY"
47 | "OTHERS";
48
49export type TemplateVisibility =
50 | "VISIBILITY_UNSPECIFIED"
51 | "PRIVATE"
52 | "SHARED"
53 | "PUBLIC";
54
55export type TemplateComponentType =
56 | "TYPE_UNSPECIFIED"
57 | "TRIGGER"
58 | "TASK"
59 | "CONNECTOR";
60
61export type TemplateComponent = {
62 /** Component type. */
63 type?: TemplateComponentType | (string & {});
64 /** Component name. */
65 name?: string;
66};
67
68export type TemplateBundle =
69 integrations.GoogleCloudIntegrationsV1alphaTemplateBundle;
70
71export type TemplateProps = {
72 /**
73 * Template id (the `{template}` segment of
74 * `projects/{project}/locations/{location}/templates/{template}`).
75 * Server-assigned on create when omitted. Immutable — changing it
76 * replaces the template.
77 */
78 templateId?: string;
79 /**
80 * Location (`us-central1`, …). Immutable — changing it replaces the
81 * template. `US-CENTRAL1` is accepted and normalized to `us-central1`.
82 * @default the stack's GCP region (`GCP.Region`, else the profile
83 * region, else `us-central1`)
84 */
85 location?: string;
86 /**
87 * Template display name. Generated from the logical id when omitted.
88 */
89 displayName?: string;
90 /**
91 * Description (max 255 characters). Templates have no labels field, so
92 * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it
93 * from attributes.
94 */
95 description?: string;
96 /**
97 * How to use the template.
98 */
99 usageInfo?: string;
100 /**
101 * Link to template documentation.
102 */
103 docLink?: string;
104 /**
105 * Bundle converted into an integration when the template is used. When
106 * omitted, a minimal API-trigger integration is used.
107 */
108 templateBundle?: TemplateBundle;
109 /**
110 * Components used for categorization and filtering.
111 */
112 components?: TemplateComponent[];
113 /**
114 * Business tags used to identify the template.
115 * @default ["utility"]
116 */
117 tags?: string[];
118 /**
119 * Categories used when listing templates.
120 * @default ["UTILITY"]
121 */
122 categories?: Array<TemplateCategory | (string & {})>;
123 /**
124 * Template author.
125 */
126 author?: string;
127 /**
128 * Visibility of the template.
129 * @default "PRIVATE"
130 */
131 visibility?: TemplateVisibility | (string & {});
132 /**
133 * Resource names the template is shared with (project numbers or org
134 * ids).
135 */
136 sharedWith?: string[];
137};
138
139export type Template = Resource<
140 "GCP.Integrations.Template",
141 TemplateProps,
142 {
143 /** Full resource name `projects/{project}/locations/{location}/templates/{id}`. */
144 name: string;
145 /** Template id (last path segment). */
146 templateId: string;
147 /** Location id. */
148 location: string;
149 /** Project id. */
150 project: string;
151 /** Display name. */
152 displayName: string | undefined;
153 /** User description with the Alchemy ownership prefix stripped. */
154 description: string | undefined;
155 /** Usage notes. */
156 usageInfo: string | undefined;
157 /** Documentation link. */
158 docLink: string | undefined;
159 /** Integration bundle. */
160 templateBundle: TemplateBundle | undefined;
161 /** Components. */
162 components: TemplateComponent[];
163 /** Business tags. */
164 tags: string[];
165 /** Categories. */
166 categories: string[];
167 /** Author. */
168 author: string | undefined;
169 /** Visibility. */
170 visibility: string | undefined;
171 /** Share targets. */
172 sharedWith: string[];
173 /** Usage count. */
174 usageCount: string | undefined;
175 /** Last used time. */
176 lastUsedTime: string | undefined;
177 /** RFC3339 creation timestamp. */
178 createTime: string | undefined;
179 /** RFC3339 last-update timestamp. */
180 updateTime: string | undefined;
181 },
182 never,
183 Providers
184>;
185
186const DEFAULT_TAGS = ["utility"];
187const DEFAULT_CATEGORIES: TemplateCategory[] = ["UTILITY"];
188const DEFAULT_VISIBILITY: TemplateVisibility = "PRIVATE";
189
190export const defaultTemplateBundle = (): TemplateBundle => ({
191 integrationVersionTemplate: {
192 key: "main",
193 integrationVersion: {
194 triggerConfigs: [
195 {
196 label: "API Trigger",
197 triggerNumber: "1",
198 triggerType: "API",
199 triggerId: "api_trigger/alchemy",
200 properties: { "Trigger name": "alchemy" },
201 },
202 ],
203 },
204 },
205});
206
207/**
208 * An Application Integration template: a reusable integration bundle plus
209 * metadata (categories, tags, visibility).
210 *
211 * Templates have no labels field, so Alchemy stamps ownership into the
212 * description for `list` / nuke. Location and id are identity — changing
213 * them replaces the template. Display name, description, bundle, tags,
214 * categories, and visibility update in place.
215 *
216 * ### Creating a Template
217 * **Example:** Private utility template
218 * ```typescript
219 * const template = yield* GCP.Integrations.Template("Orders", {
220 * displayName: "order-sync",
221 * description: "sync orders",
222 * tags: ["orders"],
223 * categories: ["SALES_AND_MARKETING"],
224 * });
225 * ```
226 *
227 * ### Updating a Template
228 * Re-declare the same logical id with changed props; the engine keeps the
229 * physical resource and updates it in place.
230 *
231 * **Example:** Rename and retag
232 * ```typescript
233 * const template = yield* GCP.Integrations.Template("Orders", {
234 * displayName: "order-sync-v2",
235 * description: "sync orders v2",
236 * tags: ["orders", "v2"],
237 * categories: ["SALES_AND_MARKETING"],
238 * });
239 * ```
240 *
241 * @resource
242 * @category Integrations
243 */
244export const Template = Resource<Template>("GCP.Integrations.Template");
245
246export class TemplateNotResolved extends Data.TaggedError(
247 "GCP.Integrations.TemplateNotResolved",
248)<{
249 name: string;
250}> {}
251
252const resourceName = (project: string, location: string, templateId: string) =>
253 `${locationParent(project, location)}/templates/${templateId}`;
254
255const toAttrs = (
256 template: integrations.GoogleCloudIntegrationsV1alphaTemplate,
257 project: string,
258 region: string,
259) => {
260 const name = withProjectId(template.name ?? "", project);
261 return {
262 name,
263 templateId: lastSegment(name),
264 location: locationOf(name, region),
265 project,
266 displayName: template.displayName,
267 description: parseOwnership(template.description).text,
268 usageInfo: template.usageInfo,
269 docLink: template.docLink,
270 templateBundle: template.templateBundle,
271 components: [...(template.components ?? [])],
272 tags: [...(template.tags ?? [])],
273 categories: [...(template.categories ?? [])],
274 author: template.author,
275 visibility: template.visibility,
276 sharedWith: [...(template.sharedWith ?? [])],
277 usageCount: template.usageCount,
278 lastUsedTime: template.lastUsedTime,
279 createTime: template.createTime,
280 updateTime: template.updateTime,
281 };
282};
283
284const getByName = (name: string) =>
285 name.length === 0
286 ? Effect.succeed(undefined)
287 : integrations
288 .getProjectsLocationsTemplates({ name })
289 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
290
291export const TemplateProvider = () =>
292 Provider.succeed(Template, {
293 stables: ["name", "templateId", "location", "project", "createTime"],
294
295 diff: Effect.fn(function* ({ news, olds, output }) {
296 if (!isResolved(news)) return undefined;
297 const env = yield* GcpEnvironment.current;
298 const previousId = olds?.templateId ?? output?.templateId;
299 const idChanged =
300 previousId !== undefined &&
301 news.templateId !== undefined &&
302 news.templateId !== previousId;
303 const previousLocation = olds?.location ?? output?.location;
304 const locationChanged =
305 previousLocation !== undefined &&
306 news.location !== undefined &&
307 normalizeLocation(news.location, env.region) !==
308 normalizeLocation(previousLocation, env.region);
309 return replaceOnIdentity(idChanged || locationChanged);
310 }),
311
312 read: Effect.fn(function* ({ id, olds, output }) {
313 const env = yield* GcpEnvironment.current;
314 const templateId = yield* toResourceId(
315 id,
316 olds?.templateId,
317 output?.templateId,
318 );
319 const location = normalizeLocation(
320 olds?.location ?? output?.location,
321 env.region,
322 );
323 const name =
324 output?.name ?? resourceName(env.project, location, templateId);
325 let existing = yield* getByName(name);
326 if (existing === undefined && output?.name === undefined) {
327 const ownership = yield* createInternalLabels(id);
328 existing = yield* findTemplateByDescription(
329 locationParent(env.project, location),
330 encodeOwnership(
331 ownership,
332 olds?.description,
333 MAX_TEMPLATE_DESCRIPTION_LENGTH,
334 ),
335 );
336 }
337 if (existing === undefined) return undefined;
338 const attrs = toAttrs(existing, env.project, env.region);
339 return (yield* ownedByAlchemy(id, existing.description))
340 ? attrs
341 : Unowned(attrs);
342 }),
343
344 list: () =>
345 Effect.gen(function* () {
346 const env = yield* GcpEnvironment.current;
347 const items = yield* listTemplates(
348 locationParent(env.project, env.region),
349 );
350 return items
351 .filter((template) => hasOwnershipMarker(template.description))
352 .map((template) => toAttrs(template, env.project, env.region));
353 }),
354
355 reconcile: Effect.fn(function* ({ id, news, output }) {
356 const env = yield* GcpEnvironment.current;
357 const location = normalizeLocation(
358 news.location ?? output?.location,
359 env.region,
360 );
361 const parent = locationParent(env.project, location);
362 const templateId = yield* toResourceId(
363 id,
364 news.templateId,
365 output?.templateId,
366 );
367 const name =
368 output?.name ?? resourceName(env.project, location, templateId);
369 const ownership = yield* createInternalLabels(id);
370 const description = encodeOwnership(
371 ownership,
372 news.description,
373 MAX_TEMPLATE_DESCRIPTION_LENGTH,
374 );
375 const displayName = news.displayName ?? templateId;
376 const templateBundle = news.templateBundle ?? defaultTemplateBundle();
377 const tags = news.tags ?? DEFAULT_TAGS;
378 const categories = news.categories ?? DEFAULT_CATEGORIES;
379 const visibility = news.visibility ?? DEFAULT_VISIBILITY;
380 const components = news.components;
381 const sharedWith = news.sharedWith;
382 const body: integrations.GoogleCloudIntegrationsV1alphaTemplate = {
383 displayName,
384 description,
385 usageInfo: news.usageInfo,
386 docLink: news.docLink,
387 templateBundle,
388 components,
389 tags,
390 categories,
391 author: news.author,
392 visibility,
393 sharedWith,
394 };
395
396 let current = yield* getByName(output?.name ?? name);
397 if (current === undefined) {
398 current = yield* findTemplateByDescription(parent, description);
399 }
400
401 if (current === undefined) {
402 const created = yield* integrations
403 .createProjectsLocationsTemplates({
404 parent,
405 body,
406 })
407 .pipe(
408 Effect.catchTag("Conflict", () =>
409 findTemplateByDescription(parent, description),
410 ),
411 );
412 current = created ?? undefined;
413 }
414
415 if (current === undefined) {
416 return yield* new TemplateNotResolved({ name });
417 }
418
419 const currentName = withProjectId(current.name ?? name, env.project);
420 const displayChanged = !sameText(current.displayName, displayName);
421 const descriptionChanged = (current.description ?? "") !== description;
422 const usageChanged = !sameText(current.usageInfo, news.usageInfo);
423 const docChanged = !sameText(current.docLink, news.docLink);
424 const bundleChanged =
425 fingerprint(current.templateBundle) !== fingerprint(templateBundle);
426 const componentsChanged =
427 fingerprint(current.components) !== fingerprint(components);
428 const tagsChanged = fingerprint(current.tags) !== fingerprint(tags);
429 const categoriesChanged =
430 fingerprint(current.categories) !== fingerprint(categories);
431 const authorChanged = !sameText(current.author, news.author);
432 const visibilityChanged = !sameText(current.visibility, visibility);
433 const sharedChanged =
434 fingerprint(current.sharedWith) !== fingerprint(sharedWith);
435
436 if (
437 displayChanged ||
438 descriptionChanged ||
439 usageChanged ||
440 docChanged ||
441 bundleChanged ||
442 componentsChanged ||
443 tagsChanged ||
444 categoriesChanged ||
445 authorChanged ||
446 visibilityChanged ||
447 sharedChanged
448 ) {
449 current = yield* integrations.patchProjectsLocationsTemplates({
450 name: currentName,
451 updateMask: updateMaskOf(
452 displayChanged ? "displayName" : undefined,
453 descriptionChanged ? "description" : undefined,
454 usageChanged ? "usageInfo" : undefined,
455 docChanged ? "docLink" : undefined,
456 bundleChanged ? "templateBundle" : undefined,
457 componentsChanged ? "components" : undefined,
458 tagsChanged ? "tags" : undefined,
459 categoriesChanged ? "categories" : undefined,
460 authorChanged ? "author" : undefined,
461 visibilityChanged ? "visibility" : undefined,
462 sharedChanged ? "sharedWith" : undefined,
463 ),
464 body: { ...body, name: currentName },
465 });
466 }
467
468 return toAttrs(current, env.project, env.region);
469 }),
470
471 delete: Effect.fn(function* ({ output }) {
472 yield* integrations
473 .deleteProjectsLocationsTemplates({ name: output.name })
474 .pipe(Effect.catchTag("NotFound", () => Effect.void));
475 }),
476 });