1import * as documentai from "@distilled.cloud/gcp/documentai_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13 toLabels,
14} from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 DEFAULT_LOCATION,
18 ResourceNotResolved,
19 findOwnedByLabels,
20 hasAlchemyLabelMap,
21 listProjectSchemas,
22 listSchemasAt,
23 locationParent,
24 normalizeLocation,
25 parseResourceName,
26 replaceOnIdentity,
27 retryTransient,
28 sameText,
29 toPhysicalId,
30 updateMaskOf,
31 userLabels,
32 waitUntilGone,
33} from "./internal.ts";
34import { waitForOperation } from "./operations.ts";
35
36export type SchemaProps = {
37 /**
38 * Schema id (the `{schema}` segment of
39 * `projects/{project}/locations/{location}/schemas/{schema}`). Assigned
40 * by the API on create. Immutable — changing it replaces the schema.
41 * Supply it to adopt an existing schema.
42 */
43 schemaId?: string;
44 /**
45 * Multi-region location (`us` or `eu`). Immutable — changing it
46 * replaces the schema.
47 * @default "us"
48 */
49 location?: string;
50 /**
51 * User-defined display name. Defaults to a generated id.
52 */
53 displayName?: string;
54 /**
55 * User labels. Alchemy ownership labels are merged in automatically.
56 */
57 labels?: Record<string, string>;
58};
59
60export type Schema = Resource<
61 "GCP.DocumentAI.Schema",
62 SchemaProps,
63 {
64 /** Full resource name `projects/{project}/locations/{location}/schemas/{schema}`. */
65 name: string;
66 /** Schema id (last path segment). */
67 schemaId: string;
68 /** Project id. */
69 project: string;
70 /** Location id. */
71 location: string;
72 /** User-defined display name. */
73 displayName: string | undefined;
74 /** User labels (Alchemy ownership labels stripped). */
75 labels: Record<string, string>;
76 /** RFC3339 creation timestamp. */
77 createTime: string | undefined;
78 /** RFC3339 last-update timestamp. */
79 updateTime: string | undefined;
80 },
81 never,
82 Providers
83>;
84
85/**
86 * A Document AI schema — a collection of schema versions describing
87 * extraction output.
88 *
89 * Schemas are location-scoped. The API assigns the schema id. Location is
90 * immutable; display name and labels update in place.
91 *
92 * ### Creating a Schema
93 * **Example:** Generated id
94 * ```typescript
95 * const schema = yield* GCP.DocumentAI.Schema("Invoice", {
96 * displayName: "invoice",
97 * labels: { env: "test" },
98 * });
99 * ```
100 *
101 * ### Updating a Schema
102 * **Example:** Rename and relabel
103 * ```typescript
104 * // Same logical id, changed props: the engine updates it in place.
105 * const schema = yield* GCP.DocumentAI.Schema("Invoice", {
106 * displayName: "invoice-v2",
107 * labels: { env: "prod" },
108 * });
109 * ```
110 *
111 * @resource
112 * @category DocumentAI
113 */
114export const Schema = Resource<Schema>("GCP.DocumentAI.Schema");
115
116const resourceName = (project: string, location: string, schemaId: string) =>
117 `${locationParent(project, location)}/schemas/${schemaId}`;
118
119const toAttrs = (
120 schema: documentai.GoogleCloudDocumentaiV1NextSchema,
121 project: string,
122) => {
123 const name = schema.name ?? "";
124 const parsed = parseResourceName(name, "schemas");
125 return {
126 name,
127 schemaId: parsed.id,
128 project: parsed.project || project,
129 location: parsed.location,
130 displayName: schema.displayName,
131 labels: userLabels(schema.labels),
132 createTime: schema.createTime,
133 updateTime: schema.updateTime,
134 };
135};
136
137const getByName = (name: string) =>
138 name.length === 0
139 ? Effect.succeed(undefined)
140 : documentai
141 .getProjectsLocationsSchemas({ name })
142 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
143
144const findOwned = (
145 id: string,
146 project: string,
147 parent: string,
148 hinted?: string,
149) =>
150 Effect.gen(function* () {
151 if (hinted !== undefined && hinted.length > 0) {
152 const existing = yield* getByName(hinted);
153 if (existing !== undefined) return existing;
154 }
155 const local = yield* findOwnedByLabels(id, yield* listSchemasAt(parent));
156 if (local !== undefined) return local;
157 return yield* findOwnedByLabels(id, yield* listProjectSchemas(project));
158 });
159
160export const SchemaProvider = () =>
161 Provider.succeed(Schema, {
162 stables: ["name", "schemaId", "project", "location", "createTime"],
163
164 diff: Effect.fn(function* ({ news, olds, output }) {
165 if (!isResolved(news)) return undefined;
166 return replaceOnIdentity({
167 previousId: olds?.schemaId ?? output?.schemaId,
168 nextId: news.schemaId,
169 previousLocation: olds?.location ?? output?.location,
170 nextLocation: news.location ?? olds?.location ?? output?.location,
171 });
172 }),
173
174 read: Effect.fn(function* ({ id, olds, output }) {
175 const env = yield* GcpEnvironment.current;
176 const location = normalizeLocation(olds?.location ?? output?.location);
177 const schemaId = olds?.schemaId ?? output?.schemaId;
178 const name =
179 output?.name ??
180 (schemaId ? resourceName(env.project, location, schemaId) : "");
181 const parent = locationParent(env.project, location);
182 const existing = yield* findOwned(id, env.project, parent, name);
183 if (existing === undefined) return undefined;
184 const attrs = toAttrs(existing, env.project);
185 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
186 ? attrs
187 : Unowned(attrs);
188 }),
189
190 list: () =>
191 Effect.gen(function* () {
192 const env = yield* GcpEnvironment.current;
193 const schemas = yield* listProjectSchemas(env.project);
194 return schemas
195 .filter((schema) => hasAlchemyLabelMap(schema.labels))
196 .map((schema) => toAttrs(schema, env.project));
197 }),
198
199 reconcile: Effect.fn(function* ({ id, news, output }) {
200 const env = yield* GcpEnvironment.current;
201 const location = normalizeLocation(
202 news.location ?? output?.location ?? DEFAULT_LOCATION,
203 );
204 const parent = locationParent(env.project, location);
205 const desiredLabels = {
206 ...toLabels(news.labels),
207 ...(yield* createInternalLabels(id)),
208 };
209 const fallbackName = yield* toPhysicalId(
210 id,
211 undefined,
212 output?.displayName ?? output?.schemaId,
213 );
214 const displayName = news.displayName ?? fallbackName;
215 const hinted =
216 output?.name ??
217 (news.schemaId
218 ? resourceName(env.project, location, news.schemaId)
219 : "");
220
221 let current = yield* findOwned(id, env.project, parent, hinted);
222
223 if (current === undefined) {
224 current = yield* retryTransient(
225 documentai.createProjectsLocationsSchemas({
226 parent,
227 body: {
228 displayName,
229 labels: desiredLabels,
230 },
231 }),
232 ).pipe(
233 Effect.catchTag("Conflict", (error) =>
234 findOwned(id, env.project, parent, hinted).pipe(
235 Effect.flatMap((found) =>
236 found !== undefined
237 ? Effect.succeed(found)
238 : Effect.fail(error),
239 ),
240 ),
241 ),
242 );
243 }
244
245 if (current === undefined) {
246 return yield* new ResourceNotResolved({
247 name: hinted || `${parent}/schemas`,
248 });
249 }
250
251 const currentName = current.name ?? hinted;
252 const observedLabels = tagRecord(current.labels);
253 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
254 const labelsChanged = upsert.length > 0 || removed.length > 0;
255 const displayChanged = !sameText(current.displayName, displayName);
256
257 if (labelsChanged || displayChanged) {
258 current = yield* retryTransient(
259 documentai.patchProjectsLocationsSchemas({
260 name: currentName,
261 updateMask: updateMaskOf(
262 displayChanged ? "displayName" : undefined,
263 labelsChanged ? "labels" : undefined,
264 ),
265 body: {
266 displayName,
267 labels: desiredLabels,
268 },
269 }),
270 );
271 }
272
273 return toAttrs(current, env.project);
274 }),
275
276 delete: Effect.fn(function* ({ output }) {
277 if (!output.name) return;
278 const operation = yield* retryTransient(
279 documentai.deleteProjectsLocationsSchemas({
280 name: output.name,
281 force: true,
282 }),
283 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
284 if (operation !== undefined) {
285 // NOT_FOUND (5): already gone.
286 yield* waitForOperation(operation).pipe(
287 Effect.catchTag("GCP.OperationFailed", (error) =>
288 error.code === 5 ? Effect.void : Effect.fail(error),
289 ),
290 );
291 }
292 yield* waitUntilGone(getByName(output.name), output.name);
293 }),
294 });