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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 collectPages,
19 expandParent,
20 forEachDataset,
21 hasAlchemyLabelMap,
22 normalizeLocation,
23 parseResourceName,
24 replaceOnIdentity,
25 retryTransient,
26 sameText,
27 toPhysicalId,
28 updateMaskOf,
29 userLabels,
30 waitUntilGone,
31} from "./internal.ts";
32
33export type DatasetsConsentStoreProps = {
34 /**
35 * Parent dataset. Full name
36 * `projects/{project}/locations/{location}/datasets/{dataset}` or the
37 * dataset id (combined with `location`). Immutable — changing it
38 * replaces the consent store.
39 */
40 dataset: string;
41 /**
42 * Region used when `dataset` is a bare id.
43 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
44 */
45 location?: string;
46 /**
47 * Consent store id. Must match letters, numbers, underscores, hyphens,
48 * and periods; 1-256 characters. Immutable — changing it replaces the
49 * store.
50 */
51 consentStoreId?: string;
52 /**
53 * User labels. Alchemy ownership labels are merged in automatically.
54 */
55 labels?: Record<string, string>;
56 /**
57 * Default TTL for Consents created in this store (e.g. `"86400s"`).
58 * Must be at least 24 hours. Updating this does not change existing
59 * consents.
60 */
61 defaultConsentTtl?: string;
62 /**
63 * If true, `UpdateConsent` creates the Consent when it is missing.
64 * @default false
65 */
66 enableConsentCreateOnUpdate?: boolean;
67};
68
69export type DatasetsConsentStore = Resource<
70 "GCP.Healthcare.DatasetsConsentStore",
71 DatasetsConsentStoreProps,
72 {
73 /** Full resource name `.../datasets/{dataset}/consentStores/{consentStore}`. */
74 name: string;
75 /** Consent store id. */
76 consentStoreId: string;
77 /** Parent dataset resource name. */
78 dataset: string;
79 /** Project id. */
80 project: string;
81 /** Location id. */
82 location: string;
83 /** User labels (Alchemy ownership labels stripped). */
84 labels: Record<string, string>;
85 /** Default consent TTL. */
86 defaultConsentTtl: string | undefined;
87 /** Whether UpdateConsent creates missing Consents. */
88 enableConsentCreateOnUpdate: boolean;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * A Cloud Healthcare consent store inside a dataset.
96 *
97 * Dataset, store id, and location are immutable. Labels, default TTL, and
98 * `enableConsentCreateOnUpdate` update in place.
99 *
100 * ### Creating a Consent Store
101 * **Example:** Store with labels
102 * ```typescript
103 * const store = yield* GCP.Healthcare.DatasetsConsentStore("Consents", {
104 * dataset: dataset.name,
105 * labels: { env: "test" },
106 * });
107 * ```
108 *
109 * **Example:** Named store that creates consents on update
110 * ```typescript
111 * const store = yield* GCP.Healthcare.DatasetsConsentStore("Consents", {
112 * dataset: dataset.name,
113 * consentStoreId: "clinic-consents",
114 * enableConsentCreateOnUpdate: true,
115 * });
116 * ```
117 *
118 * @resource
119 * @category Healthcare
120 */
121export const DatasetsConsentStore = Resource<DatasetsConsentStore>(
122 "GCP.Healthcare.DatasetsConsentStore",
123);
124
125export class DatasetsConsentStoreNotResolved extends Data.TaggedError(
126 "GCP.Healthcare.DatasetsConsentStoreNotResolved",
127)<{
128 name: string;
129}> {}
130
131const datasetOf = (dataset: string, project: string, location: string) =>
132 expandParent(dataset, project, location, "datasets");
133
134const resourceName = (dataset: string, consentStoreId: string) =>
135 `${dataset}/consentStores/${consentStoreId}`;
136
137const toAttrs = (
138 store: healthcare.ConsentStore,
139 project: string,
140 region: string,
141) => {
142 const name = store.name ?? "";
143 const parsed = parseResourceName(name, "consentStores", region);
144 return {
145 name,
146 consentStoreId: parsed.id,
147 dataset: parsed.parent,
148 project: parsed.project || project,
149 location: parsed.location,
150 labels: userLabels(store.labels),
151 defaultConsentTtl: store.defaultConsentTtl,
152 enableConsentCreateOnUpdate: store.enableConsentCreateOnUpdate === true,
153 };
154};
155
156const getByName = (name: string) =>
157 name.length === 0
158 ? Effect.succeed(undefined)
159 : healthcare
160 .getProjectsLocationsDatasetsConsentStores({ name })
161 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
162
163export const DatasetsConsentStoreProvider = () =>
164 Provider.succeed(DatasetsConsentStore, {
165 stables: ["name", "consentStoreId", "dataset", "project", "location"],
166
167 diff: Effect.fn(function* ({ news, olds, output }) {
168 if (!isResolved(news)) return undefined;
169 const env = yield* GcpEnvironment.current;
170 const previousParent = olds?.dataset ?? output?.dataset;
171 const nextParent = datasetOf(
172 news.dataset,
173 env.project,
174 normalizeLocation(news.location ?? output?.location, env.region),
175 );
176 return replaceOnIdentity({
177 previousId: olds?.consentStoreId ?? output?.consentStoreId,
178 nextId: news.consentStoreId,
179 previousParent,
180 nextParent,
181 });
182 }),
183
184 read: Effect.fn(function* ({ id, olds, output }) {
185 const env = yield* GcpEnvironment.current;
186 const location = normalizeLocation(
187 olds?.location ?? output?.location,
188 env.region,
189 );
190 const consentStoreId = yield* toPhysicalId(
191 id,
192 olds?.consentStoreId,
193 output?.consentStoreId,
194 );
195 const dataset =
196 olds?.dataset !== undefined
197 ? datasetOf(olds.dataset, env.project, location)
198 : (output?.dataset ?? "");
199 const name =
200 output?.name ??
201 (dataset.length > 0 ? resourceName(dataset, consentStoreId) : "");
202 const existing = yield* getByName(name);
203 if (existing === undefined) return undefined;
204 const attrs = toAttrs(existing, env.project, env.region);
205 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
206 ? attrs
207 : Unowned(attrs);
208 }),
209
210 list: () =>
211 Effect.gen(function* () {
212 const env = yield* GcpEnvironment.current;
213 const stores = yield* forEachDataset(
214 env.project,
215 env.region,
216 (parent) =>
217 collectPages(
218 healthcare.listProjectsLocationsDatasetsConsentStores.pages({
219 parent,
220 pageSize: 1000,
221 }),
222 (page) => page.consentStores,
223 ),
224 );
225 return stores
226 .filter((store) => hasAlchemyLabelMap(store.labels))
227 .map((store) => toAttrs(store, env.project, env.region));
228 }),
229
230 reconcile: Effect.fn(function* ({ id, news, output }) {
231 const env = yield* GcpEnvironment.current;
232 const location = normalizeLocation(
233 news.location ?? output?.location,
234 env.region,
235 );
236 const dataset = datasetOf(news.dataset, env.project, location);
237 const consentStoreId = yield* toPhysicalId(
238 id,
239 news.consentStoreId,
240 output?.consentStoreId,
241 );
242 const name = output?.name ?? resourceName(dataset, consentStoreId);
243 const desiredLabels = {
244 ...toLabels(news.labels),
245 ...(yield* createInternalLabels(id)),
246 };
247
248 let current = yield* getByName(name);
249
250 if (current === undefined) {
251 const created = yield* retryTransient(
252 healthcare.createProjectsLocationsDatasetsConsentStores({
253 parent: dataset,
254 consentStoreId,
255 body: {
256 labels: desiredLabels,
257 defaultConsentTtl: news.defaultConsentTtl,
258 enableConsentCreateOnUpdate: news.enableConsentCreateOnUpdate,
259 },
260 }),
261 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
262 current = created ?? undefined;
263 }
264
265 if (current === undefined) {
266 return yield* new DatasetsConsentStoreNotResolved({ name });
267 }
268
269 const currentName = current.name ?? name;
270 const observedLabels = tagRecord(current.labels);
271 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
272 const labelsChanged = upsert.length > 0 || removed.length > 0;
273 const ttlChanged = !sameText(
274 current.defaultConsentTtl,
275 news.defaultConsentTtl,
276 );
277 const createOnUpdateChanged =
278 (current.enableConsentCreateOnUpdate === true) !==
279 (news.enableConsentCreateOnUpdate === true);
280
281 if (labelsChanged || ttlChanged || createOnUpdateChanged) {
282 current = yield* retryTransient(
283 healthcare.patchProjectsLocationsDatasetsConsentStores({
284 name: currentName,
285 updateMask: updateMaskOf(
286 labelsChanged ? "labels" : undefined,
287 ttlChanged ? "defaultConsentTtl" : undefined,
288 createOnUpdateChanged ? "enableConsentCreateOnUpdate" : undefined,
289 ),
290 body: {
291 labels: desiredLabels,
292 defaultConsentTtl: news.defaultConsentTtl,
293 enableConsentCreateOnUpdate: news.enableConsentCreateOnUpdate,
294 },
295 }),
296 );
297 }
298
299 return toAttrs(current, env.project, env.region);
300 }),
301
302 delete: Effect.fn(function* ({ output }) {
303 if (!output.name) return;
304 yield* retryTransient(
305 healthcare.deleteProjectsLocationsDatasetsConsentStores({
306 name: output.name,
307 }),
308 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
309 yield* waitUntilGone(getByName(output.name), output.name);
310 }),
311 });