1import * as lexm from "@distilled.cloud/aws/lex-models-v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { diffTags } from "../../Tags.ts";
6
7/** A Lex V2 resource ended in a terminal `Failed` state (or never settled). */
8export class LexOperationFailed extends Data.TaggedError("LexOperationFailed")<{
9 readonly resourceId: string;
10 readonly status: string;
11 readonly reasons: readonly string[];
12}> {}
13
14/**
15 * Coerce a generated physical name into Lex's name pattern
16 * `^([0-9a-zA-Z][_-]?){1,100}$` — every `-`/`_` must be preceded by an
17 * alphanumeric (no leading or consecutive separators).
18 */
19export const toLexName = (name: string): string =>
20 name
21 .replace(/[^0-9a-zA-Z_-]/g, "-")
22 .replace(/[-_]{2,}/g, "-")
23 .replace(/^[-_]+/, "")
24 .slice(0, 100);
25
26/** Convert a Lex wire tag map (values may be undefined) into a plain record. */
27export const toTagRecord = (
28 tags: { [key: string]: string | undefined } | undefined,
29): Record<string, string> =>
30 Object.fromEntries(
31 Object.entries(tags ?? {}).filter(
32 (entry): entry is [string, string] => entry[1] !== undefined,
33 ),
34 );
35
36/**
37 * Retry `ConflictException`s on a bounded schedule. Lex serializes most
38 * mutations per bot — a concurrent build/version/update surfaces as a
39 * transient conflict.
40 *
41 * The explicit `Effect.Effect<A, E, R>` return annotation is load-bearing:
42 * inlining retry/repeat combinators in provider lifecycle code lets their
43 * conditional return types survive into declaration emit and widen the
44 * provider layer to `unknown` (see `../EC2/VolumeAttachment.ts`).
45 */
46export const retryWhileConflict = <A, E extends { readonly _tag: string }, R>(
47 self: Effect.Effect<A, E, R>,
48): Effect.Effect<A, E, R> =>
49 Effect.retry(self, {
50 while: (e) => e._tag === "ConflictException",
51 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(10)]),
52 });
53
54/**
55 * Repeat a describe poll until `done` holds (bounded). Explicitly typed for
56 * the declaration-emit reason above.
57 */
58const untilConverged = <A, E, R>(
59 self: Effect.Effect<A, E, R>,
60 done: (a: A) => boolean,
61 options?: { intervalSeconds?: number; times?: number },
62): Effect.Effect<A, E, R> =>
63 Effect.repeat(self, {
64 schedule: Schedule.spaced(`${options?.intervalSeconds ?? 2} seconds`),
65 until: done,
66 times: options?.times ?? 40,
67 });
68
69const transientBotStatuses = new Set([
70 "Creating",
71 "Versioning",
72 "Updating",
73 "Importing",
74]);
75
76/**
77 * Poll `describeBot` until the bot leaves its transient statuses
78 * (`Creating`/`Versioning`/`Updating`/`Importing`). Fails typed on `Failed`.
79 * Bot mutations settle in seconds — budget ~80s.
80 */
81export const waitForBotSettled = Effect.fn("AWS.LexV2.waitForBotSettled")(
82 function* (botId: string) {
83 const bot = yield* untilConverged(
84 lexm.describeBot({ botId }),
85 (b) => !transientBotStatuses.has(b.botStatus ?? ""),
86 );
87 if (
88 bot.botStatus === "Failed" ||
89 transientBotStatuses.has(bot.botStatus ?? "")
90 ) {
91 return yield* Effect.fail(
92 new LexOperationFailed({
93 resourceId: botId,
94 status: bot.botStatus ?? "unknown",
95 reasons: bot.failureReasons ?? [],
96 }),
97 );
98 }
99 return bot;
100 },
101);
102
103const transientLocaleStatuses = new Set([
104 "Creating",
105 "Processing",
106 "Importing",
107 "Deleting",
108]);
109
110/**
111 * Poll `describeBotLocale` until the DRAFT locale leaves its create-time
112 * transient statuses (settles at `NotBuilt`/`Built`/`ReadyExpressTesting`).
113 * Fails typed on `Failed`.
114 */
115export const waitForLocaleSettled = Effect.fn("AWS.LexV2.waitForLocaleSettled")(
116 function* (botId: string, localeId: string) {
117 const locale = yield* untilConverged(
118 lexm.describeBotLocale({ botId, botVersion: "DRAFT", localeId }),
119 (l) => !transientLocaleStatuses.has(l.botLocaleStatus ?? ""),
120 );
121 if (
122 locale.botLocaleStatus === "Failed" ||
123 transientLocaleStatuses.has(locale.botLocaleStatus ?? "")
124 ) {
125 return yield* Effect.fail(
126 new LexOperationFailed({
127 resourceId: `${botId}/${localeId}`,
128 status: locale.botLocaleStatus ?? "unknown",
129 reasons: locale.failureReasons ?? [],
130 }),
131 );
132 }
133 return locale;
134 },
135);
136
137/**
138 * Poll `describeBotLocale` until a triggered build lands on `Built`.
139 * `Building` and `ReadyExpressTesting` are intermediate build states. A tiny
140 * bot normally builds in under a minute; keep the poll bounded to ~90 seconds
141 * so a stalled AWS build fails promptly.
142 */
143export const waitForLocaleBuilt = Effect.fn("AWS.LexV2.waitForLocaleBuilt")(
144 function* (botId: string, localeId: string) {
145 const locale = yield* untilConverged(
146 lexm.describeBotLocale({ botId, botVersion: "DRAFT", localeId }),
147 (l) => l.botLocaleStatus === "Built" || l.botLocaleStatus === "Failed",
148 { intervalSeconds: 5, times: 18 },
149 );
150 if (locale.botLocaleStatus !== "Built") {
151 return yield* Effect.fail(
152 new LexOperationFailed({
153 resourceId: `${botId}/${localeId}`,
154 status: locale.botLocaleStatus ?? "unknown",
155 reasons: locale.failureReasons ?? [],
156 }),
157 );
158 }
159 return locale;
160 },
161);
162
163/**
164 * Poll `describeBotAlias` until the alias leaves `Creating`. Fails typed on
165 * `Failed`.
166 */
167export const waitForAliasSettled = Effect.fn("AWS.LexV2.waitForAliasSettled")(
168 function* (botId: string, botAliasId: string) {
169 const alias = yield* untilConverged(
170 lexm.describeBotAlias({ botId, botAliasId }),
171 (a) => a.botAliasStatus !== "Creating",
172 );
173 if (
174 alias.botAliasStatus === "Failed" ||
175 alias.botAliasStatus === "Creating"
176 ) {
177 return yield* Effect.fail(
178 new LexOperationFailed({
179 resourceId: `${botId}/${botAliasId}`,
180 status: alias.botAliasStatus ?? "unknown",
181 reasons: [],
182 }),
183 );
184 }
185 return alias;
186 },
187);
188
189/**
190 * Read the observed tags of a Lex resource by ARN. Best-effort — a race with
191 * deletion reports no tags.
192 */
193export const readLexTags = Effect.fn("AWS.LexV2.readLexTags")(function* (
194 arn: string,
195) {
196 const response = yield* lexm
197 .listTagsForResource({ resourceARN: arn })
198 .pipe(Effect.catch(() => Effect.succeed(undefined)));
199 return toTagRecord(response?.tags);
200});
201
202/**
203 * Sync tags on a Lex resource: diff OBSERVED cloud tags against the desired
204 * set and apply only the delta.
205 */
206export const syncLexTags = Effect.fn("AWS.LexV2.syncLexTags")(function* (
207 arn: string,
208 observed: Record<string, string>,
209 desired: Record<string, string>,
210) {
211 const { removed, upsert } = diffTags(observed, desired);
212 if (upsert.length > 0) {
213 yield* lexm.tagResource({
214 resourceARN: arn,
215 tags: Object.fromEntries(upsert.map((tag) => [tag.Key, tag.Value])),
216 });
217 }
218 if (removed.length > 0) {
219 yield* lexm.untagResource({ resourceARN: arn, tagKeys: removed });
220 }
221});