1import * as polly from "@distilled.cloud/aws/polly";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface LexiconProps {
13 /**
14 * Name of the lexicon — an alphanumeric string up to 20 characters
15 * (`[0-9A-Za-z]{1,20}`). If omitted, a unique name is generated. Changing
16 * this replaces the lexicon.
17 */
18 lexiconName?: string;
19 /**
20 * The lexicon content, a W3C Pronunciation Lexicon Specification (PLS)
21 * XML document. Updatable in place — `PutLexicon` is a true upsert.
22 */
23 content: string;
24}
25
26export interface Lexicon extends Resource<
27 "AWS.Polly.Lexicon",
28 LexiconProps,
29 {
30 /**
31 * Name of the lexicon (its identity within the region). Pass it as
32 * `LexiconNames` to `SynthesizeSpeech` / `StartSpeechSynthesisTask`.
33 */
34 lexiconName: string;
35 /**
36 * ARN of the lexicon, e.g. `arn:aws:polly:us-east-1:123456789012:lexicon/casing`.
37 */
38 lexiconArn: string;
39 /**
40 * Phonetic alphabet declared by the PLS document (`ipa` or `x-sampa`).
41 */
42 alphabet: string | undefined;
43 /**
44 * Language code declared by the PLS document, e.g. `en-US`.
45 */
46 languageCode: string | undefined;
47 /**
48 * Number of lexemes in the lexicon.
49 */
50 lexemesCount: number | undefined;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An Amazon Polly pronunciation lexicon — a W3C PLS document stored in a
58 * region that customizes how `SynthesizeSpeech` pronounces specific words.
59 * Identity is the region-scoped `lexiconName`; the PLS `content` is
60 * updatable in place.
61 *
62 * ### Managing Lexicons
63 * **Example:** Store a pronunciation lexicon
64 * ```typescript
65 * const lexicon = yield* AWS.Polly.Lexicon("Acronyms", {
66 * lexiconName: "acronyms",
67 * content: `<?xml version="1.0" encoding="UTF-8"?>
68 * <lexicon version="1.0" xmlns="http://www.w3.org/2005/01/pronunciation-lexicon"
69 * alphabet="ipa" xml:lang="en-US">
70 * <lexeme><grapheme>W3C</grapheme><alias>World Wide Web Consortium</alias></lexeme>
71 * </lexicon>`,
72 * });
73 * ```
74 *
75 * **Example:** Synthesize speech with the lexicon applied
76 * ```typescript
77 * const synthesizeSpeech = yield* AWS.Polly.SynthesizeSpeech();
78 * const result = yield* synthesizeSpeech({
79 * OutputFormat: "mp3",
80 * VoiceId: "Joanna",
81 * Text: "The W3C maintains the PLS standard.",
82 * LexiconNames: [lexicon.lexiconName],
83 * });
84 * ```
85 *
86 * @resource
87 */
88export const Lexicon = Resource<Lexicon>("AWS.Polly.Lexicon");
89
90/**
91 * Unwrap the lexicon `Content` (distilled types it as a `SensitiveString`,
92 * which decodes to `Redacted`) to its plain PLS XML string.
93 */
94const contentOf = (lexicon: polly.Lexicon | undefined): string | undefined => {
95 const content = lexicon?.Content;
96 return content === undefined
97 ? undefined
98 : Redacted.isRedacted(content)
99 ? Redacted.value(content)
100 : content;
101};
102
103const toAttributes = (
104 name: string,
105 arn: string,
106 attributes: polly.LexiconAttributes | undefined,
107) => ({
108 lexiconName: name,
109 lexiconArn: arn,
110 alphabet: attributes?.Alphabet,
111 languageCode: attributes?.LanguageCode,
112 lexemesCount: attributes?.LexemesCount,
113});
114
115export const LexiconProvider = () =>
116 Provider.effect(
117 Lexicon,
118 Effect.gen(function* () {
119 // Lexicon names must match [0-9A-Za-z]{1,20} — generate the engine
120 // name, drop every non-alphanumeric character, and keep the trailing
121 // 20 characters so the unique suffix survives truncation. The
122 // `typeof === "string"` guard (rather than truthiness) keeps this
123 // callable from `precreate`, whose props may still hold unresolved
124 // Output expressions.
125 const toName = (id: string, props: LexiconProps) =>
126 typeof props.lexiconName === "string"
127 ? Effect.succeed(props.lexiconName)
128 : createPhysicalName({ id, maxLength: 26, suffixLength: 10 }).pipe(
129 Effect.map((n) => n.replace(/[^0-9A-Za-z]/g, "").slice(-20)),
130 );
131
132 // Bounded retry for transient Polly control-plane failures. Under a
133 // full concurrent suite, GetLexicon/PutLexicon can answer with
134 // throttling or a transient 5xx; failing reconcile between PutLexicon
135 // and returning Attributes is exactly the window that orphans a
136 // lexicon, so ride these out instead of failing fast.
137 const retryTransient = <A, E extends { _tag: string }, R>(
138 effect: Effect.Effect<A, E, R>,
139 ): Effect.Effect<A, E, R> =>
140 Effect.retry(effect, {
141 while: (e) =>
142 e._tag === "ThrottlingException" ||
143 e._tag === "ServiceUnavailable" ||
144 e._tag === "ServiceFailureException",
145 schedule: Schedule.exponential("500 millis"),
146 times: 5,
147 });
148
149 const lexiconArn = (name: string) =>
150 Effect.gen(function* () {
151 const { accountId, region } = yield* AWSEnvironment.current;
152 return `arn:aws:polly:${region}:${accountId}:lexicon/${name}`;
153 });
154
155 const getOne = (name: string) =>
156 retryTransient(polly.getLexicon({ Name: name })).pipe(
157 Effect.catchTag("LexiconNotFoundException", () =>
158 Effect.succeed(undefined),
159 ),
160 );
161
162 const waitForContent = (name: string, desired: string) =>
163 Effect.gen(function* () {
164 let found: polly.GetLexiconOutput | undefined;
165 let consecutiveMatches = 0;
166 for (let attempt = 0; attempt < 20; attempt++) {
167 found = yield* getOne(name);
168 if (contentOf(found?.Lexicon) === desired) {
169 // Polly can transiently return a response whose Lexicon payload
170 // is absent immediately after PutLexicon. Require two stable
171 // reads before exposing the updated resource to dependants.
172 consecutiveMatches++;
173 if (consecutiveMatches === 2) return found;
174 } else {
175 consecutiveMatches = 0;
176 }
177 yield* Effect.sleep("500 millis");
178 }
179 return yield* Effect.fail(
180 new Error(
181 `Polly lexicon '${name}' did not converge to the desired content`,
182 ),
183 );
184 });
185
186 const waitUntilGone = (name: string) =>
187 Effect.gen(function* () {
188 let consecutiveMisses = 0;
189 for (let attempt = 0; attempt < 20; attempt++) {
190 const found = yield* getOne(name);
191 if (found === undefined) {
192 consecutiveMisses++;
193 if (consecutiveMisses === 2) return;
194 } else {
195 consecutiveMisses = 0;
196 }
197 yield* Effect.sleep("500 millis");
198 }
199 return yield* Effect.fail(
200 new Error(
201 `Polly lexicon '${name}' is still observable after deletion`,
202 ),
203 );
204 });
205
206 return {
207 stables: ["lexiconName", "lexiconArn"],
208 diff: Effect.fn(function* ({ id, olds, news }) {
209 if (!isResolved(news)) return;
210 if (
211 (yield* toName(id, olds ?? { content: "" })) !==
212 (yield* toName(id, news))
213 ) {
214 return { action: "replace" } as const;
215 }
216 }),
217 read: Effect.fn(function* ({ id, olds, output }) {
218 const name =
219 output?.lexiconName ?? (yield* toName(id, olds ?? { content: "" }));
220 const found = yield* getOne(name);
221 if (!found) return undefined;
222 return toAttributes(
223 name,
224 found.LexiconAttributes?.LexiconArn ?? (yield* lexiconArn(name)),
225 found.LexiconAttributes,
226 );
227 }),
228 list: () =>
229 Effect.gen(function* () {
230 const attrs: {
231 lexiconName: string;
232 lexiconArn: string;
233 alphabet: string | undefined;
234 languageCode: string | undefined;
235 lexemesCount: number | undefined;
236 }[] = [];
237 let nextToken: string | undefined;
238 // ListLexicons is not modeled as paginated by distilled; walk
239 // NextToken manually with a hard page bound.
240 for (let page = 0; page < 25; page++) {
241 const res = yield* polly.listLexicons({
242 NextToken: nextToken,
243 });
244 for (const lexicon of res.Lexicons ?? []) {
245 if (lexicon.Name) {
246 attrs.push(
247 toAttributes(
248 lexicon.Name,
249 lexicon.Attributes?.LexiconArn ??
250 (yield* lexiconArn(lexicon.Name)),
251 lexicon.Attributes,
252 ),
253 );
254 }
255 }
256 nextToken = res.NextToken;
257 if (!nextToken) break;
258 }
259 return attrs;
260 }),
261 // Persist the lexicon's deterministic identity BEFORE reconcile runs.
262 // The engine commits precreate's Attributes to state ahead of any
263 // API call, so if reconcile fails (or is interrupted) after
264 // PutLexicon succeeded, destroy still has `output.lexiconName` and
265 // deletes the lexicon instead of orphaning it — the engine skips
266 // provider.delete entirely for rows whose attr was never persisted.
267 // No API call is made here; delete tolerates the not-yet-created case.
268 precreate: Effect.fn(function* ({ id, news }) {
269 const name = yield* toName(id, news);
270 return toAttributes(name, yield* lexiconArn(name), undefined);
271 }),
272 reconcile: Effect.fn(function* ({ id, news, output, session }) {
273 // Prefer the resolved user-supplied name; fall back to the cached
274 // output identity (which for a fresh create is the precreate stub),
275 // then to the generated name.
276 const name =
277 typeof news.lexiconName === "string"
278 ? news.lexiconName
279 : (output?.lexiconName ?? (yield* toName(id, news)));
280
281 // Observe — cloud state is authoritative.
282 const observed = yield* getOne(name);
283
284 // Ensure + sync in one step: PutLexicon is a true upsert, so a
285 // single call converges both the missing and the content-drift
286 // cases. Skip the API entirely when the observed content matches.
287 if (contentOf(observed?.Lexicon) !== news.content) {
288 yield* retryTransient(
289 polly.putLexicon({ Name: name, Content: news.content }),
290 );
291 }
292
293 yield* session.note(name);
294 // PutLexicon can return before GetLexicon exposes the new document.
295 // Wait briefly for the exact desired content so callers never observe
296 // stale state immediately after a successful deploy.
297 const final = yield* waitForContent(name, news.content);
298 return toAttributes(
299 name,
300 final?.LexiconAttributes?.LexiconArn ?? (yield* lexiconArn(name)),
301 final?.LexiconAttributes,
302 );
303 }),
304 delete: Effect.fn(function* ({ output }) {
305 // Idempotent — the lexicon may already be gone (including the case
306 // where only the precreate stub was persisted and PutLexicon never
307 // ran).
308 yield* retryTransient(
309 polly.deleteLexicon({ Name: output.lexiconName }),
310 ).pipe(
311 Effect.catchTag("LexiconNotFoundException", () => Effect.void),
312 );
313 // DeleteLexicon may return before the control plane consistently
314 // reports absence. Confirm deletion so nuke cannot leave a lexicon
315 // behind and lose the only state that identifies it.
316 yield* waitUntilGone(output.lexiconName);
317 }),
318 };
319 }),
320 );