1import * as re2 from "@distilled.cloud/aws/resource-explorer-2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
8import type { Providers } from "../Providers.ts";
9
10/**
11 * The type of a Resource Explorer index.
12 *
13 * - `LOCAL` — indexes resources in its own region only.
14 * - `AGGREGATOR` — additionally replicates resource information from every
15 * other region's local index, enabling account-wide search from this
16 * region. Only one aggregator index may exist per account.
17 */
18export type IndexType = "LOCAL" | "AGGREGATOR";
19
20export interface IndexProps {
21 /**
22 * The index type. Promote to `AGGREGATOR` to enable cross-region search
23 * from this region; demote back to `LOCAL` to stop replication. Note
24 * that after demoting an aggregator index AWS enforces a 24-hour wait
25 * before another index can be promoted.
26 * @default "LOCAL"
27 */
28 type?: IndexType;
29
30 /**
31 * Tags to apply to the index. Merged with internal Alchemy tags.
32 */
33 tags?: Record<string, string>;
34}
35
36/** @resource */
37export interface Index extends Resource<
38 "AWS.ResourceExplorer.Index",
39 IndexProps,
40 {
41 /** ARN of the index, e.g. `arn:aws:resource-explorer-2:us-west-2:123456789012:index/uuid`. */
42 indexArn: string;
43 /** The current index type (`LOCAL` or `AGGREGATOR`). */
44 indexType: string;
45 /** Lifecycle state of the index (`CREATING`, `ACTIVE`, `UPDATING`, ...). */
46 indexState: string;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * An AWS Resource Explorer index — the region singleton that turns on
54 * resource indexing so resources in the region can be searched.
55 *
56 * Only one index can exist per region, so this is a capture-and-restore
57 * singleton: adopting an index that Alchemy did not create requires
58 * `--adopt` (ownership is tracked through the index's tags), and destroy
59 * turns Resource Explorer off for the region (deleting every view in it).
60 *
61 * The first index created in an account also creates the
62 * `AWSServiceRoleForResourceExplorer` service-linked role.
63 *
64 * ### Turning on Resource Explorer
65 * **Example:** Local index
66 * ```typescript
67 * const index = yield* AWS.ResourceExplorer.Index("Index", {});
68 * ```
69 *
70 * **Example:** Aggregator index for cross-region search
71 * ```typescript
72 * const index = yield* AWS.ResourceExplorer.Index("Index", {
73 * type: "AGGREGATOR",
74 * });
75 * ```
76 *
77 * ### Searching
78 * Search always goes through a view — see `AWS.ResourceExplorer.View` and
79 * the `AWS.ResourceExplorer.Search` binding.
80 */
81const IndexResource = Resource<Index>("AWS.ResourceExplorer.Index");
82
83export { IndexResource as Index };
84
85/**
86 * `GetIndex` keeps returning the most recent index long after it is
87 * deleted (`State: "DELETED"` has been observed months later), so both
88 * the typed `ResourceNotFoundException` and a `DELETING`/`DELETED` state
89 * mean "no index in this region".
90 */
91const observeIndex = re2.getIndex({}).pipe(
92 Effect.map((r): re2.GetIndexOutput | undefined =>
93 r.State === "DELETED" || r.State === "DELETING" ? undefined : r,
94 ),
95 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
96);
97
98/**
99 * Repeat a poll until the predicate holds (bounded — a fresh index becomes
100 * `ACTIVE` in ~15s; type changes usually settle in seconds when no other
101 * regions replicate). Expressed as an explicitly-typed helper: inlining
102 * `Effect.repeat`/`Effect.retry` in a provider lifecycle op leaves a
103 * conditional type unresolved in the provider's inferred layer type, which
104 * declaration emit widens to an `unknown` R — poisoning `AWS.providers()`.
105 */
106const repeatUntil = <A, E, R>(
107 self: Effect.Effect<A, E, R>,
108 until: (a: A) => boolean,
109): Effect.Effect<A, E, R> =>
110 Effect.repeat(self, {
111 schedule: Schedule.spaced("2 seconds"),
112 until,
113 times: 30,
114 });
115
116/**
117 * Retry `ConflictException` on a bounded schedule — creating an index
118 * while the previous one is still `DELETING` (or racing a peer create)
119 * conflicts transiently.
120 */
121const retryWhileConflict = <A, E extends { _tag: string }, R>(
122 self: Effect.Effect<A, E, R>,
123): Effect.Effect<A, E, R> =>
124 Effect.retry(self, {
125 while: (e) => e._tag === "ConflictException",
126 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
127 });
128
129const toTagRecord = (
130 tags: { [key: string]: string | undefined } | undefined,
131): Record<string, string> => {
132 const out: Record<string, string> = {};
133 for (const [key, value] of Object.entries(tags ?? {})) {
134 if (value !== undefined) out[key] = value;
135 }
136 return out;
137};
138
139const toAttrs = (live: re2.GetIndexOutput): Index["Attributes"] => ({
140 indexArn: live.Arn!,
141 indexType: live.Type ?? "LOCAL",
142 indexState: live.State ?? "ACTIVE",
143});
144
145export const IndexProvider = () =>
146 Provider.effect(
147 IndexResource,
148 Effect.gen(function* () {
149 return IndexResource.Provider.of({
150 stables: ["indexArn"],
151
152 read: Effect.fn(function* ({ id }) {
153 const live = yield* observeIndex;
154 if (!live) return undefined;
155 const attrs = toAttrs(live);
156 const tags = toTagRecord(live.Tags);
157 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
158 }),
159
160 // Region singleton — report the single index, if any.
161 list: () =>
162 Effect.gen(function* () {
163 const live = yield* observeIndex;
164 return live ? [toAttrs(live)] : [];
165 }),
166
167 reconcile: Effect.fn(function* ({ id, news, session }) {
168 const desiredType: IndexType = news.type ?? "LOCAL";
169 const internalTags = yield* createInternalTags(id);
170 const desiredTags = { ...news.tags, ...internalTags };
171
172 // 1. OBSERVE — cloud state is authoritative. A DELETED index is
173 // treated as absent (see observeIndex).
174 let live = yield* observeIndex;
175
176 // 2. ENSURE — create when missing. ConflictException covers both
177 // a race with a peer create and a previous index still
178 // DELETING; retry bounded, then fall through to observation.
179 if (live === undefined) {
180 yield* retryWhileConflict(
181 re2.createIndex({ Tags: desiredTags }),
182 ).pipe(
183 Effect.catchTag("ConflictException", () => Effect.void),
184 Effect.asVoid,
185 );
186 live = yield* repeatUntil(
187 re2.getIndex({}),
188 (r) => r.State === "ACTIVE",
189 );
190 }
191
192 // 3. SYNC TYPE — LOCAL <-> AGGREGATOR is an in-place update.
193 // Wait (bounded) for the asynchronous replication tasks to
194 // settle back to ACTIVE; if they take longer, return the
195 // observed UPDATING state and converge on the next deploy.
196 if ((live.Type ?? "LOCAL") !== desiredType) {
197 yield* retryWhileConflict(
198 re2.updateIndexType({ Arn: live.Arn!, Type: desiredType }),
199 );
200 live = yield* repeatUntil(
201 re2.getIndex({}),
202 (r) => r.State === "ACTIVE",
203 );
204 }
205
206 // 4. SYNC TAGS — diff against OBSERVED cloud tags so adoption
207 // converges (createIndex Tags only apply on first create).
208 const observedTags = toTagRecord(live.Tags);
209 const { upsert, removed } = diffTags(observedTags, desiredTags);
210 if (upsert.length > 0) {
211 yield* re2.tagResource({
212 resourceArn: live.Arn!,
213 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
214 });
215 }
216 if (removed.length > 0) {
217 yield* re2.untagResource({
218 resourceArn: live.Arn!,
219 tagKeys: removed,
220 });
221 }
222
223 yield* session.note(live.Arn!);
224 return {
225 indexArn: live.Arn!,
226 indexType: live.Type ?? desiredType,
227 indexState: live.State ?? "ACTIVE",
228 };
229 }),
230
231 delete: Effect.fn(function* ({ output }) {
232 // Idempotent: the index may already be gone (or replaced by a
233 // foreign one with a different ARN — never delete that one).
234 yield* re2.deleteIndex({ Arn: output.indexArn }).pipe(
235 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
236 Effect.asVoid,
237 );
238 }),
239 });
240 }),
241 );