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 RetrieverStatus = qbusiness.RetrieverStatus;
20export type RetrieverType = qbusiness.RetrieverType;
21
22export interface RetrieverProps {
23 /**
24 * The identifier of the Amazon Q Business application the retriever
25 * attaches to. Changing it replaces the retriever.
26 */
27 applicationId: string;
28 /**
29 * The retriever type — `NATIVE_INDEX` (an Amazon Q Business index) or
30 * `KENDRA_INDEX` (an existing Amazon Kendra index). Changing it replaces
31 * the retriever.
32 */
33 type: RetrieverType;
34 /**
35 * Display name of the retriever.
36 * @default ${app}-${stage}-${id}
37 */
38 displayName?: string;
39 /**
40 * The index the retriever queries — `nativeIndexConfiguration` for a
41 * `NATIVE_INDEX` retriever, `kendraIndexConfiguration` for a
42 * `KENDRA_INDEX` retriever.
43 */
44 configuration: qbusiness.RetrieverConfiguration;
45 /**
46 * ARN of the IAM role the retriever assumes (required for
47 * `KENDRA_INDEX` retrievers to query the Kendra index).
48 */
49 roleArn?: string;
50 /**
51 * Tags to associate with the retriever.
52 */
53 tags?: Record<string, string>;
54}
55
56export interface Retriever extends Resource<
57 "AWS.QBusiness.Retriever",
58 RetrieverProps,
59 {
60 /**
61 * Service-assigned unique identifier of the retriever (unique within
62 * its application).
63 */
64 retrieverId: string;
65 /**
66 * The identifier of the application the retriever belongs to.
67 */
68 applicationId: string;
69 /**
70 * ARN of the retriever.
71 */
72 retrieverArn: string;
73 /**
74 * The retriever's display name.
75 */
76 displayName: string;
77 /**
78 * The retriever type.
79 */
80 type: RetrieverType | undefined;
81 /**
82 * Current lifecycle status of the retriever.
83 */
84 status: RetrieverStatus | undefined;
85 /**
86 * Current tags reported for the retriever.
87 */
88 tags: Record<string, string>;
89 },
90 never,
91 Providers
92> {}
93
94/**
95 * An Amazon Q Business retriever — the query engine that fetches relevant
96 * passages from an index (native or Kendra) to ground chat responses.
97 *
98 * ### Creating Retrievers
99 * **Example:** Native Index Retriever
100 * ```typescript
101 * import * as AWS from "alchemy/AWS";
102 *
103 * const retriever = yield* AWS.QBusiness.Retriever("Docs", {
104 * applicationId: app.applicationId,
105 * type: "NATIVE_INDEX",
106 * configuration: {
107 * nativeIndexConfiguration: { indexId: index.indexId },
108 * },
109 * });
110 * ```
111 *
112 * **Example:** Kendra Index Retriever
113 * ```typescript
114 * const retriever = yield* AWS.QBusiness.Retriever("Kendra", {
115 * applicationId: app.applicationId,
116 * type: "KENDRA_INDEX",
117 * roleArn: role.roleArn,
118 * configuration: {
119 * kendraIndexConfiguration: { indexId: kendraIndex.id },
120 * },
121 * });
122 * ```
123 *
124 * @resource
125 */
126export const Retriever = Resource<Retriever>("AWS.QBusiness.Retriever");
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 RetrieverState {
150 attrs: Retriever["Attributes"];
151 described: qbusiness.GetRetrieverResponse;
152}
153
154const readRetrieverById = Effect.fn(function* (
155 applicationId: string,
156 retrieverId: string,
157) {
158 const described = yield* qbusiness
159 .getRetriever({ applicationId, retrieverId })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 if (!described) return undefined;
166 const arn = described.retrieverArn;
167 if (arn === undefined) return undefined;
168 const state: RetrieverState = {
169 described,
170 attrs: {
171 retrieverId: described.retrieverId ?? retrieverId,
172 applicationId: described.applicationId ?? applicationId,
173 retrieverArn: 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 findRetrieverByName = Effect.fn(function* (
184 applicationId: string,
185 displayName: string,
186) {
187 const summaries = yield* qbusiness.listRetrievers
188 .pages({ applicationId })
189 .pipe(
190 EffectStream.runCollect,
191 Effect.map((chunk) =>
192 Array.from(chunk).flatMap((page) => page.retrievers ?? []),
193 ),
194 // The parent application may itself be gone.
195 Effect.catchTag("ResourceNotFoundException", () =>
196 Effect.succeed([] as qbusiness.Retriever[]),
197 ),
198 );
199 const match = summaries.find(
200 (summary) => summary.displayName === displayName,
201 );
202 if (!match?.retrieverId) return undefined;
203 return yield* readRetrieverById(applicationId, match.retrieverId);
204});
205
206/**
207 * A retriever still transitioning toward `ACTIVE` — retried by
208 * {@link waitForRetrieverActive}'s bounded schedule.
209 */
210class RetrieverNotReady extends Data.TaggedError("RetrieverNotReady")<{
211 readonly retrieverId: string;
212 readonly status: string | undefined;
213}> {}
214
215/**
216 * A retriever whose asynchronous provisioning converged to the terminal
217 * `FAILED` status.
218 */
219export class RetrieverProvisioningFailed extends Data.TaggedError(
220 "RetrieverProvisioningFailed",
221)<{
222 readonly retrieverId: string;
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 === "RetrieverNotReady",
234 // Retriever provisioning is fast; poll every 5s up to ~5 min.
235 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(60)]),
236 });
237
238const waitForRetrieverActive = (applicationId: string, retrieverId: string) =>
239 retryWhileNotReady(
240 Effect.gen(function* () {
241 const described = yield* qbusiness
242 .getRetriever({ applicationId, retrieverId })
243 .pipe(
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed(undefined),
246 ),
247 );
248 if (described?.status === "ACTIVE") return;
249 if (described?.status === "FAILED") {
250 return yield* Effect.fail(
251 new RetrieverProvisioningFailed({ retrieverId }),
252 );
253 }
254 return yield* Effect.fail(
255 new RetrieverNotReady({ retrieverId, status: described?.status }),
256 );
257 }),
258 );
259
260export const RetrieverProvider = () =>
261 Provider.effect(
262 Retriever,
263 Effect.gen(function* () {
264 return {
265 stables: ["retrieverId", "applicationId", "retrieverArn"],
266 // Keyed by a parent application; cannot be enumerated account-wide
267 // without iterating every application — treated as a sub-resource per
268 // the factory list() convention.
269 list: () => Effect.succeed([]),
270 read: Effect.fn(function* ({ id, olds, output }) {
271 const applicationId = output?.applicationId ?? olds?.applicationId;
272 if (applicationId === undefined) return undefined;
273 const state = output?.retrieverId
274 ? yield* readRetrieverById(applicationId, output.retrieverId)
275 : yield* findRetrieverByName(
276 applicationId,
277 yield* createDisplayName(id, olds ?? {}),
278 );
279 if (!state) return undefined;
280 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
281 ? state.attrs
282 : Unowned(state.attrs);
283 }),
284 diff: Effect.fn(function* ({ news, olds }) {
285 if (!isResolved(news)) return;
286 if (olds === undefined) return;
287 // The parent application and retriever type are fixed at creation.
288 if (
289 olds.applicationId !== news.applicationId ||
290 olds.type !== news.type
291 ) {
292 return { action: "replace" } as const;
293 }
294 }),
295 reconcile: Effect.fn(function* ({ id, news, output, session }) {
296 if (!news) {
297 return yield* Effect.fail(
298 new Error("QBusiness Retriever requires props"),
299 );
300 }
301 const applicationId = news.applicationId;
302 const displayName = yield* createDisplayName(id, news);
303 const internalTags = yield* createInternalTags(id);
304 const desiredTags = { ...internalTags, ...news.tags };
305
306 // Observe — prefer the cached id; fall back to a name lookup so a
307 // create whose state failed to persist is adopted, not duplicated.
308 let state = output?.retrieverId
309 ? yield* readRetrieverById(applicationId, output.retrieverId)
310 : yield* findRetrieverByName(applicationId, displayName);
311
312 // Ensure — create if missing, then wait for ACTIVE.
313 if (state === undefined) {
314 const created = yield* qbusiness.createRetriever({
315 applicationId,
316 type: news.type,
317 displayName,
318 configuration: news.configuration,
319 roleArn: news.roleArn,
320 tags: Object.entries(desiredTags).map(([key, value]) => ({
321 key,
322 value,
323 })),
324 });
325 if (!created.retrieverId) {
326 return yield* Effect.fail(
327 new Error(
328 `CreateRetriever for '${displayName}' returned no retrieverId`,
329 ),
330 );
331 }
332 yield* session.note(
333 `Creating retriever ${displayName} (${created.retrieverId})...`,
334 );
335 yield* waitForRetrieverActive(applicationId, created.retrieverId);
336 state = yield* readRetrieverById(
337 applicationId,
338 created.retrieverId,
339 );
340 if (state === undefined) {
341 return yield* Effect.fail(
342 new Error(`failed to read created retriever ${displayName}`),
343 );
344 }
345 }
346
347 // Sync mutable settings via UpdateRetriever — only when drifted.
348 const described = state.described;
349 const desiredNativeIndexId =
350 news.configuration.nativeIndexConfiguration?.indexId;
351 const observedNativeIndexId =
352 described.configuration?.nativeIndexConfiguration?.indexId;
353 const desiredKendraIndexId =
354 news.configuration.kendraIndexConfiguration?.indexId;
355 const observedKendraIndexId =
356 described.configuration?.kendraIndexConfiguration?.indexId;
357 const needsUpdate =
358 displayName !== described.displayName ||
359 (news.roleArn !== undefined &&
360 news.roleArn !== described.roleArn) ||
361 desiredNativeIndexId !== observedNativeIndexId ||
362 desiredKendraIndexId !== observedKendraIndexId;
363 if (needsUpdate) {
364 yield* qbusiness.updateRetriever({
365 applicationId,
366 retrieverId: state.attrs.retrieverId,
367 displayName,
368 configuration: news.configuration,
369 roleArn: news.roleArn,
370 });
371 yield* waitForRetrieverActive(
372 applicationId,
373 state.attrs.retrieverId,
374 );
375 yield* session.note(`Updated retriever ${displayName}`);
376 }
377
378 // Sync tags — diff against observed cloud tags.
379 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
380 if (removed.length > 0) {
381 yield* qbusiness.untagResource({
382 resourceARN: state.attrs.retrieverArn,
383 tagKeys: removed,
384 });
385 }
386 if (upsert.length > 0) {
387 yield* qbusiness.tagResource({
388 resourceARN: state.attrs.retrieverArn,
389 tags: upsert.map(({ Key, Value }) => ({
390 key: Key,
391 value: Value,
392 })),
393 });
394 }
395
396 yield* session.note(state.attrs.retrieverArn);
397
398 const final = yield* readRetrieverById(
399 applicationId,
400 state.attrs.retrieverId,
401 );
402 if (!final) {
403 return yield* Effect.fail(
404 new Error(`failed to read reconciled retriever ${displayName}`),
405 );
406 }
407 return final.attrs;
408 }),
409 delete: Effect.fn(function* ({ output }) {
410 yield* qbusiness
411 .deleteRetriever({
412 applicationId: output.applicationId,
413 retrieverId: output.retrieverId,
414 })
415 .pipe(
416 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
417 );
418 }),
419 };
420 }),
421 );