1import * as lexm from "@distilled.cloud/aws/lex-models-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 { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 readLexTags,
15 toLexName,
16 retryWhileConflict,
17 syncLexTags,
18 waitForAliasSettled,
19} from "./internal.ts";
20
21/**
22 * Lambda code hooks per locale (`localeId` → function ARN). Attached to the
23 * alias's `botAliasLocaleSettings` as
24 * `codeHookSpecification.lambdaCodeHook` with interface version `1.0`.
25 */
26export interface BotAliasCodeHooks extends Record<string, string> {}
27
28/**
29 * The binding contract of a bot alias: event sources contribute Lambda code
30 * hooks (`localeId` → function ARN) that the provider merges with
31 * `props.codeHooks` and syncs onto the alias's `botAliasLocaleSettings`.
32 */
33export interface BotAliasBinding {
34 /** Code hook entries injected by `LexV2.onCodeHook`. */
35 codeHooks?: BotAliasCodeHooks;
36}
37
38/**
39 * Two different Lambda functions were registered as the code hook of the
40 * same alias locale — Lex supports exactly one dialog/fulfillment function
41 * per alias locale.
42 */
43export class ConflictingCodeHook extends Data.TaggedError(
44 "ConflictingCodeHook",
45)<{
46 readonly localeId: string;
47 readonly functionArns: readonly string[];
48}> {}
49
50export interface BotAliasProps {
51 /**
52 * ID of the bot the alias belongs to. Changing it replaces the alias.
53 */
54 botId: string;
55 /**
56 * Name of the alias. Mutable — renames update the alias in place (identity
57 * is the generated alias ID).
58 * @default ${app}-${id}-${stage}-${suffix}
59 */
60 botAliasName?: string;
61 /**
62 * The numbered bot version the alias points at. Leave undefined to create
63 * the alias unassociated and point it at a version later. Pass the
64 * `botVersion` attribute of a `BotVersion` so the alias depends on it.
65 */
66 botVersion?: string;
67 /**
68 * Description of the alias.
69 */
70 description?: string;
71 /**
72 * Lambda code hooks per locale (`localeId` → function ARN). Merged with
73 * code hook entries injected through the binding contract by
74 * `LexV2.onCodeHook` — prefer the event source over declaring ARNs here,
75 * since it also creates the invoke Permission and registers the runtime
76 * handler.
77 */
78 codeHooks?: BotAliasCodeHooks;
79 /**
80 * Tags to associate with the alias.
81 */
82 tags?: Record<string, string>;
83}
84
85export interface BotAlias extends Resource<
86 "AWS.LexV2.BotAlias",
87 BotAliasProps,
88 {
89 /** Unique identifier assigned to the alias. */
90 botAliasId: string;
91 /** Name of the alias. */
92 botAliasName: string;
93 /** ARN of the alias (`arn:aws:lex:...:bot-alias/{botId}/{botAliasId}`). */
94 botAliasArn: string;
95 /** ID of the bot the alias belongs to. */
96 botId: string;
97 /** The bot version the alias points at, if associated. */
98 botVersion: string | undefined;
99 /** Current status of the alias (e.g. `Available`). */
100 botAliasStatus: string;
101 /** Tags currently associated with the alias. */
102 tags: Record<string, string>;
103 },
104 BotAliasBinding,
105 Providers
106> {}
107
108/**
109 * An alias of an Amazon Lex V2 bot — a stable pointer to a numbered bot
110 * version that runtime conversations (e.g. `RecognizeText`) target.
111 *
112 * ### Creating an Alias
113 * **Example:** Alias on a Version
114 * ```typescript
115 * import * as AWS from "alchemy/AWS";
116 *
117 * const alias = yield* AWS.LexV2.BotAlias("Live", {
118 * botId: version.botId,
119 * botVersion: version.botVersion,
120 * });
121 * ```
122 *
123 * **Example:** Unassociated Alias
124 * ```typescript
125 * // point it at a version later without changing consumers
126 * const alias = yield* AWS.LexV2.BotAlias("Staging", {
127 * botId: bot.botId,
128 * });
129 * ```
130 *
131 * ### Conversing at Runtime
132 * **Example:** RecognizeText from a Lambda
133 * ```typescript
134 * const recognizeText = yield* AWS.LexV2.RecognizeText(alias);
135 * const reply = yield* recognizeText({
136 * localeId: "en_US",
137 * sessionId: "user-123",
138 * text: "hello",
139 * });
140 * ```
141 *
142 * @resource
143 */
144export const BotAlias = Resource<BotAlias>("AWS.LexV2.BotAlias");
145
146const createAliasName = (
147 id: string,
148 props: { botAliasName?: string | undefined },
149) =>
150 Effect.gen(function* () {
151 if (props.botAliasName) return props.botAliasName;
152 return toLexName(yield* createPhysicalName({ id, maxLength: 100 }));
153 });
154
155const describeAlias = Effect.fn(function* (botId: string, botAliasId: string) {
156 return yield* lexm
157 .describeBotAlias({ botId, botAliasId })
158 .pipe(
159 Effect.catchTag("ResourceNotFoundException", () =>
160 Effect.succeed(undefined),
161 ),
162 );
163});
164
165/** Find an alias of the bot by exact name (used when state was lost). */
166const findAliasByName = Effect.fn(function* (
167 botId: string,
168 botAliasName: string,
169) {
170 const pages = yield* lexm.listBotAliases.pages({ botId }).pipe(
171 Stream.runCollect,
172 Effect.catchTag("ResourceNotFoundException", () =>
173 Effect.succeed([] as lexm.ListBotAliasesResponse[]),
174 ),
175 );
176 const summary = Array.from(pages)
177 .flatMap((page) => page.botAliasSummaries ?? [])
178 .find((alias) => alias.botAliasName === botAliasName);
179 if (summary?.botAliasId === undefined) return undefined;
180 return yield* describeAlias(botId, summary.botAliasId);
181});
182
183const aliasArnOf = Effect.fn(function* (botId: string, botAliasId: string) {
184 const { accountId, region } = yield* AWSEnvironment.current;
185 return `arn:aws:lex:${region}:${accountId}:bot-alias/${botId}/${botAliasId}`;
186});
187
188/**
189 * The alias's desired code hooks: `props.codeHooks` merged with the entries
190 * contributed through the binding contract (`LexV2.onCodeHook`). Fails when
191 * two different function ARNs target the same locale. Returns `undefined`
192 * when no code hooks are desired (omitting `botAliasLocaleSettings` clears
193 * them on both create and update).
194 */
195const resolveCodeHooks = Effect.fn(function* (
196 news: BotAliasProps,
197 bindings: ReadonlyArray<BotAliasBinding | { data?: BotAliasBinding }>,
198) {
199 const merged: Record<string, string> = {};
200 const contributions = [
201 news.codeHooks,
202 ...bindings.map(
203 (binding) =>
204 (binding as { data?: BotAliasBinding }).data?.codeHooks ??
205 (binding as BotAliasBinding).codeHooks,
206 ),
207 ];
208 for (const hooks of contributions) {
209 if (hooks === undefined) continue;
210 for (const [localeId, arn] of Object.entries(hooks)) {
211 const existing = merged[localeId];
212 if (existing !== undefined && existing !== arn) {
213 return yield* Effect.fail(
214 new ConflictingCodeHook({
215 localeId,
216 functionArns: [existing, arn],
217 }),
218 );
219 }
220 merged[localeId] = arn;
221 }
222 }
223 return Object.keys(merged).length > 0 ? merged : undefined;
224});
225
226/** Build the wire `botAliasLocaleSettings` map from desired code hooks. */
227const toLocaleSettings = (
228 codeHooks: Record<string, string> | undefined,
229): { [key: string]: lexm.BotAliasLocaleSettings } | undefined =>
230 codeHooks === undefined
231 ? undefined
232 : Object.fromEntries(
233 Object.entries(codeHooks).map(([localeId, lambdaARN]) => [
234 localeId,
235 {
236 enabled: true,
237 codeHookSpecification: {
238 lambdaCodeHook: { lambdaARN, codeHookInterfaceVersion: "1.0" },
239 },
240 },
241 ]),
242 );
243
244/**
245 * Project the aspects of `botAliasLocaleSettings` this resource manages
246 * (enabled + lambda ARN per locale) into a canonical string for drift
247 * comparison.
248 */
249const localeSettingsProjection = (
250 settings:
251 | { [key: string]: lexm.BotAliasLocaleSettings | undefined }
252 | undefined,
253): string =>
254 JSON.stringify(
255 Object.entries(settings ?? {})
256 .filter(([, value]) => value !== undefined)
257 .map(([localeId, value]) => [
258 localeId,
259 value!.enabled,
260 value!.codeHookSpecification?.lambdaCodeHook.lambdaARN,
261 ])
262 .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
263 );
264
265const attributesOf = Effect.fn(function* (
266 alias: lexm.DescribeBotAliasResponse,
267) {
268 const botAliasArn = yield* aliasArnOf(alias.botId!, alias.botAliasId!);
269 return {
270 botAliasId: alias.botAliasId!,
271 botAliasName: alias.botAliasName!,
272 botAliasArn,
273 botId: alias.botId!,
274 botVersion: alias.botVersion,
275 botAliasStatus: alias.botAliasStatus!,
276 tags: yield* readLexTags(botAliasArn),
277 } satisfies BotAlias["Attributes"];
278});
279
280export const BotAliasProvider = () =>
281 Provider.effect(
282 BotAlias,
283 Effect.gen(function* () {
284 return {
285 stables: ["botAliasId", "botAliasArn", "botId"],
286
287 // Sub-resource keyed entirely by its bot — nuke reaches it through
288 // the parent bot's deletion.
289 list: () => Effect.succeed([]),
290
291 read: Effect.fn(function* ({ id, olds, output }) {
292 const botId = output?.botId ?? olds?.botId;
293 if (botId === undefined) return undefined;
294 const observed =
295 output?.botAliasId !== undefined
296 ? yield* describeAlias(botId, output.botAliasId)
297 : yield* findAliasByName(
298 botId,
299 yield* createAliasName(id, olds ?? {}),
300 );
301 if (observed === undefined) return undefined;
302 const attrs = yield* attributesOf(observed);
303 return (yield* hasAlchemyTags(id, attrs.tags))
304 ? attrs
305 : Unowned(attrs);
306 }),
307
308 diff: Effect.fn(function* ({ news, olds }) {
309 if (!isResolved(news)) return undefined;
310 if (olds?.botId !== news.botId) {
311 return { action: "replace" } as const;
312 }
313 }),
314
315 reconcile: Effect.fn(function* ({
316 id,
317 news,
318 output,
319 session,
320 bindings,
321 }) {
322 const botAliasName = yield* createAliasName(id, news);
323 const internalTags = yield* createInternalTags(id);
324 const desiredTags = { ...internalTags, ...news.tags };
325 const desiredCodeHooks = yield* resolveCodeHooks(news, bindings);
326 const desiredLocaleSettings = toLocaleSettings(desiredCodeHooks);
327
328 // 1. OBSERVE — output.botAliasId is only a cache; fall back to name.
329 let observed =
330 output?.botAliasId !== undefined
331 ? yield* describeAlias(news.botId, output.botAliasId)
332 : undefined;
333 if (observed === undefined) {
334 observed = yield* findAliasByName(news.botId, botAliasName);
335 }
336
337 // 2. ENSURE — create when missing.
338 if (observed === undefined) {
339 const created = yield* retryWhileConflict(
340 lexm.createBotAlias({
341 botId: news.botId,
342 botAliasName,
343 botVersion: news.botVersion,
344 botAliasLocaleSettings: desiredLocaleSettings,
345 description: news.description,
346 tags: desiredTags,
347 }),
348 );
349 observed = yield* waitForAliasSettled(
350 news.botId,
351 created.botAliasId!,
352 );
353 } else if (
354 // 3. SYNC — apply the delta when a declared prop drifted.
355 observed.botAliasName !== botAliasName ||
356 (observed.botVersion ?? undefined) !==
357 (news.botVersion ?? undefined) ||
358 (observed.description ?? undefined) !==
359 (news.description ?? undefined) ||
360 localeSettingsProjection(observed.botAliasLocaleSettings) !==
361 localeSettingsProjection(desiredLocaleSettings)
362 ) {
363 yield* retryWhileConflict(
364 lexm.updateBotAlias({
365 botId: news.botId,
366 botAliasId: observed.botAliasId!,
367 botAliasName,
368 botVersion: news.botVersion,
369 botAliasLocaleSettings: desiredLocaleSettings,
370 description: news.description,
371 }),
372 );
373 observed = yield* waitForAliasSettled(
374 news.botId,
375 observed.botAliasId!,
376 );
377 }
378
379 // 3b. SYNC TAGS — diff against observed cloud tags.
380 const botAliasArn = yield* aliasArnOf(
381 news.botId,
382 observed.botAliasId!,
383 );
384 const observedTags = yield* readLexTags(botAliasArn);
385 yield* syncLexTags(botAliasArn, observedTags, desiredTags);
386
387 yield* session.note(observed.botAliasId!);
388 return yield* attributesOf(observed);
389 }),
390
391 delete: Effect.fn(function* ({ output }) {
392 // Lex reports a missing alias (or already-deleted parent bot) as
393 // PreconditionFailed.
394 yield* retryWhileConflict(
395 lexm.deleteBotAlias({
396 botId: output.botId,
397 botAliasId: output.botAliasId,
398 skipResourceInUseCheck: true,
399 }),
400 ).pipe(
401 Effect.catchTag("PreconditionFailedException", () => Effect.void),
402 );
403 }),
404 };
405 }),
406 );