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 collectPages,
13 encodeOwnership,
14 ensureProject,
15 hasOwnershipMarker,
16 ignoreList,
17 locationParent,
18 normalizeLocation,
19 ownedByAlchemy,
20 parseName,
21 parseOwnership,
22 replaceOnIdentity,
23 sameJson,
24 sameText,
25 toPhysicalId,
26 waitUntilExists,
27 waitUntilGone,
28 ResourceNotResolved,
29} from "./internal.ts";
30
31export type Rule = cw.GoogleCloudContentwarehouseV1Rule;
32
33export type RuleSetProps = {
34 /**
35 * Rule set id (the `{ruleSet}` segment of
36 * `projects/{project}/locations/{location}/ruleSets/{ruleSet}`).
37 * Assigned by the API on create. Immutable — changing it replaces the
38 * rule set. Supply it to adopt an existing rule set.
39 */
40 ruleSetId?: string;
41 /**
42 * Multi-region location (`us` or `eu`). Immutable — changing it
43 * replaces the rule set. `US` is accepted and normalized to `us`.
44 * @default "us"
45 */
46 location?: string;
47 /**
48 * Short description. Rule sets have no labels field, so Alchemy stamps
49 * ownership into a `[alchemy …]` prefix and strips it from attributes.
50 */
51 description?: string;
52 /**
53 * Source of the rules (customer name).
54 */
55 source?: string;
56 /**
57 * Rules evaluated on document create/update/link events.
58 */
59 rules?: Rule[];
60};
61
62export type RuleSet = Resource<
63 "GCP.ContentWarehouse.RuleSet",
64 RuleSetProps,
65 {
66 /** Full resource name. */
67 name: string;
68 /** Rule set id (last path segment). */
69 ruleSetId: string;
70 /** Project id. */
71 project: string;
72 /** Location id. */
73 location: string;
74 /** User description with the Alchemy ownership prefix stripped. */
75 description: string | undefined;
76 /** Source of the rules. */
77 source: string | undefined;
78 /** Rules in the set. */
79 rules: Rule[] | undefined;
80 },
81 never,
82 Providers
83>;
84
85const DEFAULT_RULES: Rule[] = [
86 {
87 description: "always",
88 triggerType: "ON_CREATE",
89 condition: "true",
90 actions: [
91 {
92 dataValidation: {
93 conditions: { display_name: "true" },
94 },
95 },
96 ],
97 },
98];
99
100/**
101 * A Document AI Warehouse rule set — trigger conditions and actions run
102 * when documents are created, updated, or linked.
103 *
104 * Rule sets have no labels field — Alchemy stamps ownership into the
105 * description so `list` / nuke can find them. Location is immutable.
106 * Description, source, and rules update in place.
107 *
108 * ### Creating a Rule Set
109 * **Example:** Validate on create
110 * ```typescript
111 * const rules = yield* GCP.ContentWarehouse.RuleSet("Checks", {
112 * source: "alchemy",
113 * rules: [
114 * {
115 * description: "require title",
116 * triggerType: "ON_CREATE",
117 * condition: "true",
118 * actions: [{ dataValidation: { conditions: { display_name: "true" } } }],
119 * },
120 * ],
121 * });
122 * ```
123 *
124 * @resource
125 * @category ContentWarehouse
126 */
127export const RuleSet = Resource<RuleSet>("GCP.ContentWarehouse.RuleSet");
128
129const resourceName = (project: string, location: string, ruleSetId: string) =>
130 `${locationParent(project, location)}/ruleSets/${ruleSetId}`;
131
132const toAttrs = (
133 item: cw.GoogleCloudContentwarehouseV1RuleSet,
134 project: string,
135) => {
136 const name = item.name ?? "";
137 const parsed = parseName(name, "ruleSets");
138 return {
139 name,
140 ruleSetId: parsed.id,
141 project: parsed.project || project,
142 location: parsed.location,
143 description: parseOwnership(item.description).text,
144 source: item.source,
145 rules: item.rules,
146 };
147};
148
149const getByName = (name: string) =>
150 name.length === 0
151 ? Effect.succeed(undefined)
152 : cw
153 .getProjectsLocationsRuleSets({ name })
154 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
155
156const listAt = (parent: string) =>
157 collectPages(
158 cw.listProjectsLocationsRuleSets.pages({ parent, pageSize: 1000 }),
159 (page) => page.ruleSets,
160 ).pipe(ignoreList([] as cw.GoogleCloudContentwarehouseV1RuleSet[]));
161
162const findOwned = (
163 id: string,
164 items: readonly cw.GoogleCloudContentwarehouseV1RuleSet[],
165) =>
166 Effect.gen(function* () {
167 for (const item of items) {
168 if (yield* ownedByAlchemy(id, item.description)) return item;
169 }
170 return undefined as cw.GoogleCloudContentwarehouseV1RuleSet | undefined;
171 });
172
173const listOwned = (project: string) =>
174 Effect.forEach(
175 LIST_LOCATIONS,
176 (location) => listAt(locationParent(project, location)),
177 { concurrency: 2 },
178 ).pipe(
179 Effect.map((groups) =>
180 groups.flat().filter((item) => hasOwnershipMarker(item.description)),
181 ),
182 );
183
184export const RuleSetProvider = () =>
185 Provider.succeed(RuleSet, {
186 stables: ["name", "ruleSetId", "project", "location"],
187
188 diff: Effect.fn(function* ({ news, olds, output }) {
189 if (!isResolved(news)) return undefined;
190 return replaceOnIdentity({
191 previousId: olds?.ruleSetId ?? output?.ruleSetId,
192 nextId: news.ruleSetId,
193 previousLocation: olds?.location ?? output?.location,
194 nextLocation: news.location ?? olds?.location ?? output?.location,
195 });
196 }),
197
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const env = yield* GcpEnvironment.current;
200 const location = normalizeLocation(olds?.location ?? output?.location);
201 const ruleSetId = olds?.ruleSetId ?? output?.ruleSetId;
202 const name =
203 output?.name ??
204 (ruleSetId ? resourceName(env.project, location, ruleSetId) : "");
205 let existing = yield* getByName(name);
206 if (existing === undefined) {
207 const listed = yield* listAt(locationParent(env.project, location));
208 existing = yield* findOwned(id, listed);
209 }
210 if (existing === undefined) return undefined;
211 const attrs = toAttrs(existing, env.project);
212 return (yield* ownedByAlchemy(id, existing.description))
213 ? attrs
214 : Unowned(attrs);
215 }),
216
217 list: () =>
218 Effect.gen(function* () {
219 const env = yield* GcpEnvironment.current;
220 const items = yield* listOwned(env.project);
221 return items.map((item) => toAttrs(item, env.project));
222 }),
223
224 reconcile: Effect.fn(function* ({ id, news, output }) {
225 const env = yield* GcpEnvironment.current;
226 const location = normalizeLocation(news.location ?? output?.location);
227 const parent = locationParent(env.project, location);
228 yield* ensureProject(parent);
229 const ruleSetId = yield* toPhysicalId(
230 id,
231 news.ruleSetId,
232 output?.ruleSetId,
233 "rules",
234 );
235 const ownership = yield* createInternalLabels(id);
236 const description = encodeOwnership(ownership, news.description);
237 const source = news.source ?? "alchemy";
238 const rules = news.rules ?? DEFAULT_RULES;
239
240 const findInParent = listAt(parent).pipe(
241 Effect.flatMap((items) => findOwned(id, items)),
242 );
243 let current =
244 (yield* getByName(output?.name ?? "")) ?? (yield* findInParent);
245
246 if (current === undefined) {
247 const created = yield* cw
248 .createProjectsLocationsRuleSets({
249 parent,
250 body: { description, source, rules },
251 })
252 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
253 current =
254 created ??
255 (yield* findInParent) ??
256 (yield* waitUntilExists(
257 findInParent,
258 resourceName(env.project, location, ruleSetId),
259 ));
260 }
261
262 if (current === undefined) {
263 return yield* new ResourceNotResolved({
264 name: output?.name ?? resourceName(env.project, location, ruleSetId),
265 });
266 }
267
268 const currentName = current.name ?? "";
269 const descriptionChanged = !sameText(current.description, description);
270 const sourceChanged = !sameText(current.source, source);
271 const rulesChanged = !sameJson(current.rules, rules);
272 if (descriptionChanged || sourceChanged || rulesChanged) {
273 current = yield* cw.patchProjectsLocationsRuleSets({
274 name: currentName,
275 body: {
276 ruleSet: {
277 name: currentName,
278 description,
279 source,
280 rules,
281 },
282 },
283 });
284 }
285
286 return toAttrs(current, env.project);
287 }),
288
289 delete: Effect.fn(function* ({ output }) {
290 yield* cw
291 .deleteProjectsLocationsRuleSets({ name: output.name })
292 .pipe(Effect.catchTag("NotFound", () => Effect.void));
293 yield* waitUntilGone(getByName(output.name), output.name);
294 }),
295 });