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 expandParent,
20 findOwnedByLabels,
21 hasAlchemyLabelMap,
22 listProjectSchemas,
23 listSchemaVersionsAt,
24 locationParent,
25 normalizeLocation,
26 parseResourceName,
27 replaceOnIdentity,
28 retryTransient,
29 sameJson,
30 sameText,
31 toPhysicalId,
32 updateMaskOf,
33 userLabels,
34 waitUntilGone,
35} from "./internal.ts";
36import { waitForOperation } from "./operations.ts";
37
38export type DocumentSchemaSpec =
39 documentai.GoogleCloudDocumentaiV1DocumentSchema;
40
41export type SchemasSchemaVersionProps = {
42 /**
43 * Parent schema. Full name
44 * `projects/{project}/locations/{location}/schemas/{schema}` or the
45 * schema id (combined with `location`). Immutable — changing it
46 * replaces the schema version.
47 */
48 schema: string;
49 /**
50 * Region used when `schema` is a bare id.
51 * @default "us"
52 */
53 location?: string;
54 /**
55 * Schema version id (the `{schemaVersion}` segment). Assigned by the
56 * API on create. Immutable — changing it replaces the version. Supply
57 * it to adopt an existing version.
58 */
59 schemaVersionId?: string;
60 /**
61 * User-defined display name. Defaults to a generated id.
62 */
63 displayName?: string;
64 /**
65 * Document schema describing extraction output. Immutable — changing it
66 * replaces the schema version.
67 */
68 documentSchema: DocumentSchemaSpec;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73};
74
75export type SchemasSchemaVersion = Resource<
76 "GCP.DocumentAI.SchemasSchemaVersion",
77 SchemasSchemaVersionProps,
78 {
79 /** Full resource name `.../schemas/{schema}/schemaVersions/{schemaVersion}`. */
80 name: string;
81 /** Schema version id. */
82 schemaVersionId: string;
83 /** Parent schema resource name. */
84 schema: string;
85 /** Project id. */
86 project: string;
87 /** Location id. */
88 location: string;
89 /** User-defined display name. */
90 displayName: string | undefined;
91 /** Document schema describing extraction output. */
92 documentSchema: DocumentSchemaSpec | undefined;
93 /** User labels (Alchemy ownership labels stripped). */
94 labels: Record<string, string>;
95 /** RFC3339 creation timestamp. */
96 createTime: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A Document AI schema version — an immutable snapshot of a document
104 * schema under a Schema.
105 *
106 * Parent schema, version id, and the document schema body are immutable.
107 * Display name and labels update in place.
108 *
109 * ### Creating a Schema Version
110 * **Example:** Invoice schema version
111 * ```typescript
112 * const version = yield* GCP.DocumentAI.SchemasSchemaVersion("V1", {
113 * schema: schema.name,
114 * displayName: "v1",
115 * documentSchema: {
116 * displayName: "invoice",
117 * entityTypes: [
118 * {
119 * name: "invoice",
120 * baseTypes: ["document"],
121 * properties: [
122 * {
123 * name: "invoice_id",
124 * valueType: "string",
125 * occurrenceType: "OPTIONAL_ONCE",
126 * },
127 * ],
128 * },
129 * ],
130 * },
131 * });
132 * ```
133 *
134 * @resource
135 * @category DocumentAI
136 */
137export const SchemasSchemaVersion = Resource<SchemasSchemaVersion>(
138 "GCP.DocumentAI.SchemasSchemaVersion",
139);
140
141const schemaOf = (schema: string, project: string, location: string) =>
142 expandParent(schema, project, location, "schemas");
143
144const resourceName = (schema: string, schemaVersionId: string) =>
145 `${schema}/schemaVersions/${schemaVersionId}`;
146
147const toAttrs = (
148 version: documentai.GoogleCloudDocumentaiV1SchemaVersion,
149 project: string,
150) => {
151 const name = version.name ?? "";
152 const parsed = parseResourceName(name, "schemaVersions");
153 return {
154 name,
155 schemaVersionId: parsed.id,
156 schema: parsed.parent,
157 project: parsed.project || project,
158 location: parsed.location,
159 displayName: version.displayName,
160 documentSchema: version.schema,
161 labels: userLabels(version.labels),
162 createTime: version.createTime,
163 };
164};
165
166const getByName = (name: string) =>
167 name.length === 0
168 ? Effect.succeed(undefined)
169 : documentai
170 .getProjectsLocationsSchemasSchemaVersions({ name })
171 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
172
173const listOwnedVersions = (project: string) =>
174 Effect.gen(function* () {
175 const schemas = yield* listProjectSchemas(project);
176 const groups = yield* Effect.forEach(
177 schemas,
178 (schema) => listSchemaVersionsAt(schema.name ?? ""),
179 { concurrency: 2 },
180 );
181 return groups.flat();
182 });
183
184const findOwned = (
185 id: string,
186 project: string,
187 parent: string,
188 hinted?: string,
189) =>
190 Effect.gen(function* () {
191 if (hinted !== undefined && hinted.length > 0) {
192 const existing = yield* getByName(hinted);
193 if (existing !== undefined) return existing;
194 }
195 const local = yield* findOwnedByLabels(
196 id,
197 yield* listSchemaVersionsAt(parent),
198 );
199 if (local !== undefined) return local;
200 return yield* findOwnedByLabels(id, yield* listOwnedVersions(project));
201 });
202
203export const SchemasSchemaVersionProvider = () =>
204 Provider.succeed(SchemasSchemaVersion, {
205 stables: [
206 "name",
207 "schemaVersionId",
208 "schema",
209 "project",
210 "location",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const env = yield* GcpEnvironment.current;
217 const location = normalizeLocation(
218 news.location ?? output?.location ?? DEFAULT_LOCATION,
219 );
220 const nextParent = schemaOf(news.schema, env.project, location);
221 const previousParent = olds?.schema ?? output?.schema;
222 const previousSchema = olds?.documentSchema ?? output?.documentSchema;
223 const extra =
224 (previousParent !== undefined && previousParent !== nextParent) ||
225 (previousSchema !== undefined &&
226 !sameJson(previousSchema, news.documentSchema));
227 return replaceOnIdentity({
228 previousId: olds?.schemaVersionId ?? output?.schemaVersionId,
229 nextId: news.schemaVersionId,
230 previousLocation: olds?.location ?? output?.location,
231 nextLocation: news.location ?? olds?.location ?? output?.location,
232 extra,
233 });
234 }),
235
236 read: Effect.fn(function* ({ id, olds, output }) {
237 const env = yield* GcpEnvironment.current;
238 const location = normalizeLocation(olds?.location ?? output?.location);
239 const schemaVersionId = olds?.schemaVersionId ?? output?.schemaVersionId;
240 const schema =
241 olds?.schema !== undefined
242 ? schemaOf(olds.schema, env.project, location)
243 : (output?.schema ?? "");
244 const name =
245 output?.name ??
246 (schema.length > 0 && schemaVersionId
247 ? resourceName(schema, schemaVersionId)
248 : "");
249 const existing = yield* findOwned(id, env.project, schema, name);
250 if (existing === undefined) return undefined;
251 const attrs = toAttrs(existing, env.project);
252 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
253 ? attrs
254 : Unowned(attrs);
255 }),
256
257 list: () =>
258 Effect.gen(function* () {
259 const env = yield* GcpEnvironment.current;
260 const versions = yield* listOwnedVersions(env.project);
261 return versions
262 .filter((version) => hasAlchemyLabelMap(version.labels))
263 .map((version) => toAttrs(version, env.project));
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const env = yield* GcpEnvironment.current;
268 const location = normalizeLocation(
269 news.location ?? output?.location ?? DEFAULT_LOCATION,
270 );
271 const schema = schemaOf(news.schema, env.project, location);
272 const desiredLabels = {
273 ...toLabels(news.labels),
274 ...(yield* createInternalLabels(id)),
275 };
276 const fallbackName = yield* toPhysicalId(
277 id,
278 undefined,
279 output?.displayName ?? output?.schemaVersionId,
280 );
281 const displayName = news.displayName ?? fallbackName;
282 const hinted =
283 output?.name ??
284 (news.schemaVersionId
285 ? resourceName(schema, news.schemaVersionId)
286 : "");
287
288 let current = yield* findOwned(id, env.project, schema, hinted);
289
290 if (current === undefined) {
291 current = yield* retryTransient(
292 documentai.createProjectsLocationsSchemasSchemaVersions({
293 parent: schema,
294 body: {
295 displayName,
296 labels: desiredLabels,
297 schema: news.documentSchema,
298 },
299 }),
300 ).pipe(
301 Effect.catchTag("Conflict", (error) =>
302 findOwned(id, env.project, schema, hinted).pipe(
303 Effect.flatMap((found) =>
304 found !== undefined
305 ? Effect.succeed(found)
306 : Effect.fail(error),
307 ),
308 ),
309 ),
310 );
311 }
312
313 if (current === undefined) {
314 return yield* new ResourceNotResolved({
315 name: hinted || `${schema}/schemaVersions`,
316 });
317 }
318
319 const currentName = current.name ?? hinted;
320 const observedLabels = tagRecord(current.labels);
321 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
322 const labelsChanged = upsert.length > 0 || removed.length > 0;
323 const displayChanged = !sameText(current.displayName, displayName);
324
325 if (labelsChanged || displayChanged) {
326 current = yield* retryTransient(
327 documentai.patchProjectsLocationsSchemasSchemaVersions({
328 name: currentName,
329 updateMask: updateMaskOf(
330 displayChanged ? "displayName" : undefined,
331 labelsChanged ? "labels" : undefined,
332 ),
333 body: {
334 displayName,
335 labels: desiredLabels,
336 },
337 }),
338 );
339 }
340
341 return toAttrs(current, env.project);
342 }),
343
344 delete: Effect.fn(function* ({ output }) {
345 if (!output.name) return;
346 const operation = yield* retryTransient(
347 documentai.deleteProjectsLocationsSchemasSchemaVersions({
348 name: output.name,
349 }),
350 ).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
351 if (operation !== undefined) {
352 // NOT_FOUND (5): already gone.
353 yield* waitForOperation(operation).pipe(
354 Effect.catchTag("GCP.OperationFailed", (error) =>
355 error.code === 5 ? Effect.void : Effect.fail(error),
356 ),
357 );
358 }
359 yield* waitUntilGone(getByName(output.name), output.name);
360 }),
361 });