1import * as documentai from "@distilled.cloud/gcp/documentai_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_LOCATION,
13 DEFAULT_PROCESSOR_TYPE,
14 MAX_PROCESSOR_DISPLAY_NAME_LENGTH,
15 ProcessorFailed,
16 ProcessorPending,
17 ResourceNotResolved,
18 encodeOwnershipLine,
19 findOwnedByDisplayName,
20 hasOwnershipMarker,
21 listProcessorsAt,
22 listProjectProcessors,
23 locationParent,
24 normalizeLocation,
25 ownedByAlchemy,
26 parseOwnership,
27 parseResourceName,
28 replaceOnIdentity,
29 retryTransient,
30 sameText,
31 toPhysicalId,
32 waitUntilGone,
33} from "./internal.ts";
34import { waitForOperation } from "./operations.ts";
35
36export type ProcessorProps = {
37 /**
38 * Processor id (the `{processor}` segment of
39 * `projects/{project}/locations/{location}/processors/{processor}`).
40 * Assigned by the API on create. Immutable — changing it replaces the
41 * processor. Supply it to adopt an existing processor.
42 */
43 processorId?: string;
44 /**
45 * Multi-region location (`us` or `eu`). Some processor types are also
46 * available in regional locations. Immutable — changing it replaces the
47 * processor.
48 * @default "us"
49 */
50 location?: string;
51 /**
52 * Processor type such as `OCR_PROCESSOR` or `FORM_PARSER_PROCESSOR`.
53 * Immutable — changing it replaces the processor.
54 * @default "OCR_PROCESSOR"
55 */
56 type?: string;
57 /**
58 * User-facing display name. Processors have no labels field, so Alchemy
59 * stamps ownership into this field for list / nuke. There is no update
60 * API for display name — changing it replaces the processor.
61 */
62 displayName?: string;
63 /**
64 * Cloud KMS key used for CMEK. Immutable — changing it replaces the
65 * processor.
66 */
67 kmsKeyName?: string;
68 /**
69 * Default processor version resource name. Updates in place via
70 * `setDefaultProcessorVersion`.
71 */
72 defaultProcessorVersion?: string;
73 /**
74 * Schema version resource name used by the processor. Immutable —
75 * changing it replaces the processor.
76 */
77 activeSchemaVersion?: string;
78 /**
79 * Whether the processor is enabled. Created processors start enabled.
80 * Updates in place via enable / disable.
81 * @default true
82 */
83 enabled?: boolean;
84};
85
86export type Processor = Resource<
87 "GCP.DocumentAI.Processor",
88 ProcessorProps,
89 {
90 /** Full resource name `projects/{project}/locations/{location}/processors/{processor}`. */
91 name: string;
92 /** Processor id (last path segment). */
93 processorId: string;
94 /** Project id. */
95 project: string;
96 /** Location id. */
97 location: string;
98 /** Processor type. */
99 type: string | undefined;
100 /** User display name with the Alchemy ownership prefix stripped. */
101 displayName: string | undefined;
102 /** Processor state (`ENABLED`, `DISABLED`, …). */
103 state: string | undefined;
104 /** Default processor version resource name. */
105 defaultProcessorVersion: string | undefined;
106 /** HTTP endpoint that invokes processing. */
107 processEndpoint: string | undefined;
108 /** Cloud KMS key used for CMEK, if any. */
109 kmsKeyName: string | undefined;
110 /** Schema version resource name used by the processor. */
111 activeSchemaVersion: string | undefined;
112 /** RFC3339 creation timestamp. */
113 createTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Document AI processor that extracts structure from documents.
121 *
122 * Processors are location-scoped. The API assigns the processor id. Type,
123 * location, KMS key, and display name are immutable (there is no processor
124 * patch RPC). Enable / disable and the default processor version update in
125 * place. Processors have no labels, so Alchemy stamps ownership into
126 * `displayName` for `list` / nuke.
127 *
128 * ### Creating a Processor
129 * **Example:** OCR processor
130 * ```typescript
131 * const processor = yield* GCP.DocumentAI.Processor("Ocr", {
132 * type: "OCR_PROCESSOR",
133 * displayName: "ocr",
134 * });
135 * ```
136 *
137 * **Example:** Explicit location
138 * ```typescript
139 * const processor = yield* GCP.DocumentAI.Processor("Ocr", {
140 * location: "us",
141 * type: "OCR_PROCESSOR",
142 * });
143 * ```
144 *
145 * ### Updating a Processor
146 * **Example:** Disable
147 * ```typescript
148 * // Same logical id, changed props: the engine updates it in place.
149 * const processor = yield* GCP.DocumentAI.Processor("Ocr", {
150 * location: "us",
151 * type: "OCR_PROCESSOR",
152 * enabled: false,
153 * });
154 * ```
155 *
156 * @resource
157 * @category DocumentAI
158 */
159export const Processor = Resource<Processor>("GCP.DocumentAI.Processor");
160
161const resourceName = (project: string, location: string, processorId: string) =>
162 `${locationParent(project, location)}/processors/${processorId}`;
163
164const typeOf = (value: string | undefined) => value ?? DEFAULT_PROCESSOR_TYPE;
165
166const toAttrs = (
167 processor: documentai.GoogleCloudDocumentaiV1Processor,
168 project: string,
169) => {
170 const name = processor.name ?? "";
171 const parsed = parseResourceName(name, "processors");
172 const ownership = parseOwnership(processor.displayName);
173 return {
174 name,
175 processorId: parsed.id,
176 project: parsed.project || project,
177 location: parsed.location,
178 type: processor.type,
179 displayName: ownership.text,
180 state: processor.state,
181 defaultProcessorVersion: processor.defaultProcessorVersion,
182 processEndpoint: processor.processEndpoint,
183 kmsKeyName: processor.kmsKeyName,
184 activeSchemaVersion: processor.activeSchemaVersion,
185 createTime: processor.createTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : documentai
193 .getProjectsLocationsProcessors({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196const findOwned = (
197 id: string,
198 project: string,
199 parent: string,
200 hinted?: string,
201) =>
202 Effect.gen(function* () {
203 if (hinted !== undefined && hinted.length > 0) {
204 const existing = yield* getByName(hinted);
205 if (existing !== undefined) return existing;
206 }
207 const local = yield* findOwnedByDisplayName(
208 id,
209 yield* listProcessorsAt(parent),
210 );
211 if (local !== undefined) return local;
212 return yield* findOwnedByDisplayName(
213 id,
214 yield* listProjectProcessors(project),
215 );
216 });
217
218const waitUntilReady = (name: string) =>
219 getByName(name).pipe(
220 Effect.filterOrFail(
221 (processor): processor is documentai.GoogleCloudDocumentaiV1Processor =>
222 processor !== undefined,
223 () => new ResourceNotResolved({ name }),
224 ),
225 Effect.filterOrFail(
226 (processor) => processor.state !== "FAILED",
227 (processor) =>
228 new ProcessorFailed({
229 name,
230 state: processor.state ?? "STATE_UNSPECIFIED",
231 }),
232 ),
233 Effect.filterOrFail(
234 (processor) =>
235 processor.state === "ENABLED" || processor.state === "DISABLED",
236 (processor) =>
237 new ProcessorPending({
238 name,
239 state: processor.state ?? "STATE_UNSPECIFIED",
240 }),
241 ),
242 Effect.retry({
243 while: (error) =>
244 error instanceof ProcessorPending ||
245 error instanceof ResourceNotResolved,
246 times: 10,
247 schedule: Schedule.spaced("2 seconds"),
248 }),
249 );
250
251const waitUntilState = (name: string, desired: "ENABLED" | "DISABLED") =>
252 getByName(name).pipe(
253 Effect.filterOrFail(
254 (processor): processor is documentai.GoogleCloudDocumentaiV1Processor =>
255 processor !== undefined,
256 () => new ResourceNotResolved({ name }),
257 ),
258 Effect.filterOrFail(
259 (processor) => processor.state !== "FAILED",
260 (processor) =>
261 new ProcessorFailed({
262 name,
263 state: processor.state ?? "STATE_UNSPECIFIED",
264 }),
265 ),
266 Effect.filterOrFail(
267 (processor) => processor.state === desired,
268 (processor) =>
269 new ProcessorPending({
270 name,
271 state: processor.state ?? "STATE_UNSPECIFIED",
272 }),
273 ),
274 Effect.retry({
275 while: (error) =>
276 error instanceof ProcessorPending ||
277 error instanceof ResourceNotResolved,
278 times: 10,
279 schedule: Schedule.spaced("2 seconds"),
280 }),
281 );
282
283export const ProcessorProvider = () =>
284 Provider.succeed(Processor, {
285 stables: [
286 "name",
287 "processorId",
288 "project",
289 "location",
290 "type",
291 "createTime",
292 ],
293
294 diff: Effect.fn(function* ({ news, olds, output }) {
295 if (!isResolved(news)) return undefined;
296 const previousType = olds?.type ?? output?.type;
297 const nextType = typeOf(news.type);
298 const previousKms = olds?.kmsKeyName ?? output?.kmsKeyName;
299 const previousSchema =
300 olds?.activeSchemaVersion ?? output?.activeSchemaVersion;
301 const previousDisplay = olds?.displayName ?? output?.displayName;
302 const extra =
303 (previousType !== undefined && previousType !== nextType) ||
304 (previousKms ?? "") !== (news.kmsKeyName ?? "") ||
305 (previousSchema ?? "") !== (news.activeSchemaVersion ?? "") ||
306 (news.displayName !== undefined &&
307 previousDisplay !== undefined &&
308 news.displayName !== previousDisplay);
309 return replaceOnIdentity({
310 previousId: olds?.processorId ?? output?.processorId,
311 nextId: news.processorId,
312 previousLocation: olds?.location ?? output?.location,
313 nextLocation: news.location ?? olds?.location ?? output?.location,
314 extra,
315 });
316 }),
317
318 read: Effect.fn(function* ({ id, olds, output }) {
319 const env = yield* GcpEnvironment.current;
320 const location = normalizeLocation(olds?.location ?? output?.location);
321 const processorId = olds?.processorId ?? output?.processorId;
322 const name =
323 output?.name ??
324 (processorId ? resourceName(env.project, location, processorId) : "");
325 const parent = locationParent(env.project, location);
326 const existing = yield* findOwned(id, env.project, parent, name);
327 if (existing === undefined) return undefined;
328 const attrs = toAttrs(existing, env.project);
329 return (yield* ownedByAlchemy(id, existing.displayName))
330 ? attrs
331 : Unowned(attrs);
332 }),
333
334 list: () =>
335 Effect.gen(function* () {
336 const env = yield* GcpEnvironment.current;
337 const processors = yield* listProjectProcessors(env.project);
338 return processors
339 .filter((processor) => hasOwnershipMarker(processor.displayName))
340 .map((processor) => toAttrs(processor, env.project));
341 }),
342
343 reconcile: Effect.fn(function* ({ id, news, output }) {
344 const env = yield* GcpEnvironment.current;
345 const location = normalizeLocation(
346 news.location ?? output?.location ?? DEFAULT_LOCATION,
347 );
348 const parent = locationParent(env.project, location);
349 const ownership = yield* createInternalLabels(id);
350 const fallbackName = yield* toPhysicalId(
351 id,
352 undefined,
353 output?.displayName,
354 );
355 const displayName = encodeOwnershipLine(
356 ownership,
357 news.displayName ?? output?.displayName ?? fallbackName,
358 MAX_PROCESSOR_DISPLAY_NAME_LENGTH,
359 );
360 const type = typeOf(news.type);
361 const hinted =
362 output?.name ??
363 (news.processorId
364 ? resourceName(env.project, location, news.processorId)
365 : "");
366
367 let current = yield* findOwned(id, env.project, parent, hinted);
368
369 if (current === undefined) {
370 current = yield* retryTransient(
371 documentai.createProjectsLocationsProcessors({
372 parent,
373 body: {
374 type,
375 displayName,
376 kmsKeyName: news.kmsKeyName,
377 defaultProcessorVersion: news.defaultProcessorVersion,
378 activeSchemaVersion: news.activeSchemaVersion,
379 },
380 }),
381 ).pipe(
382 Effect.catchTag("Conflict", (error) =>
383 findOwned(id, env.project, parent, hinted).pipe(
384 Effect.flatMap((found) =>
385 found !== undefined
386 ? Effect.succeed(found)
387 : Effect.fail(error),
388 ),
389 ),
390 ),
391 );
392 }
393
394 if (current === undefined) {
395 return yield* new ResourceNotResolved({
396 name: hinted || `${parent}/processors`,
397 });
398 }
399
400 let live = current;
401 const currentName = live.name ?? hinted;
402 if (live.state !== "ENABLED" && live.state !== "DISABLED") {
403 live = yield* waitUntilReady(currentName);
404 }
405
406 const wantEnabled = news.enabled !== false;
407 if (wantEnabled && live.state === "DISABLED") {
408 const operation = yield* retryTransient(
409 documentai.enableProjectsLocationsProcessors({
410 name: currentName,
411 body: {},
412 }),
413 );
414 yield* waitForOperation(operation);
415 live = yield* waitUntilState(currentName, "ENABLED");
416 } else if (!wantEnabled && live.state === "ENABLED") {
417 const operation = yield* retryTransient(
418 documentai.disableProjectsLocationsProcessors({
419 name: currentName,
420 body: {},
421 }),
422 );
423 yield* waitForOperation(operation);
424 live = yield* waitUntilState(currentName, "DISABLED");
425 }
426
427 if (
428 news.defaultProcessorVersion !== undefined &&
429 !sameText(live.defaultProcessorVersion, news.defaultProcessorVersion)
430 ) {
431 const operation = yield* retryTransient(
432 documentai.setDefaultProcessorVersionProjectsLocationsProcessors({
433 processor: currentName,
434 body: {
435 defaultProcessorVersion: news.defaultProcessorVersion,
436 },
437 }),
438 );
439 yield* waitForOperation(operation);
440 live = (yield* getByName(currentName)) ?? live;
441 }
442
443 return toAttrs(live, env.project);
444 }),
445
446 delete: Effect.fn(function* ({ output }) {
447 if (!output.name) return;
448 const operation = yield* retryTransient(
449 documentai.deleteProjectsLocationsProcessors({
450 name: output.name,
451 }),
452 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
453 if (operation !== undefined) {
454 // NOT_FOUND (5): already gone.
455 yield* waitForOperation(operation).pipe(
456 Effect.catchTag("GCP.OperationFailed", (error) =>
457 error.code === 5 ? Effect.void : Effect.fail(error),
458 ),
459 );
460 }
461 yield* waitUntilGone(getByName(output.name), output.name);
462 }),
463 });