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 sameJson,
27 toPhysicalId,
28 updateMaskOf,
29 userLabels,
30 waitUntilGone,
31} from "./internal.ts";
32
33export type DicomNotificationConfig = {
34 /** Pub/Sub topic for DICOM change notifications. */
35 pubsubTopic?: string;
36};
37
38export type DicomLegacyNotificationConfig = {
39 /** Pub/Sub topic for new DICOM instances. */
40 pubsubTopic?: string;
41 /** Send notifications on bulk import. */
42 sendForBulkImport?: boolean;
43};
44
45export type DatasetsDicomStoreProps = {
46 /**
47 * Parent dataset. Full name
48 * `projects/{project}/locations/{location}/datasets/{dataset}` or the
49 * dataset id (combined with `location`). Immutable — changing it
50 * replaces the DICOM store.
51 */
52 dataset: string;
53 /**
54 * Region used when `dataset` is a bare id.
55 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
56 */
57 location?: string;
58 /**
59 * DICOM store id. Any string up to 256 characters. Immutable —
60 * changing it replaces the store.
61 */
62 dicomStoreId?: string;
63 /**
64 * User labels. Alchemy ownership labels are merged in automatically.
65 */
66 labels?: Record<string, string>;
67 /**
68 * Destinations for change notifications.
69 */
70 notificationConfigs?: DicomNotificationConfig[];
71 /**
72 * Legacy single notification destination.
73 */
74 notificationConfig?: DicomLegacyNotificationConfig;
75};
76
77export type DatasetsDicomStore = Resource<
78 "GCP.Healthcare.DatasetsDicomStore",
79 DatasetsDicomStoreProps,
80 {
81 /** Full resource name `.../datasets/{dataset}/dicomStores/{dicomStore}`. */
82 name: string;
83 /** DICOM store id. */
84 dicomStoreId: string;
85 /** Parent dataset resource name. */
86 dataset: string;
87 /** Project id. */
88 project: string;
89 /** Location id. */
90 location: string;
91 /** User labels (Alchemy ownership labels stripped). */
92 labels: Record<string, string>;
93 /** Change-notification destinations. */
94 notificationConfigs: DicomNotificationConfig[] | undefined;
95 /** Legacy notification destination. */
96 notificationConfig: DicomLegacyNotificationConfig | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A Cloud Healthcare DICOM store inside a dataset.
104 *
105 * Dataset, store id, and location are immutable. Labels and notification
106 * configs update in place.
107 *
108 * ### Creating a DICOM Store
109 * **Example:** Store with labels
110 * ```typescript
111 * const store = yield* GCP.Healthcare.DatasetsDicomStore("Images", {
112 * dataset: dataset.name,
113 * labels: { env: "test" },
114 * });
115 * ```
116 *
117 * **Example:** Named store
118 * ```typescript
119 * const store = yield* GCP.Healthcare.DatasetsDicomStore("Images", {
120 * dataset: dataset.name,
121 * dicomStoreId: "clinic-images",
122 * });
123 * ```
124 *
125 * @resource
126 * @category Healthcare
127 */
128export const DatasetsDicomStore = Resource<DatasetsDicomStore>(
129 "GCP.Healthcare.DatasetsDicomStore",
130);
131
132export class DatasetsDicomStoreNotResolved extends Data.TaggedError(
133 "GCP.Healthcare.DatasetsDicomStoreNotResolved",
134)<{
135 name: string;
136}> {}
137
138const datasetOf = (dataset: string, project: string, location: string) =>
139 expandParent(dataset, project, location, "datasets");
140
141const resourceName = (dataset: string, dicomStoreId: string) =>
142 `${dataset}/dicomStores/${dicomStoreId}`;
143
144const toAttrs = (
145 store: healthcare.DicomStore,
146 project: string,
147 region: string,
148) => {
149 const name = store.name ?? "";
150 const parsed = parseResourceName(name, "dicomStores", region);
151 return {
152 name,
153 dicomStoreId: parsed.id,
154 dataset: parsed.parent,
155 project: parsed.project || project,
156 location: parsed.location,
157 labels: userLabels(store.labels),
158 notificationConfigs: store.notificationConfigs,
159 notificationConfig: store.notificationConfig,
160 };
161};
162
163const getByName = (name: string) =>
164 name.length === 0
165 ? Effect.succeed(undefined)
166 : healthcare
167 .getProjectsLocationsDatasetsDicomStores({ name })
168 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
169
170export const DatasetsDicomStoreProvider = () =>
171 Provider.succeed(DatasetsDicomStore, {
172 stables: ["name", "dicomStoreId", "dataset", "project", "location"],
173
174 diff: Effect.fn(function* ({ news, olds, output }) {
175 if (!isResolved(news)) return undefined;
176 const env = yield* GcpEnvironment.current;
177 return replaceOnIdentity({
178 previousId: olds?.dicomStoreId ?? output?.dicomStoreId,
179 nextId: news.dicomStoreId,
180 previousParent: olds?.dataset ?? output?.dataset,
181 nextParent: datasetOf(
182 news.dataset,
183 env.project,
184 normalizeLocation(news.location ?? output?.location, env.region),
185 ),
186 });
187 }),
188
189 read: Effect.fn(function* ({ id, olds, output }) {
190 const env = yield* GcpEnvironment.current;
191 const location = normalizeLocation(
192 olds?.location ?? output?.location,
193 env.region,
194 );
195 const dicomStoreId = yield* toPhysicalId(
196 id,
197 olds?.dicomStoreId,
198 output?.dicomStoreId,
199 );
200 const dataset =
201 olds?.dataset !== undefined
202 ? datasetOf(olds.dataset, env.project, location)
203 : (output?.dataset ?? "");
204 const name =
205 output?.name ??
206 (dataset.length > 0 ? resourceName(dataset, dicomStoreId) : "");
207 const existing = yield* getByName(name);
208 if (existing === undefined) return undefined;
209 const attrs = toAttrs(existing, env.project, env.region);
210 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
211 ? attrs
212 : Unowned(attrs);
213 }),
214
215 list: () =>
216 Effect.gen(function* () {
217 const env = yield* GcpEnvironment.current;
218 const stores = yield* forEachDataset(
219 env.project,
220 env.region,
221 (parent) =>
222 collectPages(
223 healthcare.listProjectsLocationsDatasetsDicomStores.pages({
224 parent,
225 pageSize: 1000,
226 }),
227 (page) => page.dicomStores,
228 ),
229 );
230 return stores
231 .filter((store) => hasAlchemyLabelMap(store.labels))
232 .map((store) => toAttrs(store, env.project, env.region));
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output }) {
236 const env = yield* GcpEnvironment.current;
237 const location = normalizeLocation(
238 news.location ?? output?.location,
239 env.region,
240 );
241 const dataset = datasetOf(news.dataset, env.project, location);
242 const dicomStoreId = yield* toPhysicalId(
243 id,
244 news.dicomStoreId,
245 output?.dicomStoreId,
246 );
247 const name = output?.name ?? resourceName(dataset, dicomStoreId);
248 const desiredLabels = {
249 ...toLabels(news.labels),
250 ...(yield* createInternalLabels(id)),
251 };
252
253 let current = yield* getByName(name);
254
255 if (current === undefined) {
256 const created = yield* retryTransient(
257 healthcare.createProjectsLocationsDatasetsDicomStores({
258 parent: dataset,
259 dicomStoreId,
260 body: {
261 labels: desiredLabels,
262 notificationConfigs: news.notificationConfigs,
263 notificationConfig: news.notificationConfig,
264 },
265 }),
266 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
267 current = created ?? undefined;
268 }
269
270 if (current === undefined) {
271 return yield* new DatasetsDicomStoreNotResolved({ name });
272 }
273
274 const currentName = current.name ?? name;
275 const observedLabels = tagRecord(current.labels);
276 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
277 const labelsChanged = upsert.length > 0 || removed.length > 0;
278 const notificationsChanged = !sameJson(
279 current.notificationConfigs,
280 news.notificationConfigs,
281 );
282 const legacyChanged = !sameJson(
283 current.notificationConfig,
284 news.notificationConfig,
285 );
286
287 if (labelsChanged || notificationsChanged || legacyChanged) {
288 current = yield* retryTransient(
289 healthcare.patchProjectsLocationsDatasetsDicomStores({
290 name: currentName,
291 updateMask: updateMaskOf(
292 labelsChanged ? "labels" : undefined,
293 notificationsChanged ? "notificationConfigs" : undefined,
294 legacyChanged ? "notificationConfig" : undefined,
295 ),
296 body: {
297 labels: desiredLabels,
298 notificationConfigs: news.notificationConfigs,
299 notificationConfig: news.notificationConfig,
300 },
301 }),
302 );
303 }
304
305 return toAttrs(current, env.project, env.region);
306 }),
307
308 delete: Effect.fn(function* ({ output }) {
309 if (!output.name) return;
310 yield* retryTransient(
311 healthcare.deleteProjectsLocationsDatasetsDicomStores({
312 name: output.name,
313 }),
314 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
315 yield* waitUntilGone(getByName(output.name), output.name);
316 }),
317 });