1import * as apigee from "@distilled.cloud/gcp/apigee_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 type { Providers } from "../Providers.ts";
10import {
11 collectPages,
12 createOwnership,
13 dcCollectorId,
14 defaultOrgName,
15 encodeDescription,
16 hasOwnershipMarker,
17 lastSegment,
18 listOrgNames,
19 orgIdOf,
20 orgNameOf,
21 ownedBy,
22 parseDescription,
23} from "./operations.ts";
24
25export type DataCollectorType =
26 | apigee.GoogleCloudApigeeV1DataCollectorTypeEnum
27 | (string & {});
28
29export type DatacollectorProps = {
30 /**
31 * Apigee organization id or `organizations/{org}`. Defaults to the
32 * current GCP project id. Immutable — changing it replaces the collector.
33 */
34 organization?: string;
35 /**
36 * Data collector id (the `{data_collector}` segment of
37 * `organizations/{org}/datacollectors/{data_collector}`). Must begin with
38 * `dc_` and contain only letters, numbers, and underscores. If omitted,
39 * a unique `dc_` name is generated. Immutable — changing it replaces the
40 * collector.
41 */
42 dataCollectorId?: string;
43 /**
44 * Human-readable description. Alchemy ownership is stored in a
45 * `[alchemy …]` prefix and stripped from attributes.
46 */
47 description?: string;
48 /**
49 * Type of data this collector records. Immutable — changing it replaces
50 * the collector.
51 * @default "STRING"
52 */
53 type?: DataCollectorType;
54};
55
56export type Datacollector = Resource<
57 "GCP.Apigee.Datacollector",
58 DatacollectorProps,
59 {
60 /** Full resource name `organizations/{org}/datacollectors/{id}`. */
61 name: string;
62 /** Data collector id (last path segment). */
63 dataCollectorId: string;
64 /** Apigee organization id. */
65 organization: string;
66 /** User description with the Alchemy ownership prefix stripped. */
67 description: string | undefined;
68 /** Collector value type. */
69 type: string | undefined;
70 /** Creation time in milliseconds since epoch. */
71 createdAt: string | undefined;
72 /** Last modification time in milliseconds since epoch. */
73 lastModifiedAt: string | undefined;
74 },
75 never,
76 Providers
77>;
78
79/**
80 * An Apigee data collector used to capture custom analytics dimensions.
81 *
82 * Apigee collectors have no labels field, so Alchemy stamps ownership into
83 * the description for `list` / nuke. Name and type are identity — changing
84 * `dataCollectorId` or `type` replaces the collector. Description updates
85 * in place.
86 *
87 * ### Creating a Data Collector
88 * **Example:** Generated `dc_` name
89 * ```typescript
90 * const collector = yield* GCP.Apigee.Datacollector("Latency", {
91 * type: "INTEGER",
92 * });
93 * ```
94 *
95 * **Example:** Explicit id and description
96 * ```typescript
97 * const collector = yield* GCP.Apigee.Datacollector("Latency", {
98 * dataCollectorId: "dc_proxy_latency",
99 * type: "INTEGER",
100 * description: "proxy latency in milliseconds",
101 * });
102 * ```
103 *
104 * @resource
105 * @category Apigee
106 */
107export const Datacollector = Resource<Datacollector>(
108 "GCP.Apigee.Datacollector",
109);
110
111export class DatacollectorNotResolved extends Data.TaggedError(
112 "GCP.Apigee.DatacollectorNotResolved",
113)<{
114 name: string;
115}> {}
116
117const DEFAULT_TYPE = "STRING";
118
119const resourceName = (organization: string, dataCollectorId: string) =>
120 `${orgNameOf(organization)}/datacollectors/${dataCollectorId}`;
121
122const toAttrs = (
123 collector: apigee.GoogleCloudApigeeV1DataCollector,
124 organization: string,
125) => {
126 const name = collector.name ?? "";
127 const parsed = parseDescription(collector.description);
128 return {
129 name,
130 dataCollectorId: lastSegment(name),
131 organization: orgIdOf(organization),
132 description: parsed.description,
133 type: collector.type,
134 createdAt: collector.createdAt,
135 lastModifiedAt: collector.lastModifiedAt,
136 };
137};
138
139const getByName = (name: string) =>
140 apigee
141 .getOrganizationsDatacollectors({ name })
142 .pipe(
143 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
144 Effect.succeed(undefined),
145 ),
146 );
147
148export const DatacollectorProvider = () =>
149 Provider.succeed(Datacollector, {
150 stables: ["name", "dataCollectorId", "organization", "type", "createdAt"],
151
152 diff: Effect.fn(function* ({ news, olds, output }) {
153 if (!isResolved(news)) return undefined;
154 const previousId = olds?.dataCollectorId ?? output?.dataCollectorId;
155 const previousOrg = olds?.organization ?? output?.organization;
156 const previousType = olds?.type ?? output?.type ?? DEFAULT_TYPE;
157 const nextType = news.type ?? DEFAULT_TYPE;
158 const idChanged =
159 previousId !== undefined &&
160 news.dataCollectorId !== undefined &&
161 news.dataCollectorId !== previousId;
162 const orgChanged =
163 previousOrg !== undefined &&
164 news.organization !== undefined &&
165 orgIdOf(news.organization) !== orgIdOf(previousOrg);
166 const typeChanged = previousType !== nextType;
167 if (idChanged || orgChanged || typeChanged) {
168 const sameName =
169 !idChanged &&
170 !orgChanged &&
171 (news.dataCollectorId ?? previousId) === previousId;
172 return { action: "replace" as const, deleteFirst: sameName };
173 }
174 return undefined;
175 }),
176
177 read: Effect.fn(function* ({ id, olds, output }) {
178 const env = yield* GcpEnvironment.current;
179 const organization = defaultOrgName(
180 env.project,
181 olds?.organization ?? output?.organization,
182 );
183 const dataCollectorId = yield* dcCollectorId(
184 id,
185 olds?.dataCollectorId,
186 output?.dataCollectorId,
187 );
188 const name = output?.name ?? resourceName(organization, dataCollectorId);
189 const existing = yield* getByName(name);
190 if (existing === undefined) return undefined;
191 const attrs = toAttrs(existing, organization);
192 const { labels } = parseDescription(existing.description);
193 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
194 }),
195
196 list: () =>
197 Effect.gen(function* () {
198 const orgs = yield* listOrgNames();
199 const rows: Datacollector["Attributes"][] = [];
200 for (const organization of orgs) {
201 const collectors = yield* collectPages(
202 apigee.listOrganizationsDatacollectors.pages({
203 parent: organization,
204 pageSize: 1000,
205 }),
206 (page) => page.dataCollectors,
207 ).pipe(
208 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
209 Effect.succeed([] as apigee.GoogleCloudApigeeV1DataCollector[]),
210 ),
211 );
212 for (const collector of collectors) {
213 if (hasOwnershipMarker(collector.description)) {
214 rows.push(toAttrs(collector, organization));
215 }
216 }
217 }
218 return rows;
219 }),
220
221 reconcile: Effect.fn(function* ({ id, news, output }) {
222 const env = yield* GcpEnvironment.current;
223 const organization = defaultOrgName(env.project, news.organization);
224 const dataCollectorId = yield* dcCollectorId(
225 id,
226 news.dataCollectorId,
227 output?.dataCollectorId,
228 );
229 const name = resourceName(organization, dataCollectorId);
230 const type = news.type ?? DEFAULT_TYPE;
231 const ownership = yield* createOwnership(id);
232 const desiredDescription = encodeDescription(ownership, news.description);
233
234 let current = yield* getByName(output?.name ?? name);
235
236 if (current === undefined) {
237 const created = yield* apigee
238 .createOrganizationsDatacollectors({
239 parent: organization,
240 dataCollectorId,
241 body: {
242 name: dataCollectorId,
243 description: desiredDescription,
244 type,
245 },
246 })
247 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
248 current = created ?? undefined;
249 }
250
251 if (current === undefined) {
252 return yield* new DatacollectorNotResolved({ name });
253 }
254
255 if ((current.description ?? "") !== desiredDescription) {
256 current = yield* apigee.patchOrganizationsDatacollectors({
257 name,
258 updateMask: "description",
259 body: { description: desiredDescription },
260 });
261 }
262
263 return toAttrs(current, organization);
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 yield* apigee
268 .deleteOrganizationsDatacollectors({ name: output.name })
269 .pipe(
270 Effect.catchTag(
271 ["NotFound", "ApigeeResourceNotFound"],
272 () => Effect.void,
273 ),
274 );
275 }),
276 });