1import * as logging from "@distilled.cloud/gcp/logging_v2";
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 type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_LOCATION,
13 createOwnership,
14 encodeDescription,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 locationParent,
19 ownedBy,
20 parseDescription,
21 parseLoggingName,
22 scopeParent,
23 toPhysicalId,
24} from "./internal.ts";
25
26export type FolderSavedQueryLoggingQuery = {
27 /**
28 * Advanced Logging Query Language filter. Max 20,000 characters.
29 */
30 filter?: string;
31};
32
33export type FolderSavedQueryOpsAnalyticsQuery = {
34 /**
35 * Log Analytics SQL query text.
36 */
37 sqlQueryText?: string;
38};
39
40export type FolderSavedQueryProps = {
41 /**
42 * Folder id (`folders/{folder}` or the numeric id). When omitted, the
43 * stack project is used. Immutable — changing it replaces the query.
44 */
45 folderId?: string;
46 /**
47 * Location of the saved query. Immutable — changing it replaces the
48 * query.
49 * @default "global"
50 */
51 location?: string;
52 /**
53 * Saved query id (the `{query}` segment of
54 * `{parent}/locations/{location}/savedQueries/{query}`). If omitted, a
55 * unique name is generated. Limited to 100 characters: letters, digits,
56 * underscores, hyphens, periods; first character must be alphanumeric.
57 * Immutable — changing it replaces the query.
58 */
59 savedQueryId?: string;
60 /**
61 * User-specified title shown in Logs Explorer.
62 */
63 displayName: string;
64 /**
65 * Visibility. `PRIVATE` queries are owned by the creating user.
66 * `SHARED` queries are owned by the parent resource.
67 * @default "PRIVATE"
68 */
69 visibility?: "PRIVATE" | "SHARED";
70 /**
71 * Human-readable description. Saved queries have no labels field, so
72 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
73 * from attributes.
74 */
75 description?: string;
76 /**
77 * Logs Explorer query. At least one of `loggingQuery` or
78 * `opsAnalyticsQuery` is required.
79 */
80 loggingQuery?: FolderSavedQueryLoggingQuery;
81 /**
82 * Log Analytics SQL query. At least one of `loggingQuery` or
83 * `opsAnalyticsQuery` is required.
84 */
85 opsAnalyticsQuery?: FolderSavedQueryOpsAnalyticsQuery;
86};
87
88export type FolderSavedQuery = Resource<
89 "GCP.Logging.FolderSavedQuery",
90 FolderSavedQueryProps,
91 {
92 /** Full resource name `{parent}/locations/{location}/savedQueries/{savedQueryId}`. */
93 name: string;
94 /** Saved query id (last path segment). */
95 savedQueryId: string;
96 /** Parent resource (`folders/{folder}` or `projects/{project}`). */
97 parent: string;
98 /** Folder id when the parent is a folder. */
99 folderId: string | undefined;
100 /** Location of the saved query. */
101 location: string;
102 /** Display title. */
103 displayName: string;
104 /** Visibility (`PRIVATE` or `SHARED`). */
105 visibility: string;
106 /** User description with the Alchemy ownership prefix stripped. */
107 description: string | undefined;
108 /** Logs Explorer query, if set. */
109 loggingQuery: FolderSavedQueryLoggingQuery | undefined;
110 /** Log Analytics query, if set. */
111 opsAnalyticsQuery: FolderSavedQueryOpsAnalyticsQuery | undefined;
112 /** RFC3339 creation timestamp. */
113 createTime: string | undefined;
114 /** RFC3339 last-update timestamp. */
115 updateTime: string | undefined;
116 },
117 never,
118 Providers
119>;
120
121/**
122 * A Cloud Logging saved query owned by a folder (or the stack project).
123 *
124 * Saved queries have no labels field, so Alchemy stamps ownership into
125 * the description for `list` / nuke. `folderId`, `savedQueryId`, and
126 * `location` are identity — changing any of them replaces the query.
127 * Display name, visibility, description, and query text update in place.
128 *
129 * ### Creating a Folder Saved Query
130 * **Example:** Shared error query
131 * ```typescript
132 * const query = yield* GCP.Logging.FolderSavedQuery("Errors", {
133 * displayName: "application errors",
134 * visibility: "SHARED",
135 * loggingQuery: { filter: "severity>=ERROR" },
136 * description: "error log entries",
137 * });
138 * ```
139 *
140 * ### Updating a Folder Saved Query
141 * **Example:** Change the filter
142 * ```typescript
143 * const query = yield* GCP.Logging.FolderSavedQuery("Errors", {
144 * displayName: "warnings and errors",
145 * visibility: "SHARED",
146 * loggingQuery: { filter: "severity>=WARNING" },
147 * description: "warning and error log entries",
148 * });
149 * ```
150 *
151 * @resource
152 * @category Logging
153 */
154export const FolderSavedQuery = Resource<FolderSavedQuery>(
155 "GCP.Logging.FolderSavedQuery",
156);
157
158export class FolderSavedQueryNotResolved extends Data.TaggedError(
159 "GCP.Logging.FolderSavedQueryNotResolved",
160)<{
161 name: string;
162}> {}
163
164const resourceName = (parent: string, location: string, savedQueryId: string) =>
165 `${locationParent(parent, location)}/savedQueries/${savedQueryId}`;
166
167const folderIdOf = (parent: string) =>
168 parent.startsWith("folders/") ? lastSegment(parent) : undefined;
169
170const toAttrs = (
171 query: logging.SavedQuery,
172 parent: string,
173 location: string,
174) => {
175 const parsed = parseLoggingName(query.name ?? "");
176 const savedQueryId = parsed.savedQueryId ?? lastSegment(query.name ?? "");
177 const resolvedParent = parsed.parent || parent;
178 const resolvedLocation = parsed.location ?? location;
179 const description = parseDescription(query.description);
180 return {
181 name:
182 query.name ??
183 (savedQueryId
184 ? resourceName(resolvedParent, resolvedLocation, savedQueryId)
185 : ""),
186 savedQueryId,
187 parent: resolvedParent,
188 folderId: folderIdOf(resolvedParent),
189 location: resolvedLocation,
190 displayName: query.displayName ?? "",
191 visibility: query.visibility ?? "PRIVATE",
192 description: description.description,
193 loggingQuery: query.loggingQuery
194 ? { filter: query.loggingQuery.filter }
195 : undefined,
196 opsAnalyticsQuery: query.opsAnalyticsQuery
197 ? { sqlQueryText: query.opsAnalyticsQuery.sqlQueryText }
198 : undefined,
199 createTime: query.createTime,
200 updateTime: query.updateTime,
201 };
202};
203
204const getByName = (name: string) =>
205 logging
206 .getFoldersLocationsSavedQueries({ name })
207 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
208
209const toBody = (
210 news: FolderSavedQueryProps,
211 description: string,
212): logging.SavedQuery => ({
213 displayName: news.displayName,
214 visibility: news.visibility ?? "PRIVATE",
215 description,
216 loggingQuery: news.loggingQuery,
217 opsAnalyticsQuery: news.opsAnalyticsQuery,
218});
219
220export const FolderSavedQueryProvider = () =>
221 Provider.succeed(FolderSavedQuery, {
222 stables: [
223 "name",
224 "savedQueryId",
225 "parent",
226 "folderId",
227 "location",
228 "createTime",
229 ],
230
231 diff: Effect.fn(function* ({ news, olds, output }) {
232 if (!isResolved(news)) return undefined;
233 const previousId = olds?.savedQueryId ?? output?.savedQueryId;
234 const idChanged =
235 previousId !== undefined &&
236 news.savedQueryId !== undefined &&
237 news.savedQueryId !== previousId;
238 const previousLocation = olds?.location ?? output?.location;
239 const locationChanged =
240 previousLocation !== undefined &&
241 news.location !== undefined &&
242 news.location !== previousLocation;
243 const previousFolder = olds?.folderId ?? output?.folderId;
244 const folderChanged =
245 news.folderId !== undefined && news.folderId !== previousFolder;
246 if (!idChanged && !locationChanged && !folderChanged) return undefined;
247 return { action: "replace" as const, deleteFirst: false };
248 }),
249
250 read: Effect.fn(function* ({ id, olds, output }) {
251 const env = yield* GcpEnvironment.current;
252 const parent = scopeParent(
253 env.project,
254 olds?.folderId ?? output?.folderId,
255 );
256 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
257 const savedQueryId = yield* toPhysicalId(
258 id,
259 olds?.savedQueryId,
260 output?.savedQueryId,
261 "q",
262 );
263 const name = output?.name ?? resourceName(parent, location, savedQueryId);
264 const existing = yield* getByName(name);
265 if (existing === undefined) return undefined;
266 const attrs = toAttrs(existing, parent, location);
267 const { labels } = parseDescription(existing.description);
268 return (yield* ownedBy(id, labels)) ? attrs : Unowned(attrs);
269 }),
270
271 list: () =>
272 Effect.gen(function* () {
273 const env = yield* GcpEnvironment.current;
274 return yield* logging.listFoldersLocationsSavedQueries
275 .pages({
276 parent: `projects/${env.project}/locations/-`,
277 pageSize: 1000,
278 })
279 .pipe(
280 Stream.flatMap((page) =>
281 Stream.fromIterable(page.savedQueries ?? []),
282 ),
283 Stream.filter((query) => hasOwnershipMarker(query.description)),
284 Stream.map((query) =>
285 toAttrs(
286 query,
287 parseLoggingName(query.name ?? "").parent ||
288 `projects/${env.project}`,
289 parseLoggingName(query.name ?? "").location ?? DEFAULT_LOCATION,
290 ),
291 ),
292 Stream.runCollect,
293 Effect.map((chunk) => Array.from(chunk)),
294 );
295 }),
296
297 reconcile: Effect.fn(function* ({ id, news, output }) {
298 const env = yield* GcpEnvironment.current;
299 const parent = scopeParent(
300 env.project,
301 news.folderId ?? output?.folderId,
302 );
303 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
304 const savedQueryId = yield* toPhysicalId(
305 id,
306 news.savedQueryId,
307 output?.savedQueryId,
308 "q",
309 );
310 const name = resourceName(parent, location, savedQueryId);
311 const ownership = yield* createOwnership(id);
312 const desiredDescription = encodeDescription(ownership, news.description);
313 const desiredVisibility = news.visibility ?? "PRIVATE";
314
315 let current = yield* getByName(output?.name ?? name);
316
317 if (current === undefined) {
318 const created = yield* logging
319 .createFoldersLocationsSavedQueries({
320 parent: locationParent(parent, location),
321 savedQueryId,
322 body: toBody(news, desiredDescription),
323 })
324 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
325 current = created ?? undefined;
326 }
327
328 if (current === undefined) {
329 return yield* new FolderSavedQueryNotResolved({ name });
330 }
331
332 const displayNameChanged =
333 (current.displayName ?? "") !== news.displayName;
334 const descriptionChanged =
335 (current.description ?? "") !== desiredDescription;
336 const visibilityChanged =
337 (current.visibility ?? "PRIVATE") !== desiredVisibility;
338 const loggingChanged = !jsonEqual(
339 current.loggingQuery ?? null,
340 news.loggingQuery ?? null,
341 );
342 const analyticsChanged = !jsonEqual(
343 current.opsAnalyticsQuery?.sqlQueryText ?? null,
344 news.opsAnalyticsQuery?.sqlQueryText ?? null,
345 );
346
347 const updateMask = [
348 displayNameChanged ? "displayName" : undefined,
349 descriptionChanged ? "description" : undefined,
350 visibilityChanged ? "visibility" : undefined,
351 loggingChanged ? "loggingQuery" : undefined,
352 analyticsChanged ? "opsAnalyticsQuery" : undefined,
353 ].filter((field): field is string => field !== undefined);
354
355 if (updateMask.length > 0) {
356 current = yield* logging.patchFoldersLocationsSavedQueries({
357 name: current.name ?? name,
358 updateMask: updateMask.join(","),
359 body: toBody(news, desiredDescription),
360 });
361 }
362
363 return toAttrs(current, parent, location);
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 yield* logging
368 .deleteFoldersLocationsSavedQueries({ name: output.name })
369 .pipe(Effect.catchTag("NotFound", () => Effect.void));
370 }),
371 });