1import * as qbusiness from "@distilled.cloud/aws/qbusiness";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as EffectStream 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 {
12 createInternalTags,
13 diffTags,
14 hasAlchemyTags,
15 type Tags,
16} from "../../Tags.ts";
17import type { Providers } from "../Providers.ts";
18
19export type IndexStatus = qbusiness.IndexStatus;
20export type IndexType = qbusiness.IndexType;
21
22export interface IndexProps {
23 /**
24 * The identifier of the Amazon Q Business application the index attaches
25 * to. Changing it replaces the index.
26 */
27 applicationId: string;
28 /**
29 * Display name of the index.
30 * @default ${app}-${stage}-${id}
31 */
32 displayName?: string;
33 /**
34 * A description of the index.
35 */
36 description?: string;
37 /**
38 * The index tier — `STARTER` (single AZ) or `ENTERPRISE` (multi AZ).
39 * Changing it replaces the index.
40 * @default "STARTER"
41 */
42 type?: IndexType;
43 /**
44 * Provisioned storage capacity units. Each unit stores 20,000 documents.
45 * @default 1 unit
46 */
47 capacityConfiguration?: qbusiness.IndexCapacityConfiguration;
48 /**
49 * Configuration for document metadata attributes (search/display
50 * behavior).
51 */
52 documentAttributeConfigurations?: qbusiness.DocumentAttributeConfiguration[];
53 /**
54 * Tags to associate with the index.
55 */
56 tags?: Record<string, string>;
57}
58
59export interface Index extends Resource<
60 "AWS.QBusiness.Index",
61 IndexProps,
62 {
63 /**
64 * Service-assigned unique identifier of the index (unique within its
65 * application).
66 */
67 indexId: string;
68 /**
69 * The identifier of the application the index belongs to.
70 */
71 applicationId: string;
72 /**
73 * ARN of the index.
74 */
75 indexArn: string;
76 /**
77 * The index's display name.
78 */
79 displayName: string;
80 /**
81 * The provisioned index tier.
82 */
83 type: IndexType | undefined;
84 /**
85 * Current lifecycle status of the index.
86 */
87 status: IndexStatus | undefined;
88 /**
89 * Current tags reported for the index.
90 */
91 tags: Record<string, string>;
92 },
93 never,
94 Providers
95> {}
96
97/**
98 * An Amazon Q Business index — the document store that data sources sync
99 * content into and retrievers query.
100 *
101 * :::caution
102 * An index bills hourly per provisioned capacity unit from the moment it
103 * becomes `ACTIVE`. Destroy test indexes promptly.
104 * :::
105 * ### Creating Indexes
106 * **Example:** Starter Index
107 * ```typescript
108 * import * as AWS from "alchemy/AWS";
109 *
110 * const index = yield* AWS.QBusiness.Index("Docs", {
111 * applicationId: app.applicationId,
112 * });
113 * ```
114 *
115 * **Example:** Enterprise Index with Extra Capacity
116 * ```typescript
117 * const index = yield* AWS.QBusiness.Index("Docs", {
118 * applicationId: app.applicationId,
119 * type: "ENTERPRISE",
120 * capacityConfiguration: { units: 2 },
121 * });
122 * ```
123 *
124 * @resource
125 */
126export const Index = Resource<Index>("AWS.QBusiness.Index");
127
128const createDisplayName = (
129 id: string,
130 props: { displayName?: string | undefined },
131) =>
132 props.displayName
133 ? Effect.succeed(props.displayName)
134 : createPhysicalName({ id, maxLength: 100 });
135
136const fetchTags = Effect.fn(function* (arn: string) {
137 const response = yield* qbusiness
138 .listTagsForResource({ resourceARN: arn })
139 .pipe(
140 Effect.catchTag("ResourceNotFoundException", () =>
141 Effect.succeed(undefined),
142 ),
143 );
144 return Object.fromEntries(
145 (response?.tags ?? []).map((tag) => [tag.key, tag.value]),
146 );
147});
148
149interface IndexState {
150 attrs: Index["Attributes"];
151 described: qbusiness.GetIndexResponse;
152}
153
154const readIndexById = Effect.fn(function* (
155 applicationId: string,
156 indexId: string,
157) {
158 const described = yield* qbusiness
159 .getIndex({ applicationId, indexId })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 if (!described || described.status === "DELETING") return undefined;
166 const arn = described.indexArn;
167 if (arn === undefined) return undefined;
168 const state: IndexState = {
169 described,
170 attrs: {
171 indexId: described.indexId ?? indexId,
172 applicationId: described.applicationId ?? applicationId,
173 indexArn: arn,
174 displayName: described.displayName ?? "",
175 type: described.type,
176 status: described.status,
177 tags: yield* fetchTags(arn),
178 },
179 };
180 return state;
181});
182
183const findIndexByName = Effect.fn(function* (
184 applicationId: string,
185 displayName: string,
186) {
187 const summaries = yield* qbusiness.listIndices.pages({ applicationId }).pipe(
188 EffectStream.runCollect,
189 Effect.map((chunk) =>
190 Array.from(chunk).flatMap((page) => page.indices ?? []),
191 ),
192 // The parent application may itself be gone.
193 Effect.catchTag("ResourceNotFoundException", () =>
194 Effect.succeed([] as qbusiness.Index[]),
195 ),
196 );
197 const match = summaries.find(
198 (summary) =>
199 summary.displayName === displayName && summary.status !== "DELETING",
200 );
201 if (!match?.indexId) return undefined;
202 return yield* readIndexById(applicationId, match.indexId);
203});
204
205/**
206 * An index still transitioning toward the awaited status — retried by
207 * {@link waitForIndexStatus}'s bounded schedule.
208 */
209class IndexNotReady extends Data.TaggedError("QBusinessIndexNotReady")<{
210 readonly indexId: string;
211 readonly status: string | undefined;
212}> {}
213
214/**
215 * An index whose asynchronous provisioning converged to the terminal
216 * `FAILED` status.
217 */
218export class IndexProvisioningFailed extends Data.TaggedError(
219 "QBusinessIndexProvisioningFailed",
220)<{
221 readonly indexId: string;
222 readonly message: string | undefined;
223}> {}
224
225// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
226// lifecycle code leaks `Retry.Return`'s conditional type into declaration
227// emit and widens the provider layer to `unknown` for every consumer of
228// `AWS.providers()`.
229const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
230 self: Effect.Effect<A, E, R>,
231): Effect.Effect<A, E, R> =>
232 Effect.retry(self, {
233 while: (e) => e._tag === "QBusinessIndexNotReady",
234 // Index provisioning takes several minutes; poll every 15s up to ~30 min.
235 schedule: Schedule.max([
236 Schedule.spaced("15 seconds"),
237 Schedule.recurs(120),
238 ]),
239 });
240
241const waitForIndexStatus = (
242 applicationId: string,
243 indexId: string,
244 target: "ACTIVE" | "DELETED",
245) =>
246 retryWhileNotReady(
247 Effect.gen(function* () {
248 const described = yield* qbusiness
249 .getIndex({ applicationId, indexId })
250 .pipe(
251 Effect.catchTag("ResourceNotFoundException", () =>
252 Effect.succeed(undefined),
253 ),
254 );
255 if (target === "DELETED") {
256 if (described === undefined) return;
257 return yield* Effect.fail(
258 new IndexNotReady({ indexId, status: described.status }),
259 );
260 }
261 if (described?.status === "ACTIVE") return;
262 if (described?.status === "FAILED") {
263 return yield* Effect.fail(
264 new IndexProvisioningFailed({
265 indexId,
266 message: described.error?.errorMessage,
267 }),
268 );
269 }
270 return yield* Effect.fail(
271 new IndexNotReady({ indexId, status: described?.status }),
272 );
273 }),
274 );
275
276export const IndexProvider = () =>
277 Provider.effect(
278 Index,
279 Effect.gen(function* () {
280 return {
281 stables: ["indexId", "applicationId", "indexArn"],
282 // Keyed by a parent application; cannot be enumerated account-wide
283 // without iterating every application — treated as a sub-resource per
284 // the factory list() convention.
285 list: () => Effect.succeed([]),
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const applicationId = output?.applicationId ?? olds?.applicationId;
288 if (applicationId === undefined) return undefined;
289 const state = output?.indexId
290 ? yield* readIndexById(applicationId, output.indexId)
291 : yield* findIndexByName(
292 applicationId,
293 yield* createDisplayName(id, olds ?? {}),
294 );
295 if (!state) return undefined;
296 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
297 ? state.attrs
298 : Unowned(state.attrs);
299 }),
300 diff: Effect.fn(function* ({ news, olds }) {
301 if (!isResolved(news)) return;
302 if (olds === undefined) return;
303 // The parent application and index tier are fixed at creation.
304 if (
305 olds.applicationId !== news.applicationId ||
306 (olds.type ?? "STARTER") !== (news.type ?? "STARTER")
307 ) {
308 return { action: "replace" } as const;
309 }
310 }),
311 reconcile: Effect.fn(function* ({ id, news, output, session }) {
312 if (!news) {
313 return yield* Effect.fail(
314 new Error("QBusiness Index requires props"),
315 );
316 }
317 const applicationId = news.applicationId;
318 const displayName = yield* createDisplayName(id, news);
319 const internalTags = yield* createInternalTags(id);
320 const desiredTags = { ...internalTags, ...news.tags };
321
322 // Observe — prefer the cached id; fall back to a name lookup so a
323 // create whose state failed to persist is adopted, not duplicated.
324 let state = output?.indexId
325 ? yield* readIndexById(applicationId, output.indexId)
326 : yield* findIndexByName(applicationId, displayName);
327
328 // Ensure — create if missing, then wait for ACTIVE.
329 if (state === undefined) {
330 const created = yield* qbusiness.createIndex({
331 applicationId,
332 displayName,
333 description: news.description,
334 type: news.type,
335 capacityConfiguration: news.capacityConfiguration,
336 tags: Object.entries(desiredTags).map(([key, value]) => ({
337 key,
338 value,
339 })),
340 });
341 if (!created.indexId) {
342 return yield* Effect.fail(
343 new Error(
344 `CreateIndex for '${displayName}' returned no indexId`,
345 ),
346 );
347 }
348 yield* session.note(
349 `Creating index ${displayName} (${created.indexId})...`,
350 );
351 yield* waitForIndexStatus(applicationId, created.indexId, "ACTIVE");
352 state = yield* readIndexById(applicationId, created.indexId);
353 if (state === undefined) {
354 return yield* Effect.fail(
355 new Error(`failed to read created index ${displayName}`),
356 );
357 }
358 }
359
360 // Sync mutable settings via UpdateIndex — only when drifted.
361 const described = state.described;
362 const desiredUnits = news.capacityConfiguration?.units;
363 const needsUpdate =
364 displayName !== described.displayName ||
365 (news.description ?? "") !== (described.description ?? "") ||
366 (desiredUnits !== undefined &&
367 desiredUnits !== described.capacityConfiguration?.units) ||
368 news.documentAttributeConfigurations !== undefined;
369 if (needsUpdate) {
370 yield* qbusiness.updateIndex({
371 applicationId,
372 indexId: state.attrs.indexId,
373 displayName,
374 description: news.description,
375 capacityConfiguration: news.capacityConfiguration,
376 documentAttributeConfigurations:
377 news.documentAttributeConfigurations,
378 });
379 yield* waitForIndexStatus(
380 applicationId,
381 state.attrs.indexId,
382 "ACTIVE",
383 );
384 yield* session.note(`Updated index ${displayName}`);
385 }
386
387 // Sync tags — diff against observed cloud tags.
388 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
389 if (removed.length > 0) {
390 yield* qbusiness.untagResource({
391 resourceARN: state.attrs.indexArn,
392 tagKeys: removed,
393 });
394 }
395 if (upsert.length > 0) {
396 yield* qbusiness.tagResource({
397 resourceARN: state.attrs.indexArn,
398 tags: upsert.map(({ Key, Value }) => ({
399 key: Key,
400 value: Value,
401 })),
402 });
403 }
404
405 yield* session.note(state.attrs.indexArn);
406
407 const final = yield* readIndexById(
408 applicationId,
409 state.attrs.indexId,
410 );
411 if (!final) {
412 return yield* Effect.fail(
413 new Error(`failed to read reconciled index ${displayName}`),
414 );
415 }
416 return final.attrs;
417 }),
418 delete: Effect.fn(function* ({ output }) {
419 yield* qbusiness
420 .deleteIndex({
421 applicationId: output.applicationId,
422 indexId: output.indexId,
423 })
424 .pipe(
425 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
426 );
427 yield* waitForIndexStatus(
428 output.applicationId,
429 output.indexId,
430 "DELETED",
431 );
432 }),
433 };
434 }),
435 );