1import * as textract from "@distilled.cloud/aws/textract";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags } from "../../Tags.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface AdapterProps {
13 /**
14 * Name of the adapter — 1-128 characters matching `[a-zA-Z0-9-_]+`. If
15 * omitted, a unique name is generated. The name is updatable in place
16 * (adapter identity is the server-assigned `adapterId`).
17 */
18 adapterName?: string;
19 /**
20 * Description of the adapter, updatable in place. Once set, the Textract
21 * API cannot clear it back to empty — removing this prop leaves the last
22 * description in place.
23 */
24 description?: string;
25 /**
26 * Feature types the adapter enhances. Textract currently supports only
27 * `["QUERIES"]`. Changing this replaces the adapter.
28 */
29 featureTypes: textract.FeatureType[];
30 /**
31 * Whether Textract automatically retrains the adapter as the base model
32 * improves (`ENABLED` or `DISABLED`).
33 * @default "DISABLED"
34 */
35 autoUpdate?: textract.AutoUpdate;
36 /**
37 * Tags to apply to the adapter. Internal alchemy ownership tags are always
38 * added.
39 */
40 tags?: Record<string, string>;
41}
42
43export interface Adapter extends Resource<
44 "AWS.Textract.Adapter",
45 AdapterProps,
46 {
47 /**
48 * Server-assigned identifier of the adapter (its identity). Pass it in
49 * `AdaptersConfig` to `AnalyzeDocument` / `StartDocumentAnalysis`.
50 */
51 adapterId: string;
52 /**
53 * ARN of the adapter. Textract uses a nonstandard resource path:
54 * `arn:aws:textract:{region}:{account}:/adapters/{adapterId}`.
55 */
56 adapterArn: string;
57 /**
58 * Name of the adapter.
59 */
60 adapterName: string;
61 /**
62 * Feature types the adapter enhances (currently only `QUERIES`).
63 */
64 featureTypes: string[];
65 /**
66 * Whether the adapter auto-updates as the base model improves.
67 */
68 autoUpdate: string | undefined;
69 /**
70 * Creation time of the adapter as an ISO-8601 string.
71 */
72 creationTime: string | undefined;
73 },
74 never,
75 Providers
76> {}
77
78/**
79 * An Amazon Textract adapter — a container for custom, trained adapter
80 * versions that enhance the pre-trained Queries feature for your specific
81 * documents. The adapter itself is cheap metadata (name, feature types,
82 * auto-update, tags); versions are trained separately with
83 * `CreateAdapterVersion` against an annotated dataset.
84 *
85 * ### Managing Adapters
86 * **Example:** Create an adapter for the Queries feature
87 * ```typescript
88 * const adapter = yield* AWS.Textract.Adapter("InvoiceAdapter", {
89 * featureTypes: ["QUERIES"],
90 * description: "Tuned for invoice layouts",
91 * autoUpdate: "ENABLED",
92 * });
93 * ```
94 *
95 * **Example:** Analyze a document with a trained adapter version
96 * ```typescript
97 * const analyzeDocument = yield* AWS.Textract.AnalyzeDocument();
98 * const result = yield* analyzeDocument({
99 * Document: { S3Object: { Bucket: bucketName, Name: "invoice.pdf" } },
100 * FeatureTypes: ["QUERIES"],
101 * QueriesConfig: { Queries: [{ Text: "What is the invoice total?" }] },
102 * AdaptersConfig: {
103 * Adapters: [{ AdapterId: adapter.adapterId, Version: "1" }],
104 * },
105 * });
106 * ```
107 *
108 * @resource
109 */
110export const Adapter = Resource<Adapter>("AWS.Textract.Adapter");
111
112const toAttributes = (
113 arn: string,
114 adapter: textract.GetAdapterResponse & { AdapterId: string },
115) => ({
116 adapterId: adapter.AdapterId,
117 adapterArn: arn,
118 adapterName: adapter.AdapterName ?? "",
119 featureTypes: [...(adapter.FeatureTypes ?? [])],
120 autoUpdate: adapter.AutoUpdate,
121 creationTime: adapter.CreationTime?.toISOString(),
122});
123
124// Textract's adapter management APIs have very low default rates (~1 TPS);
125// bounded backoff absorbs the short bursts the engine's read/diff/reconcile
126// sequence emits.
127const throttleRetry = <A, E extends { readonly _tag: string }, R>(
128 effect: Effect.Effect<A, E, R>,
129) =>
130 effect.pipe(
131 Effect.retry({
132 while: (e): boolean =>
133 e._tag === "ProvisionedThroughputExceededException" ||
134 e._tag === "ThrottlingException",
135 schedule: Schedule.exponential("1 second"),
136 times: 5,
137 }),
138 );
139
140const normalizeTags = (
141 tags: { [key: string]: string | undefined } | undefined,
142): Record<string, string> =>
143 Object.fromEntries(
144 Object.entries(tags ?? {}).filter(
145 (entry): entry is [string, string] => entry[1] !== undefined,
146 ),
147 );
148
149export const AdapterProvider = () =>
150 Provider.effect(
151 Adapter,
152 Effect.gen(function* () {
153 // Adapter names must match [a-zA-Z0-9-_]{1,128}; the engine-generated
154 // physical name (alphanumerics + dashes) already satisfies it.
155 const toName = (id: string, props: AdapterProps) =>
156 props.adapterName
157 ? Effect.succeed(props.adapterName)
158 : createPhysicalName({ id, maxLength: 128 });
159
160 // Confirmed live: Textract adapter ARNs use a nonstandard resource
161 // path with a leading slash — `:/adapters/{adapterId}`.
162 const adapterArn = (adapterId: string) =>
163 Effect.gen(function* () {
164 const { accountId, region } = yield* AWSEnvironment.current;
165 return `arn:aws:textract:${region}:${accountId}:/adapters/${adapterId}`;
166 });
167
168 const getOne = (adapterId: string) =>
169 throttleRetry(textract.getAdapter({ AdapterId: adapterId })).pipe(
170 Effect.map((adapter) => ({ ...adapter, AdapterId: adapterId })),
171 Effect.catchTag("ResourceNotFoundException", () =>
172 Effect.succeed(undefined),
173 ),
174 );
175
176 // Adapter ids are server-assigned, so recovery without persisted
177 // output falls back to a bounded name scan.
178 const findByName = (name: string) =>
179 Effect.gen(function* () {
180 let nextToken: string | undefined;
181 for (let page = 0; page < 25; page++) {
182 const res = yield* throttleRetry(
183 textract.listAdapters({ NextToken: nextToken }),
184 );
185 const match = (res.Adapters ?? []).find(
186 (a) => a.AdapterName === name,
187 );
188 if (match?.AdapterId) return match.AdapterId;
189 nextToken = res.NextToken;
190 if (!nextToken) break;
191 }
192 return undefined;
193 });
194
195 return {
196 stables: ["adapterId", "adapterArn", "creationTime"],
197 diff: Effect.fn(function* ({ olds, news }) {
198 if (!isResolved(news)) return;
199 const oldFeatures = [...(olds?.featureTypes ?? [])].sort();
200 const newFeatures = [...news.featureTypes].sort();
201 if (
202 oldFeatures.length !== newFeatures.length ||
203 oldFeatures.some((f, i) => f !== newFeatures[i])
204 ) {
205 return { action: "replace" } as const;
206 }
207 }),
208 read: Effect.fn(function* ({ id, olds, output }) {
209 const adapterId =
210 output?.adapterId ??
211 (yield* findByName(
212 yield* toName(id, olds ?? { featureTypes: [] }),
213 ));
214 if (!adapterId) return undefined;
215 const found = yield* getOne(adapterId);
216 if (!found) return undefined;
217 return toAttributes(yield* adapterArn(adapterId), found);
218 }),
219 list: () =>
220 Effect.gen(function* () {
221 const attrs: ReturnType<typeof toAttributes>[] = [];
222 let nextToken: string | undefined;
223 for (let page = 0; page < 25; page++) {
224 const res = yield* throttleRetry(
225 textract.listAdapters({ NextToken: nextToken }),
226 );
227 for (const overview of res.Adapters ?? []) {
228 if (overview.AdapterId) {
229 const found = yield* getOne(overview.AdapterId);
230 if (found) {
231 attrs.push(
232 toAttributes(
233 yield* adapterArn(overview.AdapterId),
234 found,
235 ),
236 );
237 }
238 }
239 }
240 nextToken = res.NextToken;
241 if (!nextToken) break;
242 }
243 return attrs;
244 }),
245 reconcile: Effect.fn(function* ({ id, news, output, session }) {
246 const name = yield* toName(id, news);
247 const internalTags = yield* createInternalTags(id);
248 const desiredTags = { ...news.tags, ...internalTags };
249
250 // Observe — cloud state is authoritative; `output` is only a
251 // cached id hint and may be stale.
252 let adapterId = output?.adapterId ?? (yield* findByName(name));
253 let observed = adapterId ? yield* getOne(adapterId) : undefined;
254
255 // Ensure — create when missing; a ConflictException means another
256 // writer won the race, so fall through to the name lookup.
257 if (!observed) {
258 const created = yield* throttleRetry(
259 textract.createAdapter({
260 AdapterName: name,
261 FeatureTypes: news.featureTypes,
262 Description: news.description,
263 AutoUpdate: news.autoUpdate,
264 Tags: desiredTags,
265 }),
266 ).pipe(
267 Effect.catchTag("ConflictException", () =>
268 Effect.succeed(undefined),
269 ),
270 );
271 adapterId = created?.AdapterId ?? (yield* findByName(name));
272 if (!adapterId) {
273 return yield* Effect.die(
274 new Error(
275 `Textract adapter ${name} was neither created nor found`,
276 ),
277 );
278 }
279 observed = yield* getOne(adapterId);
280 }
281
282 yield* session.note(adapterId!);
283 const arn = yield* adapterArn(adapterId!);
284
285 // Sync mutable settings — diff observed against desired and apply
286 // only the delta. UpdateAdapter treats omitted fields as unchanged.
287 const update: textract.UpdateAdapterRequest = {
288 AdapterId: adapterId!,
289 };
290 let dirty = false;
291 if (observed?.AdapterName !== name) {
292 update.AdapterName = name;
293 dirty = true;
294 }
295 if (
296 news.description !== undefined &&
297 observed?.Description !== news.description
298 ) {
299 update.Description = news.description;
300 dirty = true;
301 }
302 if (
303 news.autoUpdate !== undefined &&
304 observed?.AutoUpdate !== news.autoUpdate
305 ) {
306 update.AutoUpdate = news.autoUpdate;
307 dirty = true;
308 }
309 if (dirty) {
310 yield* throttleRetry(textract.updateAdapter(update));
311 }
312
313 // Sync tags — diff against the OBSERVED cloud tags (adoption may
314 // hand us foreign tags), not olds/output.
315 const { removed, upsert } = diffTags(
316 normalizeTags(observed?.Tags),
317 desiredTags,
318 );
319 if (upsert.length > 0) {
320 yield* throttleRetry(
321 textract.tagResource({
322 ResourceARN: arn,
323 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
324 }),
325 );
326 }
327 if (removed.length > 0) {
328 yield* throttleRetry(
329 textract.untagResource({
330 ResourceARN: arn,
331 TagKeys: removed,
332 }),
333 );
334 }
335
336 const final = yield* getOne(adapterId!);
337 return toAttributes(
338 arn,
339 final ?? { AdapterId: adapterId!, AdapterName: name },
340 );
341 }),
342 delete: Effect.fn(function* ({ output }) {
343 // Idempotent — the adapter may already be gone.
344 yield* throttleRetry(
345 textract.deleteAdapter({ AdapterId: output.adapterId }),
346 ).pipe(
347 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
348 );
349 }),
350 };
351 }),
352 );