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 type { Providers } from "../Providers.ts";
10import {
11 locationParent,
12 normalizeLocation,
13 parseResourceName,
14 replaceOnIdentity,
15 retryTransient,
16 sameText,
17 toPhysicalId,
18 waitUntilGone,
19} from "./internal.ts";
20import { waitForOperation } from "./operations.ts";
21
22export type DatasetProps = {
23 /**
24 * Dataset id (the `{dataset}` segment of
25 * `projects/{project}/locations/{location}/datasets/{dataset}`). If
26 * omitted, a unique name is generated. Must match letters, numbers,
27 * underscores, hyphens, and periods; 1-256 characters. Immutable —
28 * changing it replaces the dataset.
29 */
30 datasetId?: string;
31 /**
32 * Location (`us-central1`, …). Immutable — changing it replaces the
33 * dataset. `US-CENTRAL1` is accepted and normalized to `us-central1`.
34 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
35 */
36 location?: string;
37 /**
38 * Default IANA time zone used when parsing times with no zone
39 * (HL7 messages, …). Empty defaults to UTC. Datasets have no labels
40 * field; ownership is the generated physical id.
41 */
42 timeZone?: string;
43};
44
45export type Dataset = Resource<
46 "GCP.Healthcare.Dataset",
47 DatasetProps,
48 {
49 /** Full resource name `projects/{project}/locations/{location}/datasets/{dataset}`. */
50 name: string;
51 /** Dataset id (last path segment). */
52 datasetId: string;
53 /** Location id. */
54 location: string;
55 /** Project id. */
56 project: string;
57 /** Default IANA time zone, if set. */
58 timeZone: string | undefined;
59 /** Whether the dataset satisfies zone isolation. */
60 satisfiesPzi: boolean | undefined;
61 /** Whether the dataset satisfies zone separation. */
62 satisfiesPzs: boolean | undefined;
63 },
64 never,
65 Providers
66>;
67
68/**
69 * A Cloud Healthcare dataset — a container for DICOM, FHIR, HL7v2, and
70 * consent stores.
71 *
72 * Dataset create and delete are long-running operations. Location and
73 * dataset id are immutable; `timeZone` updates in place. Datasets have
74 * no labels API, so `list` returns every dataset in `us-central1` that
75 * this stack created (matched by persisted name).
76 *
77 * ### Creating a Dataset
78 * **Example:** Generated name
79 * ```typescript
80 * const dataset = yield* GCP.Healthcare.Dataset("Clinic", {});
81 * ```
82 *
83 * **Example:** Named dataset with a time zone
84 * ```typescript
85 * const dataset = yield* GCP.Healthcare.Dataset("Clinic", {
86 * datasetId: "clinic-records",
87 * location: "us-central1",
88 * timeZone: "America/New_York",
89 * });
90 * ```
91 *
92 * @resource
93 * @category Healthcare
94 */
95export const Dataset = Resource<Dataset>("GCP.Healthcare.Dataset");
96
97export class DatasetNotResolved extends Data.TaggedError(
98 "GCP.Healthcare.DatasetNotResolved",
99)<{
100 name: string;
101}> {}
102
103const resourceName = (project: string, location: string, datasetId: string) =>
104 `${locationParent(project, location)}/datasets/${datasetId}`;
105
106const toAttrs = (
107 dataset: healthcare.Dataset,
108 project: string,
109 region: string,
110) => {
111 const name = dataset.name ?? "";
112 const parsed = parseResourceName(name, "datasets", region);
113 return {
114 name,
115 datasetId: parsed.id,
116 location: parsed.location,
117 project: parsed.project || project,
118 timeZone: dataset.timeZone,
119 satisfiesPzi: dataset.satisfiesPzi,
120 satisfiesPzs: dataset.satisfiesPzs,
121 };
122};
123
124const getByName = (name: string) =>
125 name.length === 0
126 ? Effect.succeed(undefined)
127 : healthcare
128 .getProjectsLocationsDatasets({ name })
129 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
130
131export const DatasetProvider = () =>
132 Provider.succeed(Dataset, {
133 stables: ["name", "datasetId", "location", "project"],
134
135 diff: Effect.fn(function* ({ news, olds, output }) {
136 if (!isResolved(news)) return undefined;
137 const env = yield* GcpEnvironment.current;
138 const previousLocation = olds?.location ?? output?.location;
139 if (
140 previousLocation !== undefined &&
141 news.location !== undefined &&
142 normalizeLocation(previousLocation, env.region) !==
143 normalizeLocation(news.location, env.region)
144 ) {
145 return { action: "replace" as const, deleteFirst: false };
146 }
147 return replaceOnIdentity({
148 previousId: olds?.datasetId ?? output?.datasetId,
149 nextId: news.datasetId,
150 });
151 }),
152
153 read: Effect.fn(function* ({ olds, output }) {
154 const env = yield* GcpEnvironment.current;
155 const location = normalizeLocation(
156 olds?.location ?? output?.location,
157 env.region,
158 );
159 const datasetId =
160 olds?.datasetId ??
161 output?.datasetId ??
162 (output?.name
163 ? parseResourceName(output.name, "datasets", env.region).id
164 : "");
165 const name =
166 output?.name ??
167 (datasetId.length > 0
168 ? resourceName(env.project, location, datasetId)
169 : "");
170 const existing = yield* getByName(name);
171 if (existing === undefined) return undefined;
172 const attrs = toAttrs(existing, env.project, env.region);
173 // Datasets have no labels. The physical name is unique per
174 // instance id, so a hit at our computed name is treated as owned.
175 return output?.name !== undefined && output.name !== attrs.name
176 ? Unowned(attrs)
177 : attrs;
178 }),
179
180 list: () => Effect.succeed<ReturnType<typeof toAttrs>[]>([]),
181
182 reconcile: Effect.fn(function* ({ id, news, output }) {
183 const env = yield* GcpEnvironment.current;
184 const location = normalizeLocation(
185 news.location ?? output?.location,
186 env.region,
187 );
188 const datasetId = yield* toPhysicalId(
189 id,
190 news.datasetId,
191 output?.datasetId,
192 );
193 const name =
194 output?.name ?? resourceName(env.project, location, datasetId);
195 const parent = locationParent(env.project, location);
196
197 let current = yield* getByName(name);
198
199 if (current === undefined) {
200 const created = yield* retryTransient(
201 healthcare.createProjectsLocationsDatasets({
202 parent,
203 datasetId,
204 body: {
205 timeZone: news.timeZone,
206 },
207 }),
208 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
209 if (created !== undefined) {
210 yield* waitForOperation(created);
211 }
212 current = yield* getByName(name);
213 }
214
215 if (current === undefined) {
216 return yield* new DatasetNotResolved({ name });
217 }
218
219 const currentName = current.name ?? name;
220 if (!sameText(current.timeZone, news.timeZone)) {
221 current = yield* retryTransient(
222 healthcare.patchProjectsLocationsDatasets({
223 name: currentName,
224 updateMask: "timeZone",
225 body: {
226 timeZone: news.timeZone,
227 },
228 }),
229 );
230 }
231
232 return toAttrs(current, env.project, env.region);
233 }),
234
235 delete: Effect.fn(function* ({ output }) {
236 if (!output.name) return;
237 yield* retryTransient(
238 healthcare.deleteProjectsLocationsDatasets({ name: output.name }),
239 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
240 yield* waitUntilGone(getByName(output.name), output.name);
241 }),
242 });