| 1 | import * as polly from "@distilled.cloud/aws/polly"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { AWSEnvironment } from "../Environment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | export 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 | |
| 26 | export 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 | */ |
| 88 | export 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 | */ |
| 94 | const 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 | |
| 103 | const 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 | |
| 115 | export 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 | ); |