1import * as healthcare from "@distilled.cloud/gcp/healthcare_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import {
9 alchemyLabelKeys,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13 toLabels,
14} from "../Labels.ts";
15import { isTransientGcpError } from "../Errors.ts";
16
17export const MAX_ID_LENGTH = 256;
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const parentOf = (name: string) => {
26 const parts = name.split("/").filter((part) => part.length > 0);
27 return parts.slice(0, -2).join("/");
28};
29
30export const locationParent = (project: string, location: string) =>
31 `projects/${project}/locations/${location}`;
32
33export const normalizeLocation = (
34 location: string | undefined,
35 defaultLocation: string,
36) => lastSegment(location ?? defaultLocation).toLowerCase();
37
38export const sameText = (left: string | undefined, right: string | undefined) =>
39 (left ?? "") === (right ?? "");
40
41export const sameJson = (left: unknown, right: unknown) =>
42 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
43
44export const updateMaskOf = (...fields: Array<string | undefined>) =>
45 fields.filter((field): field is string => field !== undefined).join(",");
46
47export const parseResourceName = (
48 name: string,
49 collection: string,
50 defaultLocation: string,
51) => {
52 const parts = name.split("/").filter((part) => part.length > 0);
53 const collectionAt = parts.lastIndexOf(collection);
54 const locationsAt = parts.lastIndexOf("locations");
55 const projectsAt = parts.lastIndexOf("projects");
56 return {
57 project:
58 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
59 location:
60 locationsAt >= 0 && parts[locationsAt + 1]
61 ? parts[locationsAt + 1]!
62 : defaultLocation,
63 id:
64 collectionAt >= 0 && parts[collectionAt + 1]
65 ? parts[collectionAt + 1]!
66 : lastSegment(name),
67 parent:
68 collectionAt > 0
69 ? parts.slice(0, collectionAt).join("/")
70 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
71 };
72};
73
74export const expandParent = (
75 value: string,
76 project: string,
77 location: string,
78 collection: string,
79) => {
80 if (value.includes("/")) return value.replace(/\/+$/, "");
81 return `projects/${project}/locations/${location}/${collection}/${value}`;
82};
83
84export const userLabels = (
85 labels: Record<string, string | undefined> | null | undefined,
86): Record<string, string> => stripInternalLabels(tagRecord(labels));
87
88export const hasAlchemyLabelMap = (
89 labels: Record<string, string | undefined> | null | undefined,
90) => Object.keys(labels ?? {}).some((key) => key.startsWith("alchemy-"));
91
92export const withOwnershipMetadata = (
93 metadata: Record<string, string> | undefined,
94 ownership: Record<string, string>,
95) => ({ ...toLabels(metadata), ...ownership });
96
97export const encodeDataId = (
98 labels: Record<string, string>,
99 dataId: string,
100): string => `alc-${labels[alchemyLabelKeys.id] ?? "x"}-${dataId}`;
101
102export const decodeDataId = (
103 dataId: string | undefined,
104): string | undefined => {
105 if (dataId === undefined) return undefined;
106 if (!dataId.startsWith("alc-")) return dataId;
107 const rest = dataId.slice("alc-".length);
108 const dash = rest.indexOf("-");
109 return dash >= 0 ? rest.slice(dash + 1) : dataId;
110};
111
112export const hasOwnedDataId = (dataId: string | undefined) =>
113 (dataId ?? "").startsWith("alc-");
114
115export const toPhysicalId = (
116 id: string,
117 explicit: string | undefined,
118 existing: string | undefined,
119 maxLength = MAX_ID_LENGTH,
120) =>
121 Effect.gen(function* () {
122 if (explicit !== undefined) return explicit;
123 if (existing !== undefined) return existing;
124 const generated = yield* createPhysicalName({
125 id,
126 maxLength,
127 lowercase: true,
128 });
129 const next = /^[a-z]/.test(generated)
130 ? generated
131 : `h${generated}`.slice(0, maxLength);
132 return next.length >= 1 ? next : "h";
133 });
134
135export const toPhysicalSnake = (
136 id: string,
137 explicit: string | undefined,
138 existing: string | undefined,
139 maxLength = MAX_ID_LENGTH,
140) =>
141 Effect.gen(function* () {
142 if (explicit !== undefined) return explicit;
143 if (existing !== undefined) return existing;
144 const generated = yield* createPhysicalName({
145 id,
146 maxLength,
147 lowercase: true,
148 delimiter: "_",
149 });
150 let next = generated.replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_");
151 if (!/^[a-z]/.test(next)) next = `a${next}`.slice(0, maxLength);
152 next = next.slice(0, maxLength).replace(/_+$/g, "");
153 if (next.length === 0) next = "attr";
154 if (!next.startsWith("attr_")) {
155 next = `attr_${next}`.slice(0, maxLength);
156 }
157 return next;
158 });
159
160export const replaceOnIdentity = (input: {
161 previousId?: string;
162 nextId?: string;
163 previousParent?: string;
164 nextParent?: string;
165 extra?: boolean;
166}) => {
167 if (input.extra === true) {
168 return { action: "replace" as const, deleteFirst: false };
169 }
170 if (
171 input.previousId !== undefined &&
172 input.nextId !== undefined &&
173 input.previousId !== input.nextId
174 ) {
175 return { action: "replace" as const, deleteFirst: false };
176 }
177 if (
178 input.previousParent !== undefined &&
179 input.nextParent !== undefined &&
180 input.previousParent !== input.nextParent
181 ) {
182 return { action: "replace" as const, deleteFirst: false };
183 }
184 return undefined;
185};
186
187export const retryTransient = <A, E extends { readonly _tag: string }, R>(
188 effect: Effect.Effect<A, E, R>,
189) =>
190 effect.pipe(
191 Effect.retry({
192 while: (error) => isTransientGcpError(error) || error._tag === "NotFound",
193 times: 8,
194 schedule: Schedule.exponential("250 millis"),
195 }),
196 );
197
198export class DeleteNotConfirmed extends Data.TaggedError(
199 "GCP.Healthcare.DeleteNotConfirmed",
200)<{}> {}
201
202/** Poll until the resource is gone; fails if it is still readable after ~60s. */
203export const waitUntilGone = <A, E, R>(
204 get: Effect.Effect<A | undefined, E, R>,
205 _name: string,
206) =>
207 get.pipe(
208 Effect.repeat({
209 schedule: Schedule.spaced("2 seconds"),
210 until: (value) => value === undefined,
211 times: 30,
212 }),
213 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
214 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
215 ),
216 );
217
218const emptyList = <A>() => Effect.succeed<A[]>([]);
219
220export const collectPages = <
221 Page,
222 Item,
223 E extends { readonly _tag: string },
224 R,
225>(
226 pages: Stream.Stream<Page, E, R>,
227 items: (page: Page) => readonly Item[] | null | undefined,
228) =>
229 pages.pipe(
230 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
231 Stream.runCollect,
232 Effect.map((chunk) => Array.from(chunk) as Item[]),
233 Effect.catchIf(
234 (error): error is E & { readonly _tag: "NotFound" } =>
235 error._tag === "NotFound",
236 () => emptyList<Item>(),
237 ),
238 );
239
240export const encodeHl7 = (value: string) =>
241 Effect.sync(() => Buffer.from(value, "utf8").toString("base64"));
242
243export const decodeHl7 = (value: string | undefined) =>
244 Effect.sync(() => {
245 if (value === undefined || value.length === 0) return "";
246 return Buffer.from(value, "base64").toString("utf8");
247 });
248
249export const listDatasets = (parent: string) =>
250 parent.length === 0
251 ? emptyList<healthcare.Dataset>()
252 : healthcare.listProjectsLocationsDatasets
253 .pages({ parent, pageSize: 100 })
254 .pipe(
255 Stream.flatMap((page) => Stream.fromIterable(page.datasets ?? [])),
256 Stream.runCollect,
257 Effect.map((chunk) => Array.from(chunk)),
258 Effect.catchTag("NotFound", () => emptyList<healthcare.Dataset>()),
259 );
260
261export const listHl7V2Stores = (parent: string) =>
262 parent.length === 0
263 ? emptyList<healthcare.Hl7V2Store>()
264 : healthcare.listProjectsLocationsDatasetsHl7V2Stores
265 .pages({ parent, pageSize: 100 })
266 .pipe(
267 Stream.flatMap((page) => Stream.fromIterable(page.hl7V2Stores ?? [])),
268 Stream.runCollect,
269 Effect.map((chunk) => Array.from(chunk)),
270 Effect.catchTag("NotFound", () => emptyList<healthcare.Hl7V2Store>()),
271 );
272
273export const listMessages = (parent: string) =>
274 parent.length === 0
275 ? emptyList<healthcare.Message>()
276 : healthcare.listProjectsLocationsDatasetsHl7V2StoresMessages
277 .pages({ parent, pageSize: 100, view: "FULL" })
278 .pipe(
279 Stream.flatMap((page) =>
280 Stream.fromIterable(page.hl7V2Messages ?? []),
281 ),
282 Stream.runCollect,
283 Effect.map((chunk) => Array.from(chunk)),
284 Effect.catchTag("NotFound", () => emptyList<healthcare.Message>()),
285 );
286
287// `us-central1` was the fixed default before `GCP.Region`; list paths keep
288// it alongside the stack region so datasets created under it are found.
289export const listRegionDatasets = (project: string, region: string) =>
290 Effect.forEach([...new Set([region, "us-central1"])], (location) =>
291 listDatasets(locationParent(project, location)),
292 ).pipe(Effect.map((groups) => groups.flat()));
293
294export const forEachDataset = <A, E, R>(
295 project: string,
296 region: string,
297 list: (parent: string) => Effect.Effect<A[], E, R>,
298) =>
299 Effect.gen(function* () {
300 const datasets = yield* listRegionDatasets(project, region);
301 const named = datasets.filter((dataset) => (dataset.name ?? "").length > 0);
302 const groups = yield* Effect.forEach(
303 named,
304 (dataset) => list(dataset.name!),
305 { concurrency: 4 },
306 );
307 return groups.flat();
308 });
309
310export const listAlchemyConsentStores = (project: string, region: string) =>
311 forEachDataset(project, region, (parent) =>
312 collectPages(
313 healthcare.listProjectsLocationsDatasetsConsentStores.pages({
314 parent,
315 pageSize: 1000,
316 }),
317 (page) => page.consentStores,
318 ),
319 ).pipe(
320 Effect.map((stores) =>
321 stores.filter((store) => hasAlchemyLabelMap(store.labels)),
322 ),
323 );
324
325const markerOf = (
326 labels: Record<string, string>,
327 stack: string,
328 stage: string,
329 id: string,
330) =>
331 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
332
333const fitMarker = (labels: Record<string, string>, maxLength: number) => {
334 let stack = labels[alchemyLabelKeys.stack] ?? "x";
335 let stage = labels[alchemyLabelKeys.stage] ?? "x";
336 let id = labels[alchemyLabelKeys.id] ?? "x";
337 let marker = markerOf(labels, stack, stage, id);
338 while (
339 marker.length > maxLength &&
340 (stack.length > 1 || stage.length > 1 || id.length > 1)
341 ) {
342 if (stack.length >= stage.length && stack.length >= id.length) {
343 stack = stack.slice(0, -1);
344 } else if (stage.length >= id.length) {
345 stage = stage.slice(0, -1);
346 } else {
347 id = id.slice(0, -1);
348 }
349 marker = markerOf(labels, stack, stage, id);
350 }
351 return marker.slice(0, maxLength);
352};
353
354export const encodeOwnership = (
355 labels: Record<string, string>,
356 text: string | undefined,
357): string => {
358 const marker = fitMarker(labels, 8000);
359 const trimmed = text?.trim();
360 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
361};
362
363export const parseOwnership = (
364 text: string | undefined,
365): {
366 labels: Record<string, string>;
367 text: string | undefined;
368} => {
369 if (!text?.startsWith("[alchemy ")) {
370 return { labels: {}, text };
371 }
372 const end = text.indexOf("]");
373 if (end < 0) return { labels: {}, text };
374 const labels: Record<string, string> = {};
375 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
376 const eq = part.indexOf("=");
377 if (eq > 0) {
378 labels[part.slice(0, eq)] = part.slice(eq + 1);
379 }
380 }
381 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
382 return { labels, text: rest.length > 0 ? rest : undefined };
383};
384
385export const hasOwnershipMarker = (text: string | undefined) =>
386 Object.keys(parseOwnership(text).labels).some((key) =>
387 key.startsWith("alchemy-"),
388 );
389
390const prefixMatch = (expected: string, observed: string) =>
391 expected === observed ||
392 expected.startsWith(observed) ||
393 observed.startsWith(expected);
394
395export const ownedByAlchemy = (id: string, text: string | undefined) =>
396 Effect.gen(function* () {
397 const expected = yield* createInternalLabels(id);
398 const { labels } = parseOwnership(text);
399 if (!hasOwnershipMarker(text)) return false;
400 const exact = yield* hasAlchemyLabels(id, labels);
401 if (exact) return true;
402 return (
403 prefixMatch(
404 expected[alchemyLabelKeys.stack] ?? "",
405 labels[alchemyLabelKeys.stack] ?? "",
406 ) &&
407 prefixMatch(
408 expected[alchemyLabelKeys.stage] ?? "",
409 labels[alchemyLabelKeys.stage] ?? "",
410 ) &&
411 prefixMatch(
412 expected[alchemyLabelKeys.id] ?? "",
413 labels[alchemyLabelKeys.id] ?? "",
414 )
415 );
416 });