1import * as lexm from "@distilled.cloud/aws/lex-models-v2";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import {
8 retryWhileConflict,
9 waitForBotSettled,
10 waitForLocaleBuilt,
11} from "./internal.ts";
12
13export interface BotVersionProps {
14 /**
15 * ID of the bot to version. Changing it replaces the version.
16 */
17 botId: string;
18 /**
19 * The locales included in the version, snapshot from `DRAFT`. Each locale
20 * is built first if it is not already built (building a small bot takes
21 * one to a few minutes). Pass the `localeId` attribute of the Intent (or
22 * BotLocale) resources so the version depends on the conversation graph.
23 * Changing the list replaces the version.
24 */
25 localeIds: string[];
26 /**
27 * Description of the version. Changing it replaces the version (versions
28 * are immutable).
29 */
30 description?: string;
31}
32
33export interface BotVersion extends Resource<
34 "AWS.LexV2.BotVersion",
35 BotVersionProps,
36 {
37 /** ID of the bot the version belongs to. */
38 botId: string;
39 /** The numbered version Amazon Lex assigned, e.g. `1`. */
40 botVersion: string;
41 /** Status of the bot version (e.g. `Available`). */
42 botStatus: string;
43 /** The locales included in the version. */
44 localeIds: string[];
45 },
46 never,
47 Providers
48> {}
49
50/**
51 * An immutable numbered version of an Amazon Lex V2 bot, snapshot from the
52 * DRAFT version. The provider builds each included locale first (if needed),
53 * so the version is immediately usable behind a `BotAlias`.
54 *
55 * Versions are immutable: any prop change replaces the resource with a newly
56 * created version.
57 *
58 * ### Creating a Version
59 * **Example:** Version a Built Locale
60 * ```typescript
61 * import * as AWS from "alchemy/AWS";
62 *
63 * const version = yield* AWS.LexV2.BotVersion("V1", {
64 * botId: intent.botId,
65 * // depend on the intent so the build includes it
66 * localeIds: [intent.localeId],
67 * });
68 * const alias = yield* AWS.LexV2.BotAlias("Live", {
69 * botId: version.botId,
70 * botVersion: version.botVersion,
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const BotVersion = Resource<BotVersion>("AWS.LexV2.BotVersion");
77
78const describeVersion = Effect.fn(function* (
79 botId: string,
80 botVersion: string,
81) {
82 return yield* lexm
83 .describeBotVersion({ botId, botVersion })
84 .pipe(
85 Effect.catchTag("ResourceNotFoundException", () =>
86 Effect.succeed(undefined),
87 ),
88 );
89});
90
91export const BotVersionProvider = () =>
92 Provider.effect(
93 BotVersion,
94 Effect.gen(function* () {
95 return {
96 stables: ["botId", "botVersion"],
97
98 // Sub-resource keyed entirely by its bot — nuke reaches it through
99 // the parent bot's deletion.
100 list: () => Effect.succeed([]),
101
102 read: Effect.fn(function* ({ output }) {
103 // Versions are auto-numbered — without cached output there is no
104 // deterministic identity to look up.
105 if (output === undefined) return undefined;
106 const observed = yield* describeVersion(
107 output.botId,
108 output.botVersion,
109 );
110 if (observed === undefined) return undefined;
111 return {
112 botId: observed.botId!,
113 botVersion: observed.botVersion!,
114 botStatus: observed.botStatus!,
115 localeIds: output.localeIds,
116 };
117 }),
118
119 diff: Effect.fn(function* ({ news, olds }) {
120 if (!isResolved(news)) return undefined;
121 // Versions are immutable — any change replaces (a new version is
122 // created and the old one deleted).
123 if (
124 olds?.botId !== news.botId ||
125 (olds?.description ?? undefined) !==
126 (news.description ?? undefined) ||
127 JSON.stringify([...(olds?.localeIds ?? [])].sort()) !==
128 JSON.stringify([...news.localeIds].sort())
129 ) {
130 return { action: "replace" } as const;
131 }
132 }),
133
134 reconcile: Effect.fn(function* ({ news, output, session }) {
135 // 1. OBSERVE — versions are immutable; if the cached version still
136 // exists there is nothing to converge.
137 if (output?.botVersion !== undefined) {
138 const existing = yield* describeVersion(
139 news.botId,
140 output.botVersion,
141 );
142 if (existing !== undefined) {
143 yield* session.note(`${news.botId}/${output.botVersion}`);
144 return {
145 botId: existing.botId!,
146 botVersion: existing.botVersion!,
147 botStatus: existing.botStatus!,
148 localeIds: news.localeIds,
149 };
150 }
151 }
152
153 // 2. ENSURE each included DRAFT locale is built.
154 for (const localeId of news.localeIds) {
155 const locale = yield* lexm.describeBotLocale({
156 botId: news.botId,
157 botVersion: "DRAFT",
158 localeId,
159 });
160 if (locale.botLocaleStatus !== "Built") {
161 // An in-flight build surfaces as Conflict — treat as a race.
162 yield* lexm
163 .buildBotLocale({
164 botId: news.botId,
165 botVersion: "DRAFT",
166 localeId,
167 })
168 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
169 yield* waitForLocaleBuilt(news.botId, localeId);
170 }
171 }
172
173 // 3. CREATE the version snapshot and wait for it to settle.
174 const created = yield* retryWhileConflict(
175 lexm.createBotVersion({
176 botId: news.botId,
177 description: news.description,
178 botVersionLocaleSpecification: Object.fromEntries(
179 news.localeIds.map((localeId) => [
180 localeId,
181 { sourceBotVersion: "DRAFT" },
182 ]),
183 ),
184 }),
185 );
186 const botVersion = created.botVersion!;
187 yield* waitForBotSettled(news.botId);
188 const observed = yield* describeVersion(news.botId, botVersion);
189 if (observed === undefined) {
190 return yield* Effect.fail(
191 new Error(
192 `failed to read created Lex bot version ${news.botId}/${botVersion}`,
193 ),
194 );
195 }
196
197 yield* session.note(`${news.botId}/${botVersion}`);
198 return {
199 botId: observed.botId!,
200 botVersion: observed.botVersion!,
201 botStatus: observed.botStatus!,
202 localeIds: news.localeIds,
203 };
204 }),
205
206 delete: Effect.fn(function* ({ output }) {
207 // Lex reports a missing version (or already-deleted parent bot) as
208 // PreconditionFailed.
209 yield* retryWhileConflict(
210 lexm.deleteBotVersion({
211 botId: output.botId,
212 botVersion: output.botVersion,
213 skipResourceInUseCheck: true,
214 }),
215 ).pipe(
216 Effect.catchTag("PreconditionFailedException", () => Effect.void),
217 );
218 }),
219 };
220 }),
221 );