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 stripInternalLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 decodeHl7,
20 encodeHl7,
21 hasAlchemyLabelMap,
22 lastSegment,
23 listRegionDatasets,
24 listHl7V2Stores,
25 listMessages,
26 parentOf,
27 retryTransient,
28 sameText,
29} from "./internal.ts";
30
31export type PatientId = {
32 /** Patient identifier value (PID-2/3/4). */
33 value?: string;
34 /** Identifier type, for example `MRN`. */
35 type?: string;
36};
37
38export type DatasetsHl7V2StoresMessageProps = {
39 /**
40 * Parent HL7v2 store resource name
41 * `projects/{project}/locations/{location}/datasets/{dataset}/hl7V2Stores/{store}`.
42 * Immutable — changing it replaces the message.
43 */
44 parent: string;
45 /**
46 * Raw HL7v2 message text. Segment terminator is CR. Alchemy
47 * base64-encodes this for the Healthcare API. Immutable — changing it
48 * replaces the message.
49 */
50 data: string;
51 /**
52 * Server-assigned message id. Set on create. Immutable — changing it
53 * replaces the message.
54 */
55 messageId?: string;
56 /**
57 * User labels. Alchemy ownership labels are merged in automatically.
58 */
59 labels?: Record<string, string>;
60};
61
62export type DatasetsHl7V2StoresMessage = Resource<
63 "GCP.Healthcare.DatasetsHl7V2StoresMessage",
64 DatasetsHl7V2StoresMessageProps,
65 {
66 /**
67 * Full resource name
68 * `projects/{project}/locations/{location}/datasets/{dataset}/hl7V2Stores/{store}/messages/{message}`.
69 */
70 name: string;
71 /** Server-assigned message id. */
72 messageId: string;
73 /** Parent HL7v2 store resource name. */
74 parent: string;
75 /** Project id. */
76 project: string;
77 /** Location id. */
78 location: string;
79 /** Raw HL7v2 message text (decoded from the API bytes). */
80 data: string;
81 /** User labels (Alchemy ownership labels stripped). */
82 labels: Record<string, string>;
83 /** Message type from MSH-9.1. */
84 messageType: string | undefined;
85 /** Sending facility from MSH-4. */
86 sendFacility: string | undefined;
87 /** Datetime the sending application sent this message (MSH-7). */
88 sendTime: string | undefined;
89 /** Patient ids parsed from PID-2, PID-3, and PID-4. */
90 patientIds: PatientId[];
91 /** RFC3339 creation timestamp. */
92 createTime: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * An HL7v2 message stored in a Cloud Healthcare HL7v2 store.
100 *
101 * Message ids are server-assigned. Parent store and raw `data` are
102 * identity — changing either replaces the message. Labels update in
103 * place (`updateMask=labels`).
104 *
105 * ### Creating a Message
106 * **Example:** ADT^A01 into an existing store
107 * ```typescript
108 * const message = yield* GCP.Healthcare.DatasetsHl7V2StoresMessage("Adt", {
109 * parent: store.name,
110 * data: [
111 * "MSH|^~\\&|APP|FACILITY|DEST|DESTFAC|20240101120000||ADT^A01|MSG00001|P|2.5",
112 * "PID|1||PAT001^^^MR||DOE^JOHN",
113 * ].join("\r"),
114 * labels: { env: "test" },
115 * });
116 * ```
117 *
118 * ### Updating a Message
119 * Change props on the same logical id; the engine keeps the physical id.
120 *
121 * **Example:** Relabel an existing message
122 * ```typescript
123 * const message = yield* GCP.Healthcare.DatasetsHl7V2StoresMessage("Adt", {
124 * parent: store.name,
125 * data: [
126 * "MSH|^~\\&|APP|FACILITY|DEST|DESTFAC|20240101120000||ADT^A01|MSG00001|P|2.5",
127 * "PID|1||PAT001^^^MR||DOE^JOHN",
128 * ].join("\r"),
129 * labels: { env: "prod" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category Healthcare
135 */
136export const DatasetsHl7V2StoresMessage = Resource<DatasetsHl7V2StoresMessage>(
137 "GCP.Healthcare.DatasetsHl7V2StoresMessage",
138);
139
140export class DatasetsHl7V2StoresMessageNotResolved extends Data.TaggedError(
141 "GCP.Healthcare.DatasetsHl7V2StoresMessageNotResolved",
142)<{
143 parent: string;
144 messageId: string;
145}> {}
146
147const userLabels = (
148 labels: Record<string, string | undefined> | null | undefined,
149): Record<string, string> => stripInternalLabels(tagRecord(labels));
150
151const resourceNameOf = (parent: string, messageId: string) =>
152 `${parent}/messages/${messageId}`;
153
154const locationOf = (name: string, fallback: string) => {
155 const parts = name.split("/");
156 const index = parts.indexOf("locations");
157 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
158};
159
160const projectOf = (name: string, fallback: string) => {
161 const parts = name.split("/");
162 const index = parts.indexOf("projects");
163 return index >= 0 ? (parts[index + 1] ?? fallback) : fallback;
164};
165
166const toAttrs = (
167 message: healthcare.Message,
168 project: string,
169 data: string,
170 region: string,
171) => {
172 const name = message.name ?? "";
173 return {
174 name,
175 messageId: lastSegment(name),
176 parent: parentOf(name),
177 project: projectOf(name, project),
178 location: locationOf(name, region),
179 data,
180 labels: userLabels(message.labels),
181 messageType: message.messageType,
182 sendFacility: message.sendFacility,
183 sendTime: message.sendTime,
184 patientIds: (message.patientIds ?? []).map((id) => ({
185 value: id.value,
186 type: id.type,
187 })),
188 createTime: message.createTime,
189 };
190};
191
192const getByName = (name: string) =>
193 name.length === 0
194 ? Effect.succeed(undefined)
195 : healthcare
196 .getProjectsLocationsDatasetsHl7V2StoresMessages({
197 name,
198 view: "FULL",
199 })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202const findOwned = (parent: string, id: string) =>
203 Effect.gen(function* () {
204 const items = yield* listMessages(parent);
205 for (const item of items) {
206 if (yield* hasAlchemyLabels(id, tagRecord(item.labels))) {
207 return item;
208 }
209 }
210 return undefined;
211 });
212
213const listOwnedMessages = (project: string, region: string) =>
214 Effect.gen(function* () {
215 const datasets = yield* listRegionDatasets(project, region);
216 const named = datasets.filter((dataset) => (dataset.name ?? "").length > 0);
217 const stores = yield* Effect.forEach(
218 named,
219 (dataset) => listHl7V2Stores(dataset.name!),
220 { concurrency: 4 },
221 );
222 const storeNames = stores
223 .flat()
224 .map((store) => store.name)
225 .filter((name): name is string => (name ?? "").length > 0);
226 const pages = yield* Effect.forEach(storeNames, listMessages, {
227 concurrency: 4,
228 });
229 return pages.flat().filter((message) => hasAlchemyLabelMap(message.labels));
230 });
231
232export const DatasetsHl7V2StoresMessageProvider = () =>
233 Provider.succeed(DatasetsHl7V2StoresMessage, {
234 stables: [
235 "name",
236 "messageId",
237 "parent",
238 "project",
239 "location",
240 "createTime",
241 ],
242
243 diff: Effect.fn(function* ({ news, olds, output }) {
244 if (!isResolved(news)) return undefined;
245 const previousParent = olds?.parent ?? output?.parent;
246 if (previousParent !== undefined && news.parent !== previousParent) {
247 return { action: "replace" as const, deleteFirst: false };
248 }
249 const previousId = olds?.messageId ?? output?.messageId;
250 if (
251 previousId !== undefined &&
252 news.messageId !== undefined &&
253 news.messageId !== previousId
254 ) {
255 return { action: "replace" as const, deleteFirst: false };
256 }
257 const previousData = olds?.data ?? output?.data;
258 if (previousData !== undefined && news.data !== previousData) {
259 return { action: "replace" as const, deleteFirst: false };
260 }
261 return undefined;
262 }),
263
264 read: Effect.fn(function* ({ id, olds, output }) {
265 const env = yield* GcpEnvironment.current;
266 const parent = olds?.parent ?? output?.parent ?? "";
267 const messageId = olds?.messageId ?? output?.messageId;
268 const name =
269 output?.name ??
270 (messageId !== undefined && parent.length > 0
271 ? resourceNameOf(parent, messageId)
272 : "");
273 let existing = yield* getByName(name);
274 if (existing === undefined) {
275 existing = yield* findOwned(parent, id);
276 }
277 if (existing === undefined) return undefined;
278 const data = yield* decodeHl7(existing.data);
279 const attrs = toAttrs(existing, env.project, data, env.region);
280 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
281 ? attrs
282 : Unowned(attrs);
283 }),
284
285 list: () =>
286 Effect.gen(function* () {
287 const env = yield* GcpEnvironment.current;
288 const messages = yield* listOwnedMessages(env.project, env.region);
289 return yield* Effect.forEach(messages, (message) =>
290 Effect.gen(function* () {
291 const data = yield* decodeHl7(message.data);
292 return toAttrs(message, env.project, data, env.region);
293 }),
294 );
295 }),
296
297 reconcile: Effect.fn(function* ({ id, news, output }) {
298 const env = yield* GcpEnvironment.current;
299 const parent = news.parent;
300 const desiredLabels = {
301 ...toLabels(news.labels),
302 ...(yield* createInternalLabels(id)),
303 };
304 const encoded = yield* encodeHl7(news.data);
305 const name =
306 output?.name ??
307 (news.messageId !== undefined
308 ? resourceNameOf(parent, news.messageId)
309 : "");
310
311 let current = yield* getByName(name);
312 if (current === undefined) {
313 current = yield* findOwned(parent, id);
314 }
315
316 if (current === undefined) {
317 const created = yield* retryTransient(
318 healthcare.createProjectsLocationsDatasetsHl7V2StoresMessages({
319 parent,
320 body: {
321 message: {
322 data: encoded,
323 labels: desiredLabels,
324 },
325 },
326 }),
327 ).pipe(Effect.catchTag("Conflict", () => findOwned(parent, id)));
328 current = created ?? undefined;
329 }
330
331 if (current === undefined) {
332 return yield* new DatasetsHl7V2StoresMessageNotResolved({
333 parent,
334 messageId: news.messageId ?? output?.messageId ?? "",
335 });
336 }
337
338 const currentName = current.name ?? name;
339 const observedLabels = tagRecord(current.labels);
340 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
341 const labelsChanged = upsert.length > 0 || removed.length > 0;
342
343 if (labelsChanged) {
344 current = yield* retryTransient(
345 healthcare.patchProjectsLocationsDatasetsHl7V2StoresMessages({
346 name: currentName,
347 updateMask: "labels",
348 body: {
349 labels: desiredLabels,
350 },
351 }),
352 );
353 }
354
355 const data = sameText(current.data, encoded)
356 ? news.data
357 : yield* decodeHl7(current.data);
358 return toAttrs(current, env.project, data, env.region);
359 }),
360
361 delete: Effect.fn(function* ({ output }) {
362 if (output.name.length === 0) return;
363 yield* retryTransient(
364 healthcare.deleteProjectsLocationsDatasetsHl7V2StoresMessages({
365 name: output.name,
366 }),
367 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
368 }),
369 });