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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeDescription,
13 hasOwnershipMarker,
14 parseDescription,
15} from "./ownership.ts";
16import {
17 lastSegment,
18 orgParent,
19 resolveOrgId,
20 sameJson,
21 toPhysicalId,
22} from "./operations.ts";
23
24const MAX_NAME_LENGTH = 255;
25
26export type DatastoreConfig = {
27 /**
28 * Google Cloud project that owns the destination bucket or dataset.
29 */
30 projectId?: string;
31 /**
32 * Cloud Storage bucket name. Required when `targetType` is `gcs`.
33 */
34 bucketName?: string;
35 /**
36 * Path inside the Cloud Storage bucket. Required when `targetType` is
37 * `gcs`.
38 */
39 path?: string;
40 /**
41 * BigQuery dataset name. Required when `targetType` is `bigquery`.
42 */
43 datasetName?: string;
44 /**
45 * BigQuery table prefix. Required when `targetType` is `bigquery`.
46 */
47 tablePrefix?: string;
48};
49
50export type AnalyticsDatastoreProps = {
51 /**
52 * Apigee organization id. Defaults to the GCP project id.
53 * Immutable — changing it replaces the datastore.
54 */
55 organizationId?: string;
56 /**
57 * Display name shown in the UI. If omitted, a unique name is generated.
58 * Alchemy ownership is stored in a `[alchemy …]` prefix.
59 */
60 displayName?: string;
61 /**
62 * Destination storage type: `gcs` or `bigquery`.
63 * @default "gcs"
64 */
65 targetType?: string;
66 /**
67 * Destination configuration.
68 */
69 datastoreConfig?: DatastoreConfig;
70};
71
72export type AnalyticsDatastore = Resource<
73 "GCP.Apigee.AnalyticsDatastore",
74 AnalyticsDatastoreProps,
75 {
76 /** Resource name `organizations/{org}/analytics/datastores/{id}`. */
77 name: string;
78 /** Server-assigned datastore id. */
79 datastoreId: string;
80 /** Organization id. */
81 organizationId: string;
82 /** Project id. */
83 project: string;
84 /** User display name with the Alchemy ownership prefix stripped. */
85 displayName: string | undefined;
86 /** Destination type (`gcs` or `bigquery`). */
87 targetType: string | undefined;
88 /** Destination configuration. */
89 datastoreConfig: DatastoreConfig | undefined;
90 /** Create time in milliseconds since epoch. */
91 createTime: string | undefined;
92 /** Last update time in milliseconds since epoch. */
93 lastUpdateTime: string | undefined;
94 },
95 never,
96 Providers
97>;
98
99/**
100 * An Apigee analytics datastore — an export target (Cloud Storage or
101 * BigQuery) for organization analytics.
102 *
103 * Datastores have no labels field. Alchemy stamps ownership into
104 * `displayName` so `list` / nuke can find them. The datastore id is
105 * server-assigned.
106 *
107 * ### Creating a Datastore
108 * **Example:** GCS export target
109 * ```typescript
110 * const store = yield* GCP.Apigee.AnalyticsDatastore("Exports", {
111 * targetType: "gcs",
112 * datastoreConfig: {
113 * projectId: "my-project",
114 * bucketName: "apigee-analytics",
115 * path: "exports",
116 * },
117 * });
118 * ```
119 *
120 * **Example:** BigQuery export target
121 * ```typescript
122 * const store = yield* GCP.Apigee.AnalyticsDatastore("BqExports", {
123 * targetType: "bigquery",
124 * datastoreConfig: {
125 * projectId: "my-project",
126 * datasetName: "apigee",
127 * tablePrefix: "analytics",
128 * },
129 * });
130 * ```
131 *
132 * @resource
133 * @category Apigee
134 */
135export const AnalyticsDatastore = Resource<AnalyticsDatastore>(
136 "GCP.Apigee.AnalyticsDatastore",
137);
138
139export class AnalyticsDatastoreNotResolved extends Data.TaggedError(
140 "GCP.Apigee.AnalyticsDatastoreNotResolved",
141)<{
142 name: string;
143}> {}
144
145const resourceName = (organizationId: string, datastoreId: string) =>
146 `${orgParent(organizationId)}/analytics/datastores/${datastoreId}`;
147
148const normalizeName = (value: string | undefined, organizationId: string) => {
149 if (value === undefined || value.length === 0) return undefined;
150 const trimmed = value.replace(/^\/+/, "");
151 return trimmed.includes("/")
152 ? trimmed
153 : resourceName(organizationId, trimmed);
154};
155
156const toAttrs = (
157 datastore: apigee.GoogleCloudApigeeV1Datastore,
158 project: string,
159 organizationId: string,
160) => {
161 const name =
162 normalizeName(datastore.self, organizationId) ??
163 resourceName(organizationId, lastSegment(datastore.self ?? ""));
164 const parsed = parseDescription(datastore.displayName);
165 const config = datastore.datastoreConfig;
166 return {
167 name,
168 datastoreId: lastSegment(name),
169 organizationId: datastore.org ?? organizationId,
170 project,
171 displayName: parsed.description,
172 targetType: datastore.targetType,
173 datastoreConfig: config
174 ? {
175 projectId: config.projectId,
176 bucketName: config.bucketName,
177 path: config.path,
178 datasetName: config.datasetName,
179 tablePrefix: config.tablePrefix,
180 }
181 : undefined,
182 createTime: datastore.createTime,
183 lastUpdateTime: datastore.lastUpdateTime,
184 };
185};
186
187const getByName = (name: string) =>
188 apigee
189 .getOrganizationsAnalyticsDatastores({ name })
190 .pipe(
191 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
192 Effect.succeed(undefined),
193 ),
194 );
195
196export const AnalyticsDatastoreProvider = () =>
197 Provider.succeed(AnalyticsDatastore, {
198 stables: ["name", "datastoreId", "organizationId", "project", "createTime"],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const previousOrg = olds?.organizationId ?? output?.organizationId;
203 const nextOrg = news.organizationId ?? previousOrg;
204 if (previousOrg !== undefined && nextOrg !== previousOrg) {
205 return { action: "replace" as const, deleteFirst: false };
206 }
207 return undefined;
208 }),
209
210 read: Effect.fn(function* ({ id, olds, output }) {
211 const env = yield* GcpEnvironment.current;
212 const organizationId =
213 olds?.organizationId ??
214 output?.organizationId ??
215 (yield* resolveOrgId(env.project));
216 const name = output?.name;
217 if (name === undefined) return undefined;
218 const existing = yield* getByName(name);
219 if (existing === undefined) return undefined;
220 const attrs = toAttrs(existing, env.project, organizationId);
221 const { labels } = parseDescription(existing.displayName);
222 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
223 }),
224
225 list: () =>
226 Effect.gen(function* () {
227 const env = yield* GcpEnvironment.current;
228 const organizationId = yield* resolveOrgId(env.project);
229 const page = yield* apigee
230 .listOrganizationsAnalyticsDatastores({
231 parent: orgParent(organizationId),
232 })
233 .pipe(
234 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
235 Effect.succeed({ datastores: [] }),
236 ),
237 );
238 return (page.datastores ?? [])
239 .filter((datastore) => hasOwnershipMarker(datastore.displayName))
240 .map((datastore) => toAttrs(datastore, env.project, organizationId));
241 }),
242
243 reconcile: Effect.fn(function* ({ id, news, output }) {
244 const env = yield* GcpEnvironment.current;
245 const organizationId =
246 news.organizationId ??
247 output?.organizationId ??
248 (yield* resolveOrgId(env.project));
249 const ownership = yield* createInternalLabels(id);
250 const generated = yield* toPhysicalId(
251 id,
252 news.displayName,
253 parseDescription(output?.displayName).description,
254 MAX_NAME_LENGTH,
255 );
256 const desiredDisplayName = encodeDescription(ownership, generated, 255);
257 const targetType = news.targetType ?? "gcs";
258 const datastoreConfig: apigee.GoogleCloudApigeeV1DatastoreConfig = {
259 projectId: news.datastoreConfig?.projectId ?? env.project,
260 bucketName: news.datastoreConfig?.bucketName,
261 path: news.datastoreConfig?.path,
262 datasetName: news.datastoreConfig?.datasetName,
263 tablePrefix: news.datastoreConfig?.tablePrefix,
264 };
265
266 let current =
267 output?.name !== undefined ? yield* getByName(output.name) : undefined;
268
269 if (current === undefined) {
270 const created = yield* apigee
271 .createOrganizationsAnalyticsDatastores({
272 parent: orgParent(organizationId),
273 body: {
274 displayName: desiredDisplayName,
275 targetType,
276 datastoreConfig,
277 },
278 })
279 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
280 current = created ?? undefined;
281 }
282
283 if (current === undefined) {
284 return yield* new AnalyticsDatastoreNotResolved({
285 name: output?.name ?? orgParent(organizationId),
286 });
287 }
288
289 const name =
290 normalizeName(current.self, organizationId) ??
291 output?.name ??
292 resourceName(organizationId, lastSegment(current.self ?? ""));
293
294 const needsUpdate =
295 (current.displayName ?? "") !== desiredDisplayName ||
296 (current.targetType ?? "") !== targetType ||
297 !sameJson(current.datastoreConfig ?? {}, datastoreConfig);
298
299 if (needsUpdate) {
300 current = yield* apigee.updateOrganizationsAnalyticsDatastores({
301 name,
302 body: {
303 displayName: desiredDisplayName,
304 targetType,
305 datastoreConfig,
306 },
307 });
308 }
309
310 return toAttrs(current, env.project, organizationId);
311 }),
312
313 delete: Effect.fn(function* ({ output }) {
314 yield* apigee
315 .deleteOrganizationsAnalyticsDatastores({ name: output.name })
316 .pipe(
317 Effect.catchTag(
318 ["NotFound", "ApigeeResourceNotFound"],
319 () => Effect.void,
320 ),
321 );
322 }),
323 });