1import * as re2 from "@distilled.cloud/aws/resource-explorer-2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface ViewProps {
15 /**
16 * Name of the view. Can include letters, digits, and the dash character
17 * (up to 64 characters), and must be unique within the region.
18 * @default ${app}-${stage}-${id}
19 */
20 viewName?: string;
21
22 /**
23 * A Resource Explorer search filter that scopes every query made through
24 * this view, e.g. `"service:s3"` or `"tag:stage=prod"`. Results of a
25 * search are the intersection of the query and this filter.
26 * Omit to leave the view unfiltered.
27 */
28 filterString?: string;
29
30 /**
31 * Additional resource properties to include in search results returned
32 * by this view. `"tags"` is the only supported property today.
33 */
34 includedProperties?: string[];
35
36 /**
37 * The root ARN the view is scoped to. Defaults to the account
38 * (`arn:aws:iam::<account>:root`). Changing the scope requires
39 * replacement — the API offers no scope update.
40 */
41 scope?: string;
42
43 /**
44 * Tags to apply to the view. Merged with internal Alchemy tags.
45 */
46 tags?: Record<string, string>;
47}
48
49/** @resource */
50export interface View extends Resource<
51 "AWS.ResourceExplorer.View",
52 ViewProps,
53 {
54 /** ARN of the view, e.g. `arn:aws:resource-explorer-2:us-west-2:123456789012:view/my-view/uuid`. */
55 viewArn: string;
56 /** Name of the view. */
57 viewName: string;
58 /** The root ARN the view is scoped to. */
59 scope: string | undefined;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * An AWS Resource Explorer view — a saved search lens with an optional
67 * filter and a set of included properties that `Search` queries run
68 * through.
69 *
70 * Views require an active `AWS.ResourceExplorer.Index` in the region.
71 * When an index and a view deploy together, the view provider retries
72 * through the window where the index is still provisioning.
73 *
74 * ### Creating Views
75 * **Example:** Unfiltered view over the whole account
76 * ```typescript
77 * const index = yield* AWS.ResourceExplorer.Index("Index", {});
78 * const view = yield* AWS.ResourceExplorer.View("AllResources", {});
79 * ```
80 *
81 * **Example:** Filtered view with tags included in results
82 * ```typescript
83 * const view = yield* AWS.ResourceExplorer.View("S3Only", {
84 * filterString: "service:s3",
85 * includedProperties: ["tags"],
86 * });
87 * ```
88 *
89 * ### Searching
90 * **Example:** Search from a Lambda function
91 * ```typescript
92 * // init
93 * const search = yield* AWS.ResourceExplorer.Search(view);
94 *
95 * return {
96 * fetch: Effect.gen(function* () {
97 * // runtime
98 * const results = yield* search({ QueryString: "service:s3" });
99 * return HttpServerResponse.json({ count: results.Count });
100 * }),
101 * };
102 * ```
103 */
104const ViewResource = Resource<View>("AWS.ResourceExplorer.View");
105
106export { ViewResource as View };
107
108/**
109 * Raised when a view can neither be created (a peer holds the name) nor
110 * observed afterwards — i.e. reconciliation raced a concurrent delete.
111 */
112export class ViewUnobservable extends Data.TaggedError(
113 "ResourceExplorerViewUnobservable",
114)<{ message: string }> {}
115
116/**
117 * Resource Explorer answers `GetView`/`DeleteView` for a nonexistent or
118 * deleted view with the typed `UnauthorizedException` (HTTP 401, empty
119 * message) rather than `ResourceNotFoundException` — both mean "the view
120 * is not there for you".
121 */
122const getViewSafe = Effect.fn(function* (viewArn: string) {
123 return yield* re2.getView({ ViewArn: viewArn }).pipe(
124 Effect.map((r): re2.GetViewOutput | undefined => r),
125 Effect.catchTag(
126 ["UnauthorizedException", "ResourceNotFoundException"],
127 () => Effect.succeed(undefined),
128 ),
129 );
130});
131
132/**
133 * Find a view's ARN by its name. View ARNs embed the name as the second
134 * `/`-segment: `arn:...:view/{name}/{uuid}`.
135 */
136const findViewArnByName = Effect.fn(function* (name: string) {
137 const arns = yield* re2.listViews.pages({}).pipe(
138 Stream.runCollect,
139 Effect.map((chunk) =>
140 Array.from(chunk).flatMap((page) => page.Views ?? []),
141 ),
142 );
143 return arns.find((arn) => arn.split("/")[1] === name);
144});
145
146/**
147 * Creating a view while the region's index is still provisioning (or
148 * missing) is rejected with the typed `UnauthorizedException`. When an
149 * `Index` and a `View` deploy concurrently this is a transient ordering
150 * race — retry through it on a bounded schedule (an index becomes ACTIVE
151 * in ~15s). Explicitly-typed helper: an inline `Effect.retry` widens the
152 * provider layer's declaration-emitted R to `unknown`.
153 */
154const retryWhileIndexProvisioning = <A, E extends { _tag: string }, R>(
155 self: Effect.Effect<A, E, R>,
156): Effect.Effect<A, E, R> =>
157 Effect.retry(self, {
158 while: (e) => e._tag === "UnauthorizedException",
159 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
160 });
161
162const toTagRecord = (
163 tags: { [key: string]: string | undefined } | undefined,
164): Record<string, string> => {
165 const out: Record<string, string> = {};
166 for (const [key, value] of Object.entries(tags ?? {})) {
167 if (value !== undefined) out[key] = value;
168 }
169 return out;
170};
171
172const includedPropertyNames = (
173 properties: re2.IncludedProperty[] | undefined,
174): string[] => (properties ?? []).map((p) => p.Name);
175
176const sameStringSet = (a: string[], b: string[]) =>
177 a.length === b.length && [...a].sort().join(",") === [...b].sort().join(",");
178
179export const ViewProvider = () =>
180 Provider.effect(
181 ViewResource,
182 Effect.gen(function* () {
183 const createViewName = Effect.fn(function* (
184 id: string,
185 props: { viewName?: string | undefined },
186 ) {
187 return (
188 props.viewName ?? (yield* createPhysicalName({ id, maxLength: 64 }))
189 );
190 });
191
192 const toAttrs = (view: re2.View, name: string): View["Attributes"] => ({
193 viewArn: view.ViewArn!,
194 viewName: name,
195 scope: view.Scope,
196 });
197
198 return ViewResource.Provider.of({
199 stables: ["viewArn", "viewName"],
200
201 list: () =>
202 Effect.gen(function* () {
203 const arns = yield* re2.listViews.pages({}).pipe(
204 Stream.runCollect,
205 Effect.map((chunk) =>
206 Array.from(chunk).flatMap((page) => page.Views ?? []),
207 ),
208 );
209 // A view can vanish between enumeration and hydration — drop it.
210 const items = yield* Effect.forEach(
211 arns,
212 (arn) =>
213 getViewSafe(arn).pipe(
214 Effect.map((found) =>
215 found?.View?.ViewArn
216 ? toAttrs(
217 found.View,
218 found.View.ViewName ?? arn.split("/")[1]!,
219 )
220 : undefined,
221 ),
222 ),
223 { concurrency: 10 },
224 );
225 return items.filter(
226 (item): item is View["Attributes"] => item !== undefined,
227 );
228 }),
229
230 read: Effect.fn(function* ({ id, olds, output }) {
231 // Prefer the cached ARN; fall back to a by-name scan (state may
232 // have been lost after a persistence failure).
233 let arn = output?.viewArn;
234 if (arn === undefined) {
235 const name = yield* createViewName(id, olds ?? {});
236 arn = yield* findViewArnByName(name);
237 }
238 if (arn === undefined) return undefined;
239 const found = yield* getViewSafe(arn);
240 if (!found?.View?.ViewArn) return undefined;
241 const attrs = toAttrs(
242 found.View,
243 found.View.ViewName ?? arn.split("/")[1]!,
244 );
245 const tags = toTagRecord(found.Tags);
246 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
247 }),
248
249 diff: Effect.fn(function* ({ id, news, olds }) {
250 if (!isResolved(news)) return undefined;
251 const oldName = yield* createViewName(id, olds ?? {});
252 const newName = yield* createViewName(id, news);
253 if (oldName !== newName) {
254 return { action: "replace" } as const;
255 }
256 // Scope has no update API — a change requires replacement.
257 if ((olds ?? {}).scope !== news.scope) {
258 return { action: "replace" } as const;
259 }
260 // Fall through: default update path handles filters/properties/tags.
261 }),
262
263 reconcile: Effect.fn(function* ({ id, news, output, session }) {
264 const name = output?.viewName ?? (yield* createViewName(id, news));
265 const internalTags = yield* createInternalTags(id);
266 const desiredTags = { ...news.tags, ...internalTags };
267 const desiredProperties = news.includedProperties ?? [];
268 const desiredFilter = news.filterString;
269
270 // 1. OBSERVE — by cached ARN first, then by name (covers
271 // adoption and lost state).
272 let found =
273 output?.viewArn !== undefined
274 ? yield* getViewSafe(output.viewArn)
275 : undefined;
276 if (!found?.View) {
277 const arn = yield* findViewArnByName(name);
278 found = arn !== undefined ? yield* getViewSafe(arn) : undefined;
279 }
280
281 // 2. ENSURE — create when missing. Retries through the window
282 // where the region's index is still provisioning, and
283 // tolerates a duplicate-name Conflict as a race (observe the
284 // winner and converge below).
285 if (!found?.View) {
286 found = yield* retryWhileIndexProvisioning(
287 re2.createView({
288 ViewName: name,
289 Filters:
290 desiredFilter !== undefined
291 ? { FilterString: desiredFilter }
292 : undefined,
293 IncludedProperties: desiredProperties.map((Name) => ({
294 Name,
295 })),
296 Scope: news.scope,
297 Tags: desiredTags,
298 }),
299 ).pipe(
300 Effect.map(
301 (r): re2.GetViewOutput | undefined =>
302 r.View && { View: r.View, Tags: desiredTags },
303 ),
304 Effect.catchTag("ConflictException", () =>
305 Effect.gen(function* () {
306 const arn = yield* findViewArnByName(name);
307 return arn !== undefined
308 ? yield* getViewSafe(arn)
309 : undefined;
310 }),
311 ),
312 );
313 }
314 const view = found?.View;
315 if (found === undefined || view?.ViewArn === undefined) {
316 // Unreachable in practice: create either succeeded or the
317 // conflicting peer exists; observation just raced a delete.
318 return yield* Effect.fail(
319 new ViewUnobservable({
320 message: `ResourceExplorer view ${name} could not be created or observed`,
321 }),
322 );
323 }
324
325 // 3. SYNC — UpdateView REPLACES both Filters and
326 // IncludedProperties (omitted fields are cleared), so always
327 // send the full desired shape when either differs.
328 const observedFilter = view.Filters?.FilterString;
329 const observedProperties = includedPropertyNames(
330 view.IncludedProperties,
331 );
332 if (
333 (observedFilter ?? undefined) !== desiredFilter ||
334 !sameStringSet(observedProperties, desiredProperties)
335 ) {
336 yield* re2.updateView({
337 ViewArn: view.ViewArn,
338 Filters:
339 desiredFilter !== undefined
340 ? { FilterString: desiredFilter }
341 : undefined,
342 IncludedProperties: desiredProperties.map((Name) => ({ Name })),
343 });
344 }
345
346 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
347 const observedTags = toTagRecord(found.Tags);
348 const { upsert, removed } = diffTags(observedTags, desiredTags);
349 if (upsert.length > 0) {
350 yield* re2.tagResource({
351 resourceArn: view.ViewArn,
352 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
353 });
354 }
355 if (removed.length > 0) {
356 yield* re2.untagResource({
357 resourceArn: view.ViewArn,
358 tagKeys: removed,
359 });
360 }
361
362 yield* session.note(view.ViewArn);
363 return toAttrs(view, view.ViewName ?? name);
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 // Idempotent: a missing/deleted view answers with the typed
368 // UnauthorizedException (Resource Explorer's "not found" for
369 // views) or ResourceNotFoundException.
370 yield* re2.deleteView({ ViewArn: output.viewArn }).pipe(
371 Effect.catchTag(
372 ["UnauthorizedException", "ResourceNotFoundException"],
373 () => Effect.succeed(undefined),
374 ),
375 Effect.asVoid,
376 );
377 }),
378 });
379 }),
380 );