1import * as healthcare from "@distilled.cloud/gcp/healthcare_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 collectPages,
13 encodeOwnership,
14 expandParent,
15 hasOwnershipMarker,
16 listAlchemyConsentStores,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 retryTransient,
23 sameJson,
24 sameText,
25 toPhysicalSnake,
26 updateMaskOf,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type DatasetsConsentStoresAttributeDefinitionProps = {
31 /**
32 * Parent consent store. Full name
33 * `.../consentStores/{consentStore}` or the store id (combined with
34 * `dataset` and `location`). Immutable — changing it replaces the
35 * definition.
36 */
37 consentStore: string;
38 /**
39 * Parent dataset used when `consentStore` is a bare id.
40 */
41 dataset?: string;
42 /**
43 * Region used when `consentStore` is a bare id.
44 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Attribute definition id. Must match letters, digits, and
49 * underscores; cannot be a CEL reserved keyword. Immutable — changing
50 * it replaces the definition.
51 */
52 attributeDefinitionId?: string;
53 /**
54 * Attribute category. Immutable — changing it replaces the definition.
55 */
56 category: healthcare.AttributeDefinitionCategoryEnum | (string & {});
57 /**
58 * Allowed values. The list can only be expanded after creation.
59 */
60 allowedValues: string[];
61 /**
62 * Human-readable description. Attribute definitions have no labels
63 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
64 * stripped from attributes.
65 */
66 description?: string;
67 /**
68 * Default values applied to Consents that omit this attribute.
69 */
70 consentDefaultValues?: string[];
71 /**
72 * Default value applied to User data mappings (RESOURCE category).
73 */
74 dataMappingDefaultValue?: string;
75};
76
77export type DatasetsConsentStoresAttributeDefinition = Resource<
78 "GCP.Healthcare.DatasetsConsentStoresAttributeDefinition",
79 DatasetsConsentStoresAttributeDefinitionProps,
80 {
81 /** Full resource name `.../attributeDefinitions/{attributeDefinition}`. */
82 name: string;
83 /** Attribute definition id. */
84 attributeDefinitionId: string;
85 /** Parent consent store resource name. */
86 consentStore: string;
87 /** Project id. */
88 project: string;
89 /** Location id. */
90 location: string;
91 /** Attribute category. */
92 category: string;
93 /** Allowed values. */
94 allowedValues: string[];
95 /** Description with the Alchemy ownership prefix stripped. */
96 description: string | undefined;
97 /** Default Consent values. */
98 consentDefaultValues: string[] | undefined;
99 /** Default User data mapping value. */
100 dataMappingDefaultValue: string | undefined;
101 },
102 never,
103 Providers
104>;
105
106/**
107 * A client-defined consent attribute in a consent store.
108 *
109 * Consent store, id, and category are immutable. Allowed values can only
110 * grow. Description, consent defaults, and data-mapping default update
111 * in place. Ownership is stamped into `description`.
112 *
113 * ### Creating an Attribute Definition
114 * **Example:** Resource attribute
115 * ```typescript
116 * const attr = yield* GCP.Healthcare.DatasetsConsentStoresAttributeDefinition(
117 * "DataType",
118 * {
119 * consentStore: store.name,
120 * category: "RESOURCE",
121 * allowedValues: ["fhir", "dicom"],
122 * description: "data modality",
123 * },
124 * );
125 * ```
126 *
127 * @resource
128 * @category Healthcare
129 */
130export const DatasetsConsentStoresAttributeDefinition =
131 Resource<DatasetsConsentStoresAttributeDefinition>(
132 "GCP.Healthcare.DatasetsConsentStoresAttributeDefinition",
133 );
134
135export class DatasetsConsentStoresAttributeDefinitionNotResolved extends Data.TaggedError(
136 "GCP.Healthcare.DatasetsConsentStoresAttributeDefinitionNotResolved",
137)<{
138 name: string;
139}> {}
140
141const storeOf = (
142 consentStore: string,
143 project: string,
144 location: string,
145 dataset: string | undefined,
146) => {
147 if (consentStore.includes("/")) return consentStore.replace(/\/+$/, "");
148 if (dataset === undefined) {
149 return expandParent(consentStore, project, location, "consentStores");
150 }
151 const datasetName = expandParent(dataset, project, location, "datasets");
152 return `${datasetName}/consentStores/${consentStore}`;
153};
154
155const resourceName = (consentStore: string, attributeDefinitionId: string) =>
156 `${consentStore}/attributeDefinitions/${attributeDefinitionId}`;
157
158const toAttrs = (
159 definition: healthcare.AttributeDefinition,
160 project: string,
161 region: string,
162) => {
163 const name = definition.name ?? "";
164 const parsed = parseResourceName(name, "attributeDefinitions", region);
165 const ownership = parseOwnership(definition.description);
166 return {
167 name,
168 attributeDefinitionId: parsed.id,
169 consentStore: parsed.parent,
170 project: parsed.project || project,
171 location: parsed.location,
172 category: definition.category ?? "CATEGORY_UNSPECIFIED",
173 allowedValues: definition.allowedValues ?? [],
174 description: ownership.text,
175 consentDefaultValues: definition.consentDefaultValues,
176 dataMappingDefaultValue: definition.dataMappingDefaultValue,
177 };
178};
179
180const getByName = (name: string) =>
181 name.length === 0
182 ? Effect.succeed(undefined)
183 : healthcare
184 .getProjectsLocationsDatasetsConsentStoresAttributeDefinitions({
185 name,
186 })
187 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
188
189export const DatasetsConsentStoresAttributeDefinitionProvider = () =>
190 Provider.succeed(DatasetsConsentStoresAttributeDefinition, {
191 stables: [
192 "name",
193 "attributeDefinitionId",
194 "consentStore",
195 "project",
196 "location",
197 "category",
198 ],
199
200 diff: Effect.fn(function* ({ news, olds, output }) {
201 if (!isResolved(news)) return undefined;
202 const env = yield* GcpEnvironment.current;
203 const previousCategory = olds?.category ?? output?.category;
204 const extra =
205 previousCategory !== undefined && previousCategory !== news.category;
206 return replaceOnIdentity({
207 previousId:
208 olds?.attributeDefinitionId ?? output?.attributeDefinitionId,
209 nextId: news.attributeDefinitionId,
210 previousParent: olds?.consentStore ?? output?.consentStore,
211 nextParent: storeOf(
212 news.consentStore,
213 env.project,
214 normalizeLocation(news.location ?? output?.location, env.region),
215 news.dataset,
216 ),
217 extra,
218 });
219 }),
220
221 read: Effect.fn(function* ({ id, olds, output }) {
222 const env = yield* GcpEnvironment.current;
223 const location = normalizeLocation(
224 olds?.location ?? output?.location,
225 env.region,
226 );
227 const attributeDefinitionId = yield* toPhysicalSnake(
228 id,
229 olds?.attributeDefinitionId,
230 output?.attributeDefinitionId,
231 );
232 const consentStore =
233 olds?.consentStore !== undefined
234 ? storeOf(olds.consentStore, env.project, location, olds.dataset)
235 : (output?.consentStore ?? "");
236 const name =
237 output?.name ??
238 (consentStore.length > 0
239 ? resourceName(consentStore, attributeDefinitionId)
240 : "");
241 const existing = yield* getByName(name);
242 if (existing === undefined) return undefined;
243 const attrs = toAttrs(existing, env.project, env.region);
244 return (yield* ownedByAlchemy(id, existing.description))
245 ? attrs
246 : Unowned(attrs);
247 }),
248
249 list: () =>
250 Effect.gen(function* () {
251 const env = yield* GcpEnvironment.current;
252 const stores = yield* listAlchemyConsentStores(env.project, env.region);
253 const definitions = yield* Effect.forEach(
254 stores,
255 (store) =>
256 collectPages(
257 healthcare.listProjectsLocationsDatasetsConsentStoresAttributeDefinitions.pages(
258 {
259 parent: store.name ?? "",
260 pageSize: 1000,
261 },
262 ),
263 (page) => page.attributeDefinitions,
264 ),
265 { concurrency: 4 },
266 );
267 return definitions
268 .flat()
269 .filter((definition) => hasOwnershipMarker(definition.description))
270 .map((definition) => toAttrs(definition, env.project, env.region));
271 }),
272
273 reconcile: Effect.fn(function* ({ id, news, output }) {
274 const env = yield* GcpEnvironment.current;
275 const location = normalizeLocation(
276 news.location ?? output?.location,
277 env.region,
278 );
279 const consentStore = storeOf(
280 news.consentStore,
281 env.project,
282 location,
283 news.dataset,
284 );
285 const attributeDefinitionId = yield* toPhysicalSnake(
286 id,
287 news.attributeDefinitionId,
288 output?.attributeDefinitionId,
289 );
290 const name =
291 output?.name ?? resourceName(consentStore, attributeDefinitionId);
292 const ownership = yield* createInternalLabels(id);
293 const description = encodeOwnership(ownership, news.description);
294
295 let current = yield* getByName(name);
296
297 if (current === undefined) {
298 const created = yield* retryTransient(
299 healthcare.createProjectsLocationsDatasetsConsentStoresAttributeDefinitions(
300 {
301 parent: consentStore,
302 attributeDefinitionId,
303 body: {
304 category: news.category,
305 allowedValues: news.allowedValues,
306 description,
307 consentDefaultValues: news.consentDefaultValues,
308 dataMappingDefaultValue: news.dataMappingDefaultValue,
309 },
310 },
311 ),
312 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
313 current = created ?? undefined;
314 }
315
316 if (current === undefined) {
317 return yield* new DatasetsConsentStoresAttributeDefinitionNotResolved({
318 name,
319 });
320 }
321
322 const currentName = current.name ?? name;
323 const descriptionChanged = !sameText(current.description, description);
324 const allowedChanged = !sameJson(
325 current.allowedValues,
326 news.allowedValues,
327 );
328 const consentDefaultChanged = !sameJson(
329 current.consentDefaultValues,
330 news.consentDefaultValues,
331 );
332 const mappingDefaultChanged = !sameText(
333 current.dataMappingDefaultValue,
334 news.dataMappingDefaultValue,
335 );
336
337 if (
338 descriptionChanged ||
339 allowedChanged ||
340 consentDefaultChanged ||
341 mappingDefaultChanged
342 ) {
343 current = yield* retryTransient(
344 healthcare.patchProjectsLocationsDatasetsConsentStoresAttributeDefinitions(
345 {
346 name: currentName,
347 updateMask: updateMaskOf(
348 descriptionChanged ? "description" : undefined,
349 allowedChanged ? "allowedValues" : undefined,
350 consentDefaultChanged ? "consentDefaultValues" : undefined,
351 mappingDefaultChanged ? "dataMappingDefaultValue" : undefined,
352 ),
353 body: {
354 description,
355 allowedValues: news.allowedValues,
356 consentDefaultValues: news.consentDefaultValues,
357 dataMappingDefaultValue: news.dataMappingDefaultValue,
358 },
359 },
360 ),
361 );
362 }
363
364 return toAttrs(current, env.project, env.region);
365 }),
366
367 delete: Effect.fn(function* ({ output }) {
368 if (!output.name) return;
369 yield* retryTransient(
370 healthcare.deleteProjectsLocationsDatasetsConsentStoresAttributeDefinitions(
371 {
372 name: output.name,
373 },
374 ),
375 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
376 yield* waitUntilGone(getByName(output.name), output.name);
377 }),
378 });