1import * as ces from "@distilled.cloud/gcp/ces_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 {
12 collectPages,
13 encodeOwnership,
14 expandApp,
15 forEachApp,
16 hasOwnershipMarker,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 retryTransient,
23 toPhysicalId,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type AppsVersionProps = {
28 /**
29 * Parent CES app. Full name
30 * `projects/{project}/locations/{location}/apps/{app}` or the app id
31 * (combined with `location`). Immutable — changing it replaces the
32 * version.
33 */
34 app: string;
35 /**
36 * Region used when `app` is a bare id.
37 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
38 */
39 location?: string;
40 /**
41 * App version id. If omitted, a unique name is generated. Immutable —
42 * changing it replaces the version.
43 */
44 appVersionId?: string;
45 /**
46 * Human-readable name. Alchemy falls back to the generated version
47 * id.
48 */
49 displayName?: string;
50 /**
51 * Snapshot description. Versions have no labels field and no update
52 * RPC, so Alchemy stamps ownership into this field at create time.
53 * Later description edits are ignored.
54 */
55 description?: string;
56};
57
58export type AppsVersion = Resource<
59 "GCP.CES.AppsVersion",
60 AppsVersionProps,
61 {
62 /** Full resource name `.../apps/{app}/versions/{version}`. */
63 name: string;
64 /** Version id (last path segment). */
65 appVersionId: string;
66 /** Parent app resource name. */
67 app: string;
68 /** Location id. */
69 location: string;
70 /** Project id. */
71 project: string;
72 /** Display name. */
73 displayName: string | undefined;
74 /** User description with the Alchemy ownership prefix stripped. */
75 description: string | undefined;
76 /** Email of the user who created the version. */
77 creator: string | undefined;
78 /** RFC3339 creation timestamp. */
79 createTime: string | undefined;
80 /** Server-assigned etag. */
81 etag: string | undefined;
82 },
83 never,
84 Providers
85>;
86
87/**
88 * A Customer Engagement Suite app version — an immutable snapshot of an
89 * app at a point in time.
90 *
91 * Versions have no labels field and no patch RPC. Alchemy stamps
92 * ownership into `description` so `list` / nuke can find them. Reconcile
93 * is observe-ensure: if the snapshot is missing it is created; later
94 * description edits are ignored.
95 *
96 * ### Creating an App Version
97 * **Example:** Snapshot
98 * ```typescript
99 * const version = yield* GCP.CES.AppsVersion("v1", {
100 * app: app.name,
101 * displayName: "v1",
102 * description: "initial",
103 * });
104 * ```
105 *
106 * @resource
107 * @category CES
108 */
109export const AppsVersion = Resource<AppsVersion>("GCP.CES.AppsVersion");
110
111export class AppsVersionNotResolved extends Data.TaggedError(
112 "GCP.CES.AppsVersionNotResolved",
113)<{
114 name: string;
115}> {}
116
117const resourceName = (app: string, appVersionId: string) =>
118 `${app}/versions/${appVersionId}`;
119
120const toAttrs = (
121 version: ces.AppVersion,
122 project: string,
123 region: string,
124 appHint?: string,
125) => {
126 const name = version.name ?? "";
127 const parsed = parseResourceName(name, "versions", region);
128 return {
129 name,
130 appVersionId: parsed.id,
131 app: name.includes("/versions/") ? parsed.app : (appHint ?? parsed.parent),
132 location: parsed.location,
133 project: parsed.project || project,
134 displayName: version.displayName,
135 description: parseOwnership(version.description).text,
136 creator: version.creator,
137 createTime: version.createTime,
138 etag: version.etag,
139 };
140};
141
142const getByName = (name: string) =>
143 name.length === 0
144 ? Effect.succeed(undefined)
145 : ces
146 .getProjectsLocationsAppsVersions({ name })
147 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
148
149const listAt = (parent: string, project: string, region: string) =>
150 collectPages(
151 ces.listProjectsLocationsAppsVersions.pages({ parent, pageSize: 100 }),
152 (page) => page.appVersions,
153 ).pipe(
154 Effect.map((versions) =>
155 versions
156 .filter((version) => hasOwnershipMarker(version.description))
157 .map((version) => toAttrs(version, project, region, parent)),
158 ),
159 );
160
161export const AppsVersionProvider = () =>
162 Provider.succeed(AppsVersion, {
163 stables: [
164 "name",
165 "appVersionId",
166 "app",
167 "location",
168 "project",
169 "createTime",
170 "creator",
171 ],
172
173 diff: Effect.fn(function* ({ news, olds, output }) {
174 if (!isResolved(news)) return undefined;
175 return replaceOnIdentity({
176 previousId: olds?.appVersionId ?? output?.appVersionId,
177 nextId: news.appVersionId,
178 previousParent: olds?.app ?? output?.app,
179 nextParent: news.app,
180 });
181 }),
182
183 read: Effect.fn(function* ({ id, olds, output }) {
184 const env = yield* GcpEnvironment.current;
185 const location = normalizeLocation(
186 olds?.location ?? output?.location,
187 env.region,
188 );
189 const app = olds?.app
190 ? expandApp(olds.app, env.project, location)
191 : output?.app;
192 const appVersionId = yield* toPhysicalId(
193 id,
194 olds?.appVersionId,
195 output?.appVersionId,
196 );
197 const name =
198 output?.name ??
199 (app !== undefined ? resourceName(app, appVersionId) : "");
200 const existing = yield* getByName(name);
201 if (existing === undefined) return undefined;
202 const attrs = toAttrs(existing, env.project, env.region, app);
203 return (yield* ownedByAlchemy(id, existing.description))
204 ? attrs
205 : Unowned(attrs);
206 }),
207
208 list: () =>
209 Effect.gen(function* () {
210 const env = yield* GcpEnvironment.current;
211 return yield* forEachApp(
212 env.project,
213 (parent) => listAt(parent, env.project, env.region),
214 env.region,
215 );
216 }),
217
218 reconcile: Effect.fn(function* ({ id, news, output }) {
219 const env = yield* GcpEnvironment.current;
220 const location = normalizeLocation(
221 news.location ?? output?.location,
222 env.region,
223 );
224 const app = expandApp(news.app, env.project, location);
225 const appVersionId = yield* toPhysicalId(
226 id,
227 news.appVersionId,
228 output?.appVersionId,
229 );
230 const name = output?.name ?? resourceName(app, appVersionId);
231 const ownership = yield* createInternalLabels(id);
232 const description = encodeOwnership(ownership, news.description);
233 const displayName = news.displayName ?? appVersionId;
234
235 let current = yield* getByName(name);
236
237 if (current === undefined) {
238 const created = yield* retryTransient(
239 ces.createProjectsLocationsAppsVersions({
240 parent: app,
241 appVersionId,
242 body: {
243 displayName,
244 description,
245 },
246 }),
247 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
248 current = created ?? undefined;
249 }
250
251 if (current === undefined) {
252 return yield* new AppsVersionNotResolved({ name });
253 }
254
255 return toAttrs(current, env.project, env.region, app);
256 }),
257
258 delete: Effect.fn(function* ({ output }) {
259 if (!output.name) return;
260 yield* retryTransient(
261 ces.deleteProjectsLocationsAppsVersions({ name: output.name }),
262 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
263 yield* waitUntilGone(getByName(output.name));
264 }),
265 });