1import * as healthlake from "@distilled.cloud/aws/healthlake";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface FHIRDatastoreIdentityProviderProps {
13 /**
14 * Authorization strategy for the data store — `"AWS_AUTH"` (SigV4, the
15 * default), `"SMART_ON_FHIR"` or `"SMART_ON_FHIR_V1"`.
16 */
17 authorizationStrategy: healthlake.AuthorizationStrategy;
18 /**
19 * Whether fine-grained (SMART on FHIR scope based) authorization is
20 * enabled.
21 * @default false
22 */
23 fineGrainedAuthorizationEnabled?: boolean;
24 /**
25 * JSON metadata elements the identity provider uses in its authorization
26 * flow (issuer, authorization/token endpoints, etc.).
27 */
28 metadata?: string;
29 /**
30 * ARN of the Lambda function the data store invokes to decode SMART on
31 * FHIR access tokens.
32 */
33 idpLambdaArn?: string;
34}
35
36export interface FHIRDatastoreProps {
37 /**
38 * Name of the data store. Names do not have to be unique within an
39 * account; the data store is identified by its auto-assigned id. Updated
40 * in place.
41 * @default a deterministic physical name derived from app, stage and logical id
42 */
43 datastoreName?: string;
44 /**
45 * FHIR release version supported by the data store. Only `"R4"` is
46 * currently supported. Changing this replaces the data store.
47 * @default "R4"
48 */
49 datastoreTypeVersion?: healthlake.FHIRVersion;
50 /**
51 * Customer-managed KMS key (id, ARN or alias) used to encrypt the data
52 * store at rest. Changing this replaces the data store.
53 * @default an AWS-owned KMS key
54 */
55 kmsKeyId?: string;
56 /**
57 * Preload the data store with synthetic sample data on creation —
58 * `"SYNTHEA"` loads ~100k synthetic patient records. Changing this
59 * replaces the data store.
60 * @default no preloaded data
61 */
62 preloadDataType?: healthlake.PreloadDataType;
63 /**
64 * Identity provider configuration for SMART on FHIR authorization.
65 * Updated in place.
66 * @default AWS_AUTH (SigV4)
67 */
68 identityProviderConfiguration?: FHIRDatastoreIdentityProviderProps;
69 /**
70 * User-defined tags for the data store.
71 */
72 tags?: Record<string, string>;
73}
74
75export interface FHIRDatastore extends Resource<
76 "AWS.HealthLake.FHIRDatastore",
77 FHIRDatastoreProps,
78 {
79 /** The unique ID of the datastore. */
80 datastoreId: string;
81 /** The ARN of the datastore. */
82 datastoreArn: string;
83 /** The name of the datastore. */
84 datastoreName: string;
85 /** The current status of the datastore (`ACTIVE`, `CREATING`, ...). */
86 datastoreStatus: string;
87 /** The FHIR REST API endpoint of the datastore. */
88 datastoreEndpoint: string;
89 /** The FHIR version of the datastore (`R4`). */
90 datastoreTypeVersion: string;
91 /** The tags applied to the datastore. */
92 tags: Record<string, string>;
93 },
94 never,
95 Providers
96> {}
97
98/**
99 * An AWS HealthLake FHIR-enabled data store — a HIPAA-eligible, managed
100 * store for FHIR R4 health data with a FHIR REST endpoint plus bulk
101 * import/export.
102 *
103 * Data stores take roughly 15-30 minutes to provision (`CREATING` →
104 * `ACTIVE`) and are billed while they exist; deletion is also asynchronous
105 * (`DELETING` → gone). Destroy data stores you are not using.
106 * ### Creating a Data Store
107 * **Example:** Basic FHIR R4 Data Store
108 * ```typescript
109 * const datastore = yield* FHIRDatastore("Records", {});
110 * ```
111 *
112 * **Example:** Data Store Preloaded with Synthetic Data
113 * ```typescript
114 * const datastore = yield* FHIRDatastore("Sandbox", {
115 * preloadDataType: "SYNTHEA",
116 * });
117 * ```
118 *
119 * ### Encryption
120 * **Example:** Data Store Encrypted with a Customer-Managed KMS Key
121 * ```typescript
122 * const key = yield* KMS.Key("RecordsKey", {
123 * description: "healthlake data store key",
124 * });
125 * const datastore = yield* FHIRDatastore("Records", {
126 * kmsKeyId: key.keyArn,
127 * });
128 * ```
129 *
130 * @resource
131 */
132export const FHIRDatastore = Resource<FHIRDatastore>(
133 "AWS.HealthLake.FHIRDatastore",
134);
135
136const DEFAULT_FHIR_VERSION: healthlake.FHIRVersion = "R4";
137
138const toIdpConfig = (
139 props: FHIRDatastoreIdentityProviderProps | undefined,
140): healthlake.IdentityProviderConfiguration | undefined =>
141 props === undefined
142 ? undefined
143 : {
144 AuthorizationStrategy: props.authorizationStrategy,
145 FineGrainedAuthorizationEnabled: props.fineGrainedAuthorizationEnabled,
146 Metadata: props.metadata,
147 IdpLambdaArn: props.idpLambdaArn,
148 };
149
150const sameIdpConfig = (
151 observed: healthlake.IdentityProviderConfiguration | undefined,
152 desired: healthlake.IdentityProviderConfiguration,
153): boolean =>
154 observed !== undefined &&
155 observed.AuthorizationStrategy === desired.AuthorizationStrategy &&
156 (observed.FineGrainedAuthorizationEnabled ?? false) ===
157 (desired.FineGrainedAuthorizationEnabled ?? false) &&
158 observed.Metadata === desired.Metadata &&
159 observed.IdpLambdaArn === desired.IdpLambdaArn;
160
161export const FHIRDatastoreProvider = () =>
162 Provider.effect(
163 FHIRDatastore,
164 Effect.gen(function* () {
165 const toName = (id: string, props: Partial<FHIRDatastoreProps>) =>
166 props.datastoreName
167 ? Effect.succeed(props.datastoreName)
168 : createPhysicalName({ id, maxLength: 256 });
169
170 const readDatastoreTags = Effect.fn(function* (arn: string) {
171 const response = yield* healthlake
172 .listTagsForResource({ ResourceARN: arn })
173 .pipe(Effect.catch(() => Effect.succeed({ Tags: [] })));
174 const tags: Record<string, string> = {};
175 for (const tag of response.Tags ?? []) {
176 tags[tag.Key] = tag.Value;
177 }
178 return tags;
179 });
180
181 // Describe by id; typed not-found → undefined. A data store in
182 // DELETED status still describes for a while — callers decide how to
183 // treat terminal/transitional statuses.
184 const readDatastore = Effect.fn(function* (datastoreId: string) {
185 const response = yield* healthlake
186 .describeFHIRDatastore({ DatastoreId: datastoreId })
187 .pipe(
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 return response?.DatastoreProperties;
193 });
194
195 const isGoneOrGoing = (
196 properties: healthlake.DatastoreProperties | undefined,
197 ) =>
198 properties === undefined ||
199 properties.DatastoreStatus === "DELETED" ||
200 properties.DatastoreStatus === "DELETING";
201
202 // Data store ids are auto-assigned; recover an existing instance from
203 // its deterministic name (bounded pagination, name-filtered).
204 const findByName = Effect.fn(function* (name: string) {
205 let nextToken: string | undefined;
206 for (let page = 0; page < 20; page++) {
207 const response = yield* healthlake.listFHIRDatastores({
208 Filter: { DatastoreName: name },
209 NextToken: nextToken,
210 });
211 const match = (response.DatastorePropertiesList ?? []).find(
212 (properties) => !isGoneOrGoing(properties),
213 );
214 if (match !== undefined) return match;
215 nextToken = response.NextToken;
216 if (!nextToken) break;
217 }
218 return undefined;
219 });
220
221 // Bounded readiness wait. Provisioning typically completes in 15-30
222 // minutes; budget ~35 min (70 * 30s). Terminal failure statuses stop
223 // the wait immediately.
224 const waitForActive = Effect.fn(function* (datastoreId: string) {
225 const properties = yield* readDatastore(datastoreId).pipe(
226 Effect.repeat({
227 schedule: Schedule.max([
228 Schedule.fixed("30 seconds"),
229 Schedule.recurs(70),
230 ]),
231 until: (p) =>
232 p === undefined ||
233 p.DatastoreStatus === "ACTIVE" ||
234 p.DatastoreStatus === "CREATE_FAILED" ||
235 p.DatastoreStatus === "UPDATE_FAILED" ||
236 p.DatastoreStatus === "DELETED",
237 }),
238 );
239 if (
240 properties === undefined ||
241 properties.DatastoreStatus !== "ACTIVE"
242 ) {
243 const cause = properties?.ErrorCause?.ErrorMessage;
244 return yield* Effect.fail(
245 new Error(
246 `HealthLake data store '${datastoreId}' did not become ACTIVE (status: ${
247 properties?.DatastoreStatus ?? "gone"
248 }${cause ? `: ${cause}` : ""})`,
249 ),
250 );
251 }
252 return properties;
253 });
254
255 // Wait for a data store to leave a transitional state (CREATING /
256 // UPDATING) so delete does not hit ConflictException.
257 const waitUntilSettled = Effect.fn(function* (datastoreId: string) {
258 return yield* readDatastore(datastoreId).pipe(
259 Effect.repeat({
260 schedule: Schedule.max([
261 Schedule.fixed("30 seconds"),
262 Schedule.recurs(70),
263 ]),
264 until: (p) =>
265 p === undefined ||
266 (p.DatastoreStatus !== "CREATING" &&
267 p.DatastoreStatus !== "UPDATING"),
268 }),
269 );
270 });
271
272 // Deletion is asynchronous (DELETING → gone); wait until the data
273 // store is fully gone. Budget ~35 min (70 * 30s).
274 const waitUntilGone = Effect.fn(function* (datastoreId: string) {
275 const properties = yield* readDatastore(datastoreId).pipe(
276 Effect.repeat({
277 schedule: Schedule.max([
278 Schedule.fixed("30 seconds"),
279 Schedule.recurs(70),
280 ]),
281 until: (p) => p === undefined || p.DatastoreStatus === "DELETED",
282 }),
283 );
284 if (
285 properties !== undefined &&
286 properties.DatastoreStatus !== "DELETED"
287 ) {
288 return yield* Effect.fail(
289 new Error(
290 `HealthLake data store '${datastoreId}' still exists after deletion (status: ${properties.DatastoreStatus})`,
291 ),
292 );
293 }
294 });
295
296 const toAttrs = Effect.fn(function* (
297 properties: healthlake.DatastoreProperties,
298 ) {
299 return {
300 datastoreId: properties.DatastoreId,
301 datastoreArn: properties.DatastoreArn,
302 datastoreName: properties.DatastoreName ?? "",
303 datastoreStatus: properties.DatastoreStatus,
304 datastoreEndpoint: properties.DatastoreEndpoint,
305 datastoreTypeVersion: properties.DatastoreTypeVersion,
306 tags: yield* readDatastoreTags(properties.DatastoreArn),
307 };
308 });
309
310 return {
311 stables: [
312 "datastoreId",
313 "datastoreArn",
314 "datastoreEndpoint",
315 "datastoreTypeVersion",
316 ],
317
318 diff: Effect.fn(function* ({ olds, news }) {
319 if (!isResolved(news)) return undefined;
320 const n = news ?? {};
321 const o = olds ?? {};
322 // Create-only properties force a replacement.
323 if (
324 (n.datastoreTypeVersion ?? DEFAULT_FHIR_VERSION) !==
325 (o.datastoreTypeVersion ?? DEFAULT_FHIR_VERSION)
326 ) {
327 return { action: "replace" } as const;
328 }
329 if (n.kmsKeyId !== o.kmsKeyId) {
330 return { action: "replace" } as const;
331 }
332 if (n.preloadDataType !== o.preloadDataType) {
333 return { action: "replace" } as const;
334 }
335 }),
336
337 read: Effect.fn(function* ({ id, olds, output }) {
338 let properties = output?.datastoreId
339 ? yield* readDatastore(output.datastoreId)
340 : undefined;
341 if (isGoneOrGoing(properties)) {
342 const name = yield* toName(id, olds ?? {});
343 properties = yield* findByName(name);
344 }
345 if (properties === undefined) return undefined;
346 const attrs = yield* toAttrs(properties);
347 return (yield* hasAlchemyTags(id, attrs.tags))
348 ? attrs
349 : Unowned(attrs);
350 }),
351
352 reconcile: Effect.fn(function* ({ id, news, output, session }) {
353 const props = news!;
354 const name = yield* toName(id, props);
355 const internalTags = yield* createInternalTags(id);
356 const desiredTags = { ...internalTags, ...props.tags };
357
358 // 1. Observe — cloud state is authoritative; output is only an
359 // id cache. A deleted/deleting instance is treated as missing.
360 let observed = output?.datastoreId
361 ? yield* readDatastore(output.datastoreId)
362 : undefined;
363 if (isGoneOrGoing(observed)) {
364 observed = yield* findByName(name);
365 }
366
367 // 2. Ensure — create if missing, then wait (bounded) for ACTIVE.
368 // ClientToken is an idempotency token filled in by distilled.
369 if (observed === undefined) {
370 const created = yield* healthlake.createFHIRDatastore({
371 DatastoreName: name,
372 DatastoreTypeVersion:
373 props.datastoreTypeVersion ?? DEFAULT_FHIR_VERSION,
374 SseConfiguration: props.kmsKeyId
375 ? {
376 KmsEncryptionConfig: {
377 CmkType: "CUSTOMER_MANAGED_KMS_KEY",
378 KmsKeyId: props.kmsKeyId,
379 },
380 }
381 : undefined,
382 PreloadDataConfig: props.preloadDataType
383 ? { PreloadDataType: props.preloadDataType }
384 : undefined,
385 IdentityProviderConfiguration: toIdpConfig(
386 props.identityProviderConfiguration,
387 ),
388 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
389 Key,
390 Value,
391 })),
392 });
393 observed = yield* waitForActive(created.DatastoreId);
394 } else if (observed.DatastoreStatus !== "ACTIVE") {
395 // Mid-flight provisioning or a prior update still converging.
396 observed = yield* waitForActive(observed.DatastoreId);
397 }
398
399 // 3. Sync mutable aspects — compute the update delta from
400 // OBSERVED state; skip the API entirely on no-op.
401 const update: healthlake.UpdateFHIRDatastoreRequest = {
402 DatastoreId: observed.DatastoreId,
403 };
404 let mutated = false;
405 if (observed.DatastoreName !== name) {
406 update.DatastoreName = name;
407 mutated = true;
408 }
409 const desiredIdp = toIdpConfig(props.identityProviderConfiguration);
410 if (
411 desiredIdp !== undefined &&
412 !sameIdpConfig(observed.IdentityProviderConfiguration, desiredIdp)
413 ) {
414 update.IdentityProviderConfiguration = desiredIdp;
415 mutated = true;
416 }
417 if (mutated) {
418 yield* healthlake.updateFHIRDatastore(update);
419 observed = yield* waitForActive(observed.DatastoreId);
420 }
421
422 // 3b. Sync tags — diff against OBSERVED cloud tags.
423 const observedTags = yield* readDatastoreTags(observed.DatastoreArn);
424 const { removed, upsert } = diffTags(observedTags, desiredTags);
425 if (upsert.length > 0) {
426 yield* healthlake.tagResource({
427 ResourceARN: observed.DatastoreArn,
428 Tags: upsert,
429 });
430 }
431 if (removed.length > 0) {
432 yield* healthlake.untagResource({
433 ResourceARN: observed.DatastoreArn,
434 TagKeys: removed,
435 });
436 }
437
438 yield* session.note(observed.DatastoreId);
439 return yield* toAttrs(observed);
440 }),
441
442 delete: Effect.fn(function* ({ output }) {
443 const datastoreId = output.datastoreId;
444 // A data store mid-create/update rejects deletion with
445 // ConflictException — wait (bounded) for it to settle first.
446 const settled = yield* waitUntilSettled(datastoreId).pipe(
447 Effect.catch(() => Effect.succeed(undefined)),
448 );
449 if (settled === undefined || settled.DatastoreStatus === "DELETED") {
450 return;
451 }
452 if (settled.DatastoreStatus !== "DELETING") {
453 yield* healthlake
454 .deleteFHIRDatastore({ DatastoreId: datastoreId })
455 .pipe(
456 Effect.retry({
457 while: (e) => e._tag === "ConflictException",
458 schedule: Schedule.max([
459 Schedule.fixed("15 seconds"),
460 Schedule.recurs(10),
461 ]),
462 }),
463 Effect.catchTag("ResourceNotFoundException", () =>
464 Effect.succeed(undefined),
465 ),
466 );
467 }
468 // Deletion is asynchronous — wait until the data store is gone so
469 // dependent resources (e.g. the KMS key) can be deleted next.
470 yield* waitUntilGone(datastoreId);
471 }),
472
473 list: () =>
474 Effect.gen(function* () {
475 // Bounded hand-rolled pagination.
476 const datastores: healthlake.DatastoreProperties[] = [];
477 let nextToken: string | undefined;
478 for (let page = 0; page < 20; page++) {
479 const response = yield* healthlake.listFHIRDatastores({
480 NextToken: nextToken,
481 });
482 datastores.push(...(response.DatastorePropertiesList ?? []));
483 nextToken = response.NextToken;
484 if (!nextToken) break;
485 }
486 return yield* Effect.forEach(
487 datastores.filter((properties) => !isGoneOrGoing(properties)),
488 (properties) => toAttrs(properties),
489 { concurrency: 4 },
490 );
491 }),
492 };
493 }),
494 );