1import * as medicalimaging from "@distilled.cloud/aws/medical-imaging";
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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface DatastoreProps {
15 /**
16 * Name of the data store. If omitted, a deterministic physical name is
17 * generated. AWS HealthImaging has no update operation, so changing the
18 * name replaces the data store.
19 */
20 datastoreName?: string;
21 /**
22 * ARN of a customer-managed KMS key used to encrypt the data store at
23 * rest. Changing the key replaces the data store.
24 * @default AWS-owned key
25 */
26 kmsKeyArn?: string;
27 /**
28 * ARN of a Lambda authorizer used to gate data access. Changing the
29 * authorizer replaces the data store.
30 */
31 lambdaAuthorizerArn?: string;
32 /**
33 * Lossless compression format for stored image frames — `"HTJ2K"` or
34 * `"JPEG_2000_LOSSLESS"`. Changing the format replaces the data store.
35 * @default "HTJ2K"
36 */
37 losslessStorageFormat?: string;
38 /**
39 * User-defined tags for the data store.
40 */
41 tags?: Record<string, string>;
42}
43
44export interface Datastore extends Resource<
45 "AWS.MedicalImaging.Datastore",
46 DatastoreProps,
47 {
48 /** Server-assigned unique id of the data store. */
49 datastoreId: string;
50 /** Name of the data store. */
51 datastoreName: string;
52 /** ARN of the data store. */
53 datastoreArn: string;
54 /** Current lifecycle status (e.g. `CREATING`, `ACTIVE`). */
55 datastoreStatus: string;
56 /** ARN of the KMS key encrypting the data store, if customer-managed. */
57 kmsKeyArn: string | undefined;
58 /** Tags on the data store (user + internal Alchemy tags). */
59 tags: Record<string, string>;
60 },
61 never,
62 Providers
63> {}
64
65/**
66 * An AWS HealthImaging data store — a HIPAA-eligible store for DICOM P10
67 * medical images with sub-second image-frame retrieval.
68 *
69 * Data stores provision asynchronously (CREATING → ACTIVE, typically a few
70 * minutes) and must be empty (no image sets) before they can be deleted.
71 * HealthImaging has no update API, so every property except tags triggers a
72 * replacement.
73 * ### Creating a Data Store
74 * **Example:** Basic Data Store
75 * ```typescript
76 * const datastore = yield* Datastore("Imaging", {});
77 * ```
78 *
79 * **Example:** Data Store with a Customer-Managed KMS Key
80 * ```typescript
81 * const datastore = yield* Datastore("Imaging", {
82 * kmsKeyArn: key.keyArn,
83 * tags: { team: "radiology" },
84 * });
85 * ```
86 *
87 * ### Importing DICOM Data
88 * **Example:** Reference the Data Store Id
89 * ```typescript
90 * const datastore = yield* Datastore("Imaging", {});
91 * // startDICOMImportJob and image-set APIs address the store by id
92 * const id = datastore.datastoreId;
93 * ```
94 *
95 * @resource
96 */
97export const Datastore = Resource<Datastore>("AWS.MedicalImaging.Datastore");
98
99/**
100 * Raised when a data store never reaches `ACTIVE` (or its create fails)
101 * within the bounded provisioning wait.
102 */
103export class DatastoreNotReady extends Data.TaggedError("DatastoreNotReady")<{
104 message: string;
105}> {}
106
107/**
108 * Raised when the HealthImaging API omits an expected field (e.g. the
109 * data store ARN) from a response.
110 */
111export class DatastoreIncomplete extends Data.TaggedError(
112 "DatastoreIncomplete",
113)<{ message: string }> {}
114
115/** CreateDatastore requires a client token; derive it from the instance id
116 * so engine retries of the same instance are idempotent. */
117const createToken = (instanceId: string): string =>
118 instanceId.replaceAll(/[^a-zA-Z0-9]/g, "").slice(0, 64) || "alchemy";
119
120/** Provisioning typically completes in a few minutes; budget 40 × 15s. */
121const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
122 self: Effect.Effect<A, E, R>,
123): Effect.Effect<A, E, R> =>
124 Effect.retry(self, {
125 while: (e) => e._tag === "DatastoreNotReady",
126 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(40)]),
127 });
128
129/** A data store mid-create rejects deletion with ConflictException — retry
130 * (bounded) while it settles. */
131const retryWhileConflict = <A, E extends { readonly _tag: string }, R>(
132 self: Effect.Effect<A, E, R>,
133): Effect.Effect<A, E, R> =>
134 Effect.retry(self, {
135 while: (e) => e._tag === "ConflictException",
136 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(20)]),
137 });
138
139const toTagRecord = (
140 tags: { [key: string]: string | undefined } | undefined,
141): Record<string, string> =>
142 Object.fromEntries(
143 Object.entries(tags ?? {}).filter(
144 (entry): entry is [string, string] => entry[1] !== undefined,
145 ),
146 );
147
148export const DatastoreProvider = () =>
149 Provider.effect(
150 Datastore,
151 Effect.gen(function* () {
152 const toName = (id: string, props: Partial<DatastoreProps>) =>
153 props.datastoreName
154 ? Effect.succeed(props.datastoreName)
155 : createPhysicalName({ id, maxLength: 64 });
156
157 const getById = Effect.fn(function* (datastoreId: string) {
158 const response = yield* medicalimaging
159 .getDatastore({ datastoreId })
160 .pipe(
161 Effect.catchTag("ResourceNotFoundException", () =>
162 Effect.succeed(undefined),
163 ),
164 );
165 const properties = response?.datastoreProperties;
166 if (properties === undefined) return undefined;
167 if (
168 properties.datastoreStatus === "DELETED" ||
169 properties.datastoreStatus === "DELETING"
170 ) {
171 return undefined;
172 }
173 return properties;
174 });
175
176 // Data stores are addressed by a server-generated id; a name-based
177 // lookup scans the paginated list (names are not unique — take the
178 // first live match).
179 const findByName = Effect.fn(function* (name: string) {
180 const matches = yield* medicalimaging.listDatastores.items({}).pipe(
181 Stream.filter(
182 (summary) =>
183 summary.datastoreName === name &&
184 summary.datastoreStatus !== "DELETED" &&
185 summary.datastoreStatus !== "DELETING",
186 ),
187 Stream.take(1),
188 Stream.runCollect,
189 );
190 const summary = Array.from(matches)[0];
191 if (summary === undefined) return undefined;
192 return yield* getById(summary.datastoreId);
193 });
194
195 const observe = Effect.fn(function* (
196 datastoreId: string | undefined,
197 name: string,
198 ) {
199 if (datastoreId !== undefined) {
200 const properties = yield* getById(datastoreId);
201 if (properties !== undefined) return properties;
202 }
203 return yield* findByName(name);
204 });
205
206 const requireArn = Effect.fn(function* (
207 properties: medicalimaging.DatastoreProperties,
208 ) {
209 if (properties.datastoreArn === undefined) {
210 return yield* Effect.fail(
211 new DatastoreIncomplete({
212 message: `data store '${properties.datastoreId}' has no ARN in the GetDatastore response`,
213 }),
214 );
215 }
216 return properties.datastoreArn;
217 });
218
219 const readTags = Effect.fn(function* (arn: string) {
220 const response = yield* medicalimaging
221 .listTagsForResource({ resourceArn: arn })
222 .pipe(Effect.catch(() => Effect.succeed(undefined)));
223 return toTagRecord(response?.tags);
224 });
225
226 const toAttrs = Effect.fn(function* (
227 properties: medicalimaging.DatastoreProperties,
228 ) {
229 const arn = yield* requireArn(properties);
230 return {
231 datastoreId: properties.datastoreId,
232 datastoreName: properties.datastoreName,
233 datastoreArn: arn,
234 datastoreStatus: properties.datastoreStatus as string,
235 kmsKeyArn: properties.kmsKeyArn,
236 tags: yield* readTags(arn),
237 };
238 });
239
240 const waitForActive = Effect.fn(function* (datastoreId: string) {
241 return yield* Effect.gen(function* () {
242 const properties = yield* getById(datastoreId);
243 if (properties === undefined) {
244 return yield* Effect.fail(
245 new DatastoreNotReady({
246 message: `data store '${datastoreId}' disappeared while waiting for ACTIVE`,
247 }),
248 );
249 }
250 if (properties.datastoreStatus === "CREATE_FAILED") {
251 return yield* Effect.fail(
252 new DatastoreIncomplete({
253 message: `data store '${datastoreId}' failed to create (status: CREATE_FAILED)`,
254 }),
255 );
256 }
257 if (properties.datastoreStatus !== "ACTIVE") {
258 return yield* Effect.fail(
259 new DatastoreNotReady({
260 message: `data store '${datastoreId}' not active (status: ${properties.datastoreStatus})`,
261 }),
262 );
263 }
264 return properties;
265 }).pipe(retryWhileNotReady);
266 });
267
268 return {
269 stables: ["datastoreId", "datastoreArn", "datastoreName"],
270
271 diff: Effect.fn(function* ({ id, olds, news }) {
272 if (!isResolved(news)) return undefined;
273 const n = news;
274 const o = olds;
275 if (n === undefined || o === undefined) return undefined;
276 // HealthImaging has no UpdateDatastore — every property except
277 // tags is create-only and forces a replacement.
278 if ((yield* toName(id, o)) !== (yield* toName(id, n))) {
279 return { action: "replace" } as const;
280 }
281 if ((n.kmsKeyArn ?? undefined) !== (o.kmsKeyArn ?? undefined)) {
282 return { action: "replace" } as const;
283 }
284 if (
285 (n.lambdaAuthorizerArn ?? undefined) !==
286 (o.lambdaAuthorizerArn ?? undefined)
287 ) {
288 return { action: "replace" } as const;
289 }
290 if (
291 (n.losslessStorageFormat ?? undefined) !==
292 (o.losslessStorageFormat ?? undefined)
293 ) {
294 return { action: "replace" } as const;
295 }
296 // tags → default update
297 }),
298
299 read: Effect.fn(function* ({ id, olds, output }) {
300 const name = output?.datastoreName ?? (yield* toName(id, olds ?? {}));
301 const properties = yield* observe(output?.datastoreId, name);
302 if (properties === undefined) return undefined;
303 const attrs = yield* toAttrs(properties);
304 return (yield* hasAlchemyTags(id, attrs.tags))
305 ? attrs
306 : Unowned(attrs);
307 }),
308
309 reconcile: Effect.fn(function* ({
310 id,
311 news,
312 output,
313 session,
314 instanceId,
315 }) {
316 const props = news!;
317 const name = output?.datastoreName ?? (yield* toName(id, props));
318 const internalTags = yield* createInternalTags(id);
319 const desiredTags = { ...props.tags, ...internalTags };
320
321 // 1. OBSERVE — cloud state is authoritative; output is only an
322 // id cache.
323 let observed = yield* observe(output?.datastoreId, name);
324
325 // 2. ENSURE — create if missing; a ConflictException is a create
326 // race (or a leftover CREATE_FAILED store), resolved by re-reading.
327 if (observed === undefined) {
328 const created = yield* medicalimaging
329 .createDatastore({
330 datastoreName: name,
331 clientToken: createToken(instanceId),
332 kmsKeyArn: props.kmsKeyArn,
333 lambdaAuthorizerArn: props.lambdaAuthorizerArn,
334 losslessStorageFormat: props.losslessStorageFormat,
335 tags: desiredTags,
336 })
337 .pipe(
338 Effect.catchTag("ConflictException", () =>
339 Effect.succeed(undefined),
340 ),
341 );
342 observed =
343 created !== undefined
344 ? yield* getById(created.datastoreId)
345 : yield* findByName(name);
346 }
347 if (observed === undefined) {
348 return yield* Effect.fail(
349 new DatastoreIncomplete({
350 message: `data store '${name}' not found after create`,
351 }),
352 );
353 }
354
355 // Provisioning is async (CREATING → ACTIVE); wait bounded so tag
356 // syncs and dependents see an ACTIVE store.
357 observed = yield* waitForActive(observed.datastoreId);
358 const arn = yield* requireArn(observed);
359
360 // 3. SYNC TAGS — diff against OBSERVED cloud tags.
361 const observedTags = yield* readTags(arn);
362 const { removed, upsert } = diffTags(observedTags, desiredTags);
363 if (upsert.length > 0) {
364 yield* medicalimaging.tagResource({
365 resourceArn: arn,
366 tags: Object.fromEntries(
367 upsert.map((tag) => [tag.Key, tag.Value]),
368 ),
369 });
370 }
371 if (removed.length > 0) {
372 yield* medicalimaging.untagResource({
373 resourceArn: arn,
374 tagKeys: removed,
375 });
376 }
377
378 yield* session.note(observed.datastoreId);
379 return yield* toAttrs(observed);
380 }),
381
382 delete: Effect.fn(function* ({ output }) {
383 // Already deleting or gone is success; a store mid-create rejects
384 // deletion with ConflictException (retried, bounded). A non-empty
385 // store also raises ConflictException and surfaces the typed error
386 // once the retry budget is exhausted.
387 const observed = yield* medicalimaging
388 .getDatastore({ datastoreId: output.datastoreId })
389 .pipe(
390 Effect.catchTag("ResourceNotFoundException", () =>
391 Effect.succeed(undefined),
392 ),
393 );
394 const status = observed?.datastoreProperties.datastoreStatus;
395 if (
396 status === undefined ||
397 status === "DELETING" ||
398 status === "DELETED"
399 ) {
400 return;
401 }
402 yield* medicalimaging
403 .deleteDatastore({ datastoreId: output.datastoreId })
404 .pipe(
405 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
406 retryWhileConflict,
407 );
408 }),
409
410 list: () =>
411 medicalimaging.listDatastores.items({}).pipe(
412 Stream.filter(
413 (summary) =>
414 summary.datastoreStatus !== "DELETED" &&
415 summary.datastoreStatus !== "DELETING",
416 ),
417 Stream.runCollect,
418 Effect.flatMap((summaries) =>
419 Effect.forEach(
420 Array.from(summaries),
421 (summary) =>
422 getById(summary.datastoreId).pipe(
423 Effect.flatMap((properties) =>
424 properties === undefined
425 ? Effect.succeed(undefined)
426 : toAttrs(properties),
427 ),
428 ),
429 { concurrency: 4 },
430 ),
431 ),
432 Effect.map((attrs) =>
433 attrs.filter((a): a is NonNullable<typeof a> => a !== undefined),
434 ),
435 ),
436 };
437 }),
438 );