import * as polly from "@distilled.cloud/aws/polly"; import * as Effect from "effect/Effect"; import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { AWSEnvironment } from "../Environment.ts"; import type { Providers } from "../Providers.ts"; export interface LexiconProps { /** * Name of the lexicon — an alphanumeric string up to 20 characters * (`[0-9A-Za-z]{1,20}`). If omitted, a unique name is generated. Changing * this replaces the lexicon. */ lexiconName?: string; /** * The lexicon content, a W3C Pronunciation Lexicon Specification (PLS) * XML document. Updatable in place — `PutLexicon` is a true upsert. */ content: string; } export interface Lexicon extends Resource< "AWS.Polly.Lexicon", LexiconProps, { /** * Name of the lexicon (its identity within the region). Pass it as * `LexiconNames` to `SynthesizeSpeech` / `StartSpeechSynthesisTask`. */ lexiconName: string; /** * ARN of the lexicon, e.g. `arn:aws:polly:us-east-1:123456789012:lexicon/casing`. */ lexiconArn: string; /** * Phonetic alphabet declared by the PLS document (`ipa` or `x-sampa`). */ alphabet: string | undefined; /** * Language code declared by the PLS document, e.g. `en-US`. */ languageCode: string | undefined; /** * Number of lexemes in the lexicon. */ lexemesCount: number | undefined; }, never, Providers > {} /** * An Amazon Polly pronunciation lexicon — a W3C PLS document stored in a * region that customizes how `SynthesizeSpeech` pronounces specific words. * Identity is the region-scoped `lexiconName`; the PLS `content` is * updatable in place. * * ### Managing Lexicons * **Example:** Store a pronunciation lexicon * ```typescript * const lexicon = yield* AWS.Polly.Lexicon("Acronyms", { * lexiconName: "acronyms", * content: ` * * W3CWorld Wide Web Consortium * `, * }); * ``` * * **Example:** Synthesize speech with the lexicon applied * ```typescript * const synthesizeSpeech = yield* AWS.Polly.SynthesizeSpeech(); * const result = yield* synthesizeSpeech({ * OutputFormat: "mp3", * VoiceId: "Joanna", * Text: "The W3C maintains the PLS standard.", * LexiconNames: [lexicon.lexiconName], * }); * ``` * * @resource */ export const Lexicon = Resource("AWS.Polly.Lexicon"); /** * Unwrap the lexicon `Content` (distilled types it as a `SensitiveString`, * which decodes to `Redacted`) to its plain PLS XML string. */ const contentOf = (lexicon: polly.Lexicon | undefined): string | undefined => { const content = lexicon?.Content; return content === undefined ? undefined : Redacted.isRedacted(content) ? Redacted.value(content) : content; }; const toAttributes = ( name: string, arn: string, attributes: polly.LexiconAttributes | undefined, ) => ({ lexiconName: name, lexiconArn: arn, alphabet: attributes?.Alphabet, languageCode: attributes?.LanguageCode, lexemesCount: attributes?.LexemesCount, }); export const LexiconProvider = () => Provider.effect( Lexicon, Effect.gen(function* () { // Lexicon names must match [0-9A-Za-z]{1,20} — generate the engine // name, drop every non-alphanumeric character, and keep the trailing // 20 characters so the unique suffix survives truncation. The // `typeof === "string"` guard (rather than truthiness) keeps this // callable from `precreate`, whose props may still hold unresolved // Output expressions. const toName = (id: string, props: LexiconProps) => typeof props.lexiconName === "string" ? Effect.succeed(props.lexiconName) : createPhysicalName({ id, maxLength: 26, suffixLength: 10 }).pipe( Effect.map((n) => n.replace(/[^0-9A-Za-z]/g, "").slice(-20)), ); // Bounded retry for transient Polly control-plane failures. Under a // full concurrent suite, GetLexicon/PutLexicon can answer with // throttling or a transient 5xx; failing reconcile between PutLexicon // and returning Attributes is exactly the window that orphans a // lexicon, so ride these out instead of failing fast. const retryTransient = ( effect: Effect.Effect, ): Effect.Effect => Effect.retry(effect, { while: (e) => e._tag === "ThrottlingException" || e._tag === "ServiceUnavailable" || e._tag === "ServiceFailureException", schedule: Schedule.exponential("500 millis"), times: 5, }); const lexiconArn = (name: string) => Effect.gen(function* () { const { accountId, region } = yield* AWSEnvironment.current; return `arn:aws:polly:${region}:${accountId}:lexicon/${name}`; }); const getOne = (name: string) => retryTransient(polly.getLexicon({ Name: name })).pipe( Effect.catchTag("LexiconNotFoundException", () => Effect.succeed(undefined), ), ); const waitForContent = (name: string, desired: string) => Effect.gen(function* () { let found: polly.GetLexiconOutput | undefined; let consecutiveMatches = 0; for (let attempt = 0; attempt < 20; attempt++) { found = yield* getOne(name); if (contentOf(found?.Lexicon) === desired) { // Polly can transiently return a response whose Lexicon payload // is absent immediately after PutLexicon. Require two stable // reads before exposing the updated resource to dependants. consecutiveMatches++; if (consecutiveMatches === 2) return found; } else { consecutiveMatches = 0; } yield* Effect.sleep("500 millis"); } return yield* Effect.fail( new Error( `Polly lexicon '${name}' did not converge to the desired content`, ), ); }); const waitUntilGone = (name: string) => Effect.gen(function* () { let consecutiveMisses = 0; for (let attempt = 0; attempt < 20; attempt++) { const found = yield* getOne(name); if (found === undefined) { consecutiveMisses++; if (consecutiveMisses === 2) return; } else { consecutiveMisses = 0; } yield* Effect.sleep("500 millis"); } return yield* Effect.fail( new Error( `Polly lexicon '${name}' is still observable after deletion`, ), ); }); return { stables: ["lexiconName", "lexiconArn"], diff: Effect.fn(function* ({ id, olds, news }) { if (!isResolved(news)) return; if ( (yield* toName(id, olds ?? { content: "" })) !== (yield* toName(id, news)) ) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ id, olds, output }) { const name = output?.lexiconName ?? (yield* toName(id, olds ?? { content: "" })); const found = yield* getOne(name); if (!found) return undefined; return toAttributes( name, found.LexiconAttributes?.LexiconArn ?? (yield* lexiconArn(name)), found.LexiconAttributes, ); }), list: () => Effect.gen(function* () { const attrs: { lexiconName: string; lexiconArn: string; alphabet: string | undefined; languageCode: string | undefined; lexemesCount: number | undefined; }[] = []; let nextToken: string | undefined; // ListLexicons is not modeled as paginated by distilled; walk // NextToken manually with a hard page bound. for (let page = 0; page < 25; page++) { const res = yield* polly.listLexicons({ NextToken: nextToken, }); for (const lexicon of res.Lexicons ?? []) { if (lexicon.Name) { attrs.push( toAttributes( lexicon.Name, lexicon.Attributes?.LexiconArn ?? (yield* lexiconArn(lexicon.Name)), lexicon.Attributes, ), ); } } nextToken = res.NextToken; if (!nextToken) break; } return attrs; }), // Persist the lexicon's deterministic identity BEFORE reconcile runs. // The engine commits precreate's Attributes to state ahead of any // API call, so if reconcile fails (or is interrupted) after // PutLexicon succeeded, destroy still has `output.lexiconName` and // deletes the lexicon instead of orphaning it — the engine skips // provider.delete entirely for rows whose attr was never persisted. // No API call is made here; delete tolerates the not-yet-created case. precreate: Effect.fn(function* ({ id, news }) { const name = yield* toName(id, news); return toAttributes(name, yield* lexiconArn(name), undefined); }), reconcile: Effect.fn(function* ({ id, news, output, session }) { // Prefer the resolved user-supplied name; fall back to the cached // output identity (which for a fresh create is the precreate stub), // then to the generated name. const name = typeof news.lexiconName === "string" ? news.lexiconName : (output?.lexiconName ?? (yield* toName(id, news))); // Observe — cloud state is authoritative. const observed = yield* getOne(name); // Ensure + sync in one step: PutLexicon is a true upsert, so a // single call converges both the missing and the content-drift // cases. Skip the API entirely when the observed content matches. if (contentOf(observed?.Lexicon) !== news.content) { yield* retryTransient( polly.putLexicon({ Name: name, Content: news.content }), ); } yield* session.note(name); // PutLexicon can return before GetLexicon exposes the new document. // Wait briefly for the exact desired content so callers never observe // stale state immediately after a successful deploy. const final = yield* waitForContent(name, news.content); return toAttributes( name, final?.LexiconAttributes?.LexiconArn ?? (yield* lexiconArn(name)), final?.LexiconAttributes, ); }), delete: Effect.fn(function* ({ output }) { // Idempotent — the lexicon may already be gone (including the case // where only the precreate stub was persisted and PutLexicon never // ran). yield* retryTransient( polly.deleteLexicon({ Name: output.lexiconName }), ).pipe( Effect.catchTag("LexiconNotFoundException", () => Effect.void), ); // DeleteLexicon may return before the control plane consistently // reports absence. Confirm deletion so nuke cannot leave a lexicon // behind and lose the only state that identifies it. yield* waitUntilGone(output.lexiconName); }), }; }), );