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 decodeDataId,
14 encodeDataId,
15 expandParent,
16 hasOwnedDataId,
17 listAlchemyConsentStores,
18 normalizeLocation,
19 parseResourceName,
20 retryTransient,
21 sameJson,
22 sameText,
23 updateMaskOf,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type DatasetsConsentStoresUserDataMappingProps = {
28 /**
29 * Parent consent store. Full name
30 * `.../consentStores/{consentStore}` or the store id (combined with
31 * `dataset` and `location`). Immutable — changing it replaces the
32 * mapping.
33 */
34 consentStore: string;
35 /**
36 * Parent dataset used when `consentStore` is a bare id.
37 */
38 dataset?: string;
39 /**
40 * Region used when `consentStore` is a bare id.
41 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
42 */
43 location?: string;
44 /**
45 * User UUID supplied by the client.
46 */
47 userId: string;
48 /**
49 * Unique identifier for the mapped resource. User data mappings have
50 * no labels field, so Alchemy prefixes `dataId` with `alc-{id}-` and
51 * strips it from attributes.
52 */
53 dataId: string;
54 /**
55 * Single-valued resource attributes.
56 */
57 resourceAttributes?: healthcare.Attribute[];
58};
59
60export type DatasetsConsentStoresUserDataMapping = Resource<
61 "GCP.Healthcare.DatasetsConsentStoresUserDataMapping",
62 DatasetsConsentStoresUserDataMappingProps,
63 {
64 /** Full resource name `.../userDataMappings/{userDataMapping}`. */
65 name: string;
66 /** Server-assigned mapping id. */
67 userDataMappingId: string;
68 /** Parent consent store resource name. */
69 consentStore: string;
70 /** Project id. */
71 project: string;
72 /** Location id. */
73 location: string;
74 /** User id. */
75 userId: string | undefined;
76 /** Mapped resource id with the Alchemy prefix stripped. */
77 dataId: string | undefined;
78 /** Resource attributes. */
79 resourceAttributes: healthcare.Attribute[] | undefined;
80 /** Whether the mapping is archived. */
81 archived: boolean;
82 /** RFC3339 archive time. */
83 archiveTime: string | undefined;
84 },
85 never,
86 Providers
87>;
88
89/**
90 * Maps a resource to a user and consent attributes.
91 *
92 * Mapping ids are server-assigned. `userId`, `dataId`, and resource
93 * attributes update in place. Ownership is stamped into `dataId`.
94 *
95 * ### Creating a User Data Mapping
96 * **Example:** Map a FHIR resource to a user
97 * ```typescript
98 * const mapping = yield* GCP.Healthcare.DatasetsConsentStoresUserDataMapping(
99 * "Chart",
100 * {
101 * consentStore: store.name,
102 * userId: "user-123",
103 * dataId: "Patient/abc",
104 * },
105 * );
106 * ```
107 *
108 * @resource
109 * @category Healthcare
110 */
111export const DatasetsConsentStoresUserDataMapping =
112 Resource<DatasetsConsentStoresUserDataMapping>(
113 "GCP.Healthcare.DatasetsConsentStoresUserDataMapping",
114 );
115
116export class DatasetsConsentStoresUserDataMappingNotResolved extends Data.TaggedError(
117 "GCP.Healthcare.DatasetsConsentStoresUserDataMappingNotResolved",
118)<{
119 name: string;
120}> {}
121
122const storeOf = (
123 consentStore: string,
124 project: string,
125 location: string,
126 dataset: string | undefined,
127) => {
128 if (consentStore.includes("/")) return consentStore.replace(/\/+$/, "");
129 if (dataset === undefined) {
130 return expandParent(consentStore, project, location, "consentStores");
131 }
132 const datasetName = expandParent(dataset, project, location, "datasets");
133 return `${datasetName}/consentStores/${consentStore}`;
134};
135
136const toAttrs = (
137 mapping: healthcare.UserDataMapping,
138 project: string,
139 region: string,
140) => {
141 const name = mapping.name ?? "";
142 const parsed = parseResourceName(name, "userDataMappings", region);
143 return {
144 name,
145 userDataMappingId: parsed.id,
146 consentStore: parsed.parent,
147 project: parsed.project || project,
148 location: parsed.location,
149 userId: mapping.userId,
150 dataId: decodeDataId(mapping.dataId),
151 resourceAttributes: mapping.resourceAttributes,
152 archived: mapping.archived === true,
153 archiveTime: mapping.archiveTime,
154 };
155};
156
157const getByName = (name: string) =>
158 name.length === 0
159 ? Effect.succeed(undefined)
160 : healthcare
161 .getProjectsLocationsDatasetsConsentStoresUserDataMappings({ name })
162 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
163
164export const DatasetsConsentStoresUserDataMappingProvider = () =>
165 Provider.succeed(DatasetsConsentStoresUserDataMapping, {
166 stables: [
167 "name",
168 "userDataMappingId",
169 "consentStore",
170 "project",
171 "location",
172 ],
173
174 diff: Effect.fn(function* ({ news, olds, output }) {
175 if (!isResolved(news)) return undefined;
176 const env = yield* GcpEnvironment.current;
177 const previousParent = olds?.consentStore ?? output?.consentStore;
178 const nextParent = storeOf(
179 news.consentStore,
180 env.project,
181 normalizeLocation(news.location ?? output?.location, env.region),
182 news.dataset,
183 );
184 if (previousParent !== undefined && previousParent !== nextParent) {
185 return { action: "replace" as const, deleteFirst: false };
186 }
187 return undefined;
188 }),
189
190 read: Effect.fn(function* ({ output }) {
191 const env = yield* GcpEnvironment.current;
192 const existing = yield* getByName(output?.name ?? "");
193 if (existing === undefined) return undefined;
194 const attrs = toAttrs(existing, env.project, env.region);
195 return hasOwnedDataId(existing.dataId) ? attrs : Unowned(attrs);
196 }),
197
198 list: () =>
199 Effect.gen(function* () {
200 const env = yield* GcpEnvironment.current;
201 const stores = yield* listAlchemyConsentStores(env.project, env.region);
202 const mappings = yield* Effect.forEach(
203 stores,
204 (store) =>
205 collectPages(
206 healthcare.listProjectsLocationsDatasetsConsentStoresUserDataMappings.pages(
207 {
208 parent: store.name ?? "",
209 pageSize: 1000,
210 },
211 ),
212 (page) => page.userDataMappings,
213 ),
214 { concurrency: 4 },
215 );
216 return mappings
217 .flat()
218 .filter((mapping) => hasOwnedDataId(mapping.dataId))
219 .map((mapping) => toAttrs(mapping, env.project, env.region));
220 }),
221
222 reconcile: Effect.fn(function* ({ id, news, output }) {
223 const env = yield* GcpEnvironment.current;
224 const location = normalizeLocation(
225 news.location ?? output?.location,
226 env.region,
227 );
228 const consentStore = storeOf(
229 news.consentStore,
230 env.project,
231 location,
232 news.dataset,
233 );
234 const ownership = yield* createInternalLabels(id);
235 const dataId = encodeDataId(ownership, news.dataId);
236
237 let current = yield* getByName(output?.name ?? "");
238
239 if (current === undefined) {
240 current = yield* retryTransient(
241 healthcare.createProjectsLocationsDatasetsConsentStoresUserDataMappings(
242 {
243 parent: consentStore,
244 body: {
245 userId: news.userId,
246 dataId,
247 resourceAttributes: news.resourceAttributes,
248 },
249 },
250 ),
251 );
252 }
253
254 if (current === undefined) {
255 return yield* new DatasetsConsentStoresUserDataMappingNotResolved({
256 name: consentStore,
257 });
258 }
259
260 const currentName = current.name ?? output?.name ?? "";
261 const userChanged = !sameText(current.userId, news.userId);
262 const dataChanged = !sameText(current.dataId, dataId);
263 const attrsChanged = !sameJson(
264 current.resourceAttributes,
265 news.resourceAttributes,
266 );
267
268 if (userChanged || dataChanged || attrsChanged) {
269 current = yield* retryTransient(
270 healthcare.patchProjectsLocationsDatasetsConsentStoresUserDataMappings(
271 {
272 name: currentName,
273 updateMask: updateMaskOf(
274 userChanged ? "userId" : undefined,
275 dataChanged ? "dataId" : undefined,
276 attrsChanged ? "resourceAttributes" : undefined,
277 ),
278 body: {
279 userId: news.userId,
280 dataId,
281 resourceAttributes: news.resourceAttributes,
282 },
283 },
284 ),
285 );
286 }
287
288 return toAttrs(current, env.project, env.region);
289 }),
290
291 delete: Effect.fn(function* ({ output }) {
292 if (!output.name) return;
293 yield* retryTransient(
294 healthcare.deleteProjectsLocationsDatasetsConsentStoresUserDataMappings(
295 {
296 name: output.name,
297 },
298 ),
299 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
300 yield* waitUntilGone(getByName(output.name), output.name);
301 }),
302 });