1import * as cw from "@distilled.cloud/gcp/contentwarehouse_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 { GcpEnvironment } from "../Environment.ts";
8import { createInternalLabels } from "../Labels.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 LIST_LOCATIONS,
12 appendProperties,
13 collectPages,
14 defaultTextProperty,
15 encodeOwnership,
16 ensureProject,
17 hasOwnershipMarker,
18 ignoreList,
19 lastSegment,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 sameText,
27 toPhysicalId,
28 waitUntilExists,
29 waitUntilGone,
30 ResourceNotResolved,
31} from "./internal.ts";
32
33export type PropertyDefinition =
34 cw.GoogleCloudContentwarehouseV1PropertyDefinition;
35
36export type DocumentSchemaProps = {
37 /**
38 * Document schema id (the `{documentSchema}` segment of
39 * `projects/{project}/locations/{location}/documentSchemas/{documentSchema}`).
40 * Assigned by the API on create. Immutable — changing it replaces the
41 * schema. Supply it to adopt an existing schema.
42 */
43 documentSchemaId?: string;
44 /**
45 * Multi-region location (`us` or `eu`). Immutable — changing it
46 * replaces the schema. `US` is accepted and normalized to `us`.
47 * @default "us"
48 */
49 location?: string;
50 /**
51 * Unique display name within the project. Defaults to a generated id.
52 */
53 displayName?: string;
54 /**
55 * Schema description. Document schemas have no labels field, so Alchemy
56 * stamps ownership into a `[alchemy …]` prefix and strips it from
57 * attributes.
58 */
59 description?: string;
60 /**
61 * When true the schema describes a folder rather than a document.
62 * @default false
63 */
64 documentIsFolder?: boolean;
65 /**
66 * Property definitions. Updates may only append new properties — existing
67 * definitions cannot be mutated in place.
68 */
69 propertyDefinitions?: PropertyDefinition[];
70};
71
72export type DocumentSchema = Resource<
73 "GCP.ContentWarehouse.DocumentSchema",
74 DocumentSchemaProps,
75 {
76 /** Full resource name. */
77 name: string;
78 /** Document schema id (last path segment). */
79 documentSchemaId: string;
80 /** Project id. */
81 project: string;
82 /** Location id. */
83 location: string;
84 /** Display name. */
85 displayName: string | undefined;
86 /** User description with the Alchemy ownership prefix stripped. */
87 description: string | undefined;
88 /** Whether this schema describes a folder. */
89 documentIsFolder: boolean;
90 /** Property definitions. */
91 propertyDefinitions: PropertyDefinition[] | undefined;
92 /** RFC3339 creation timestamp. */
93 createTime: string | undefined;
94 /** RFC3339 last-update timestamp. */
95 updateTime: string | undefined;
96 },
97 never,
98 Providers
99>;
100
101/**
102 * A Document AI Warehouse document schema describing document structure.
103 *
104 * Schemas have no labels field — Alchemy stamps ownership into the
105 * description so `list` / nuke can find them. Location is immutable.
106 * Description and newly appended properties update in place; existing
107 * property definitions cannot be rewritten.
108 *
109 * The project must be initialized for Document AI Warehouse in `us` or
110 * `eu` before schemas can be created.
111 *
112 * ### Creating a Document Schema
113 * **Example:** Text document schema
114 * ```typescript
115 * const schema = yield* GCP.ContentWarehouse.DocumentSchema("Invoice", {
116 * displayName: "invoice",
117 * propertyDefinitions: [
118 * { name: "title", isSearchable: true, textTypeOptions: {} },
119 * ],
120 * });
121 * ```
122 *
123 * @resource
124 * @category ContentWarehouse
125 */
126export const DocumentSchema = Resource<DocumentSchema>(
127 "GCP.ContentWarehouse.DocumentSchema",
128);
129
130const resourceName = (
131 project: string,
132 location: string,
133 documentSchemaId: string,
134) => `${locationParent(project, location)}/documentSchemas/${documentSchemaId}`;
135
136const toAttrs = (
137 item: cw.GoogleCloudContentwarehouseV1DocumentSchema,
138 project: string,
139) => {
140 const name = item.name ?? "";
141 const parsed = parseName(name, "documentSchemas");
142 return {
143 name,
144 documentSchemaId: parsed.id,
145 project: parsed.project || project,
146 location: parsed.location,
147 displayName: item.displayName,
148 description: parseOwnership(item.description).text,
149 documentIsFolder: item.documentIsFolder === true,
150 propertyDefinitions: item.propertyDefinitions,
151 createTime: item.createTime,
152 updateTime: item.updateTime,
153 };
154};
155
156const getByName = (name: string) =>
157 name.length === 0
158 ? Effect.succeed(undefined)
159 : cw
160 .getProjectsLocationsDocumentSchemas({ name })
161 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
162
163const listAt = (parent: string) =>
164 collectPages(
165 cw.listProjectsLocationsDocumentSchemas.pages({
166 parent,
167 pageSize: 1000,
168 }),
169 (page) => page.documentSchemas,
170 ).pipe(ignoreList([] as cw.GoogleCloudContentwarehouseV1DocumentSchema[]));
171
172const findOwned = (
173 id: string,
174 items: readonly cw.GoogleCloudContentwarehouseV1DocumentSchema[],
175) =>
176 Effect.gen(function* () {
177 for (const item of items) {
178 if (yield* ownedByAlchemy(id, item.description)) return item;
179 }
180 return undefined as
181 | cw.GoogleCloudContentwarehouseV1DocumentSchema
182 | undefined;
183 });
184
185const listOwned = (project: string) =>
186 Effect.forEach(
187 LIST_LOCATIONS,
188 (location) => listAt(locationParent(project, location)),
189 { concurrency: 2 },
190 ).pipe(
191 Effect.map((groups) =>
192 groups.flat().filter((item) => hasOwnershipMarker(item.description)),
193 ),
194 );
195
196export const DocumentSchemaProvider = () =>
197 Provider.succeed(DocumentSchema, {
198 stables: ["name", "documentSchemaId", "project", "location", "createTime"],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const folderChanged =
203 (olds?.documentIsFolder ?? output?.documentIsFolder ?? false) !==
204 (news.documentIsFolder ?? false);
205 return replaceOnIdentity({
206 previousId: olds?.documentSchemaId ?? output?.documentSchemaId,
207 nextId: news.documentSchemaId,
208 previousLocation: olds?.location ?? output?.location,
209 nextLocation: news.location ?? olds?.location ?? output?.location,
210 extra: folderChanged,
211 });
212 }),
213
214 read: Effect.fn(function* ({ id, olds, output }) {
215 const env = yield* GcpEnvironment.current;
216 const location = normalizeLocation(olds?.location ?? output?.location);
217 const documentSchemaId =
218 olds?.documentSchemaId ?? output?.documentSchemaId;
219 const name =
220 output?.name ??
221 (documentSchemaId
222 ? resourceName(env.project, location, documentSchemaId)
223 : "");
224 let existing = yield* getByName(name);
225 if (existing === undefined) {
226 const listed = yield* listAt(locationParent(env.project, location));
227 existing = yield* findOwned(id, listed);
228 }
229 if (existing === undefined) return undefined;
230 const attrs = toAttrs(existing, env.project);
231 return (yield* ownedByAlchemy(id, existing.description))
232 ? attrs
233 : Unowned(attrs);
234 }),
235
236 list: () =>
237 Effect.gen(function* () {
238 const env = yield* GcpEnvironment.current;
239 const items = yield* listOwned(env.project);
240 return items.map((item) => toAttrs(item, env.project));
241 }),
242
243 reconcile: Effect.fn(function* ({ id, news, output }) {
244 const env = yield* GcpEnvironment.current;
245 const location = normalizeLocation(news.location ?? output?.location);
246 const parent = locationParent(env.project, location);
247 yield* ensureProject(parent);
248 const documentSchemaId = yield* toPhysicalId(
249 id,
250 news.documentSchemaId,
251 output?.documentSchemaId,
252 "schema",
253 );
254 const ownership = yield* createInternalLabels(id);
255 const description = encodeOwnership(ownership, news.description);
256 const displayName = news.displayName ?? documentSchemaId;
257 const propertyDefinitions = news.propertyDefinitions ?? [
258 defaultTextProperty(),
259 ];
260 const documentIsFolder = news.documentIsFolder === true;
261
262 const findInParent = listAt(parent).pipe(
263 Effect.flatMap((items) => findOwned(id, items)),
264 );
265 let current =
266 (yield* getByName(output?.name ?? "")) ?? (yield* findInParent);
267
268 if (current === undefined) {
269 const created = yield* cw
270 .createProjectsLocationsDocumentSchemas({
271 parent,
272 body: {
273 displayName,
274 description,
275 documentIsFolder,
276 propertyDefinitions,
277 },
278 })
279 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
280 current = created ?? (yield* findInParent);
281 if (current === undefined) {
282 current = yield* waitUntilExists(
283 Effect.flatMap(listAt(parent), (items) => findOwned(id, items)),
284 `${parent}/documentSchemas/${documentSchemaId}`,
285 );
286 }
287 }
288
289 if (current === undefined) {
290 return yield* new ResourceNotResolved({
291 name:
292 output?.name ??
293 resourceName(env.project, location, documentSchemaId),
294 });
295 }
296
297 const currentName = current.name ?? "";
298 const appended = appendProperties(
299 current.propertyDefinitions,
300 propertyDefinitions,
301 );
302 const descriptionChanged = !sameText(current.description, description);
303 const displayChanged = !sameText(current.displayName, displayName);
304 if (descriptionChanged || displayChanged || appended.appended) {
305 current = yield* cw.patchProjectsLocationsDocumentSchemas({
306 name: currentName,
307 body: {
308 documentSchema: {
309 name: currentName,
310 displayName,
311 description,
312 documentIsFolder,
313 propertyDefinitions: appended.properties,
314 },
315 },
316 });
317 }
318
319 return toAttrs(current, env.project);
320 }),
321
322 delete: Effect.fn(function* ({ output }) {
323 yield* cw
324 .deleteProjectsLocationsDocumentSchemas({ name: output.name })
325 .pipe(Effect.catchTag("NotFound", () => Effect.void));
326 yield* waitUntilGone(getByName(output.name), output.name);
327 }),
328 });
329
330export const documentSchemaIdOf = lastSegment;