1import * as omics from "@distilled.cloud/aws/omics";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags, tagRecord } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { syncOmicsTags } from "./internal.ts";
13
14// Annotation/variant store names must match `[a-z]([a-z0-9_]){0,254}` — the
15// generated physical name (hyphens, mixed case) is coerced to fit.
16const toStoreName = (raw: string) => {
17 const sanitized = raw.toLowerCase().replace(/[^a-z0-9_]/g, "_");
18 const prefixed = /^[a-z]/.test(sanitized) ? sanitized : `a${sanitized}`;
19 return prefixed.slice(0, 255);
20};
21
22export interface AnnotationStoreProps {
23 /**
24 * A name for the annotation store. If omitted, a unique name is generated
25 * from the app, stage, and logical ID. Must be lowercase, start with a
26 * letter, and contain only `[a-z0-9_]`. Changing the name replaces the
27 * store.
28 */
29 name?: string;
30 /**
31 * A description for the annotation store. Mutable.
32 */
33 description?: string;
34 /**
35 * The annotation file format of the store. Immutable — changing it replaces
36 * the store.
37 */
38 storeFormat: "GFF" | "TSV" | "VCF";
39 /**
40 * The genome reference for the store, as a reference ARN. Required for
41 * `GFF` and `VCF` stores. Immutable — changing it replaces the store.
42 */
43 reference?: {
44 /** ARN of a reference in a HealthOmics reference store. */
45 referenceArn: string;
46 };
47 /**
48 * File parsing options for `TSV` stores. Immutable.
49 */
50 storeOptions?: {
51 /** Parsing options for TSV-format annotation files. */
52 tsvStoreOptions: {
53 /** The store's annotation type (e.g. `GENERIC`, `CHR_POS_REF_ALT`). */
54 annotationType?: string;
55 /** Maps TSV format fields to the store's header columns. */
56 formatToHeader?: Record<string, string>;
57 /** Column-name-to-type entries describing the store's schema. */
58 schema?: Record<string, string>[];
59 };
60 };
61 /**
62 * Server-side encryption (SSE) settings. Defaults to an AWS-owned key.
63 * Immutable.
64 */
65 sseConfig?: {
66 /** Encryption type. `KMS` uses a customer-managed key. */
67 type: "KMS";
68 /** ARN of the customer-managed KMS key. Required when `type` is `KMS`. */
69 keyArn?: string;
70 };
71 /**
72 * Tags to apply to the annotation store. Merged with internal Alchemy tags.
73 */
74 tags?: Record<string, string>;
75}
76
77export interface AnnotationStore extends Resource<
78 "AWS.Omics.AnnotationStore",
79 AnnotationStoreProps,
80 {
81 /**
82 * ID of the annotation store.
83 */
84 annotationStoreId: string;
85 /**
86 * ARN of the annotation store.
87 */
88 annotationStoreArn: string;
89 /**
90 * Name of the annotation store.
91 */
92 name: string;
93 /**
94 * Store status (e.g. `ACTIVE`, `CREATING`, `UPDATING`, `FAILED`).
95 */
96 status: string;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An Amazon HealthOmics annotation store — a container for genome annotation
104 * data (GFF, TSV, or VCF) aligned to a reference genome.
105 *
106 * An annotation store name is auto-generated from the app, stage, and logical
107 * ID unless you provide one. The `storeFormat`, `reference`, `storeOptions`,
108 * and `sseConfig` are immutable — changing any of them replaces the store.
109 * `description` is updated in place.
110 * ### Creating an Annotation Store
111 * **Example:** VCF Annotation Store
112 * ```typescript
113 * import * as Omics from "alchemy/AWS/Omics";
114 *
115 * const store = yield* Omics.AnnotationStore("Annotations", {
116 * storeFormat: "VCF",
117 * reference: {
118 * referenceArn: "arn:aws:omics:us-east-1:123456789012:referenceStore/1234567890/reference/0987654321",
119 * },
120 * });
121 * ```
122 *
123 * **Example:** TSV Annotation Store
124 * ```typescript
125 * const store = yield* Omics.AnnotationStore("Annotations", {
126 * storeFormat: "TSV",
127 * storeOptions: {
128 * tsvStoreOptions: { annotationType: "GENERIC" },
129 * },
130 * });
131 * ```
132 *
133 * @resource
134 */
135export const AnnotationStore = Resource<AnnotationStore>(
136 "AWS.Omics.AnnotationStore",
137);
138
139export const AnnotationStoreProvider = () =>
140 Provider.effect(
141 AnnotationStore,
142 Effect.gen(function* () {
143 const createName = Effect.fn(function* (
144 id: string,
145 props: { name?: string | undefined },
146 ) {
147 return (
148 props.name ??
149 toStoreName(yield* createPhysicalName({ id, maxLength: 96 }))
150 );
151 });
152
153 const waitUntilActive = Effect.fn(function* (name: string) {
154 const final = yield* omics.getAnnotationStore({ name }).pipe(
155 Effect.repeat({
156 schedule: Schedule.max([
157 Schedule.fixed("5 seconds"),
158 Schedule.recurs(23),
159 ]),
160 until: (s) => s.status === "ACTIVE" || s.status === "FAILED",
161 }),
162 );
163 if (final.status === "FAILED") {
164 return yield* Effect.fail(
165 new omics.ValidationException({
166 message: `Annotation store ${name} failed: ${final.statusMessage ?? "unknown"}`,
167 }),
168 );
169 }
170 return final;
171 });
172
173 return AnnotationStore.Provider.of({
174 stables: ["annotationStoreId", "annotationStoreArn", "name"],
175 list: () =>
176 omics.listAnnotationStores.items({}).pipe(
177 Stream.map((item) => ({
178 annotationStoreId: item.id!,
179 annotationStoreArn: item.storeArn!,
180 name: item.name ?? "",
181 status: item.status ?? "",
182 })),
183 Stream.runCollect,
184 Effect.map((chunk) => Array.from(chunk)),
185 ),
186 read: Effect.fn(function* ({ id, olds, output }) {
187 const name = output?.name ?? (yield* createName(id, olds ?? {}));
188 const found = yield* omics
189 .getAnnotationStore({ name })
190 .pipe(
191 Effect.catchTag("ResourceNotFoundException", () =>
192 Effect.succeed(undefined),
193 ),
194 );
195 if (found === undefined) return undefined;
196 const attrs = {
197 annotationStoreId: found.id,
198 annotationStoreArn: found.storeArn,
199 name: found.name,
200 status: found.status,
201 };
202 return (yield* hasAlchemyTags(id, tagRecord(found.tags)))
203 ? attrs
204 : Unowned(attrs);
205 }),
206 diff: Effect.fn(function* ({ id, news, olds }) {
207 if (!isResolved(news)) return undefined;
208 const prev = olds ?? {};
209 const oldName = yield* createName(id, prev);
210 const newName = yield* createName(id, news);
211 if (oldName !== newName) return { action: "replace" } as const;
212 if (
213 (prev.storeFormat ?? "") !== (news.storeFormat ?? "") ||
214 (prev.reference?.referenceArn ?? "") !==
215 (news.reference?.referenceArn ?? "") ||
216 prev.sseConfig?.type !== news.sseConfig?.type ||
217 prev.sseConfig?.keyArn !== news.sseConfig?.keyArn
218 ) {
219 return { action: "replace" } as const;
220 }
221 }),
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const name = output?.name ?? (yield* createName(id, news));
224 const internalTags = yield* createInternalTags(id);
225 const desiredTags = { ...news.tags, ...internalTags };
226
227 // OBSERVE — annotation stores are addressable by name.
228 let store = yield* omics
229 .getAnnotationStore({ name })
230 .pipe(
231 Effect.catchTag("ResourceNotFoundException", () =>
232 Effect.succeed(undefined),
233 ),
234 );
235
236 // ENSURE — create if missing, tolerating a create race, then wait
237 // for the async provisioning to reach a terminal state.
238 if (store === undefined) {
239 yield* omics
240 .createAnnotationStore({
241 name,
242 description: news.description,
243 storeFormat: news.storeFormat,
244 reference: news.reference,
245 storeOptions: news.storeOptions,
246 sseConfig: news.sseConfig,
247 tags: desiredTags,
248 })
249 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
250 store = yield* waitUntilActive(name);
251 } else if (store.status === "CREATING") {
252 store = yield* waitUntilActive(name);
253 }
254
255 // SYNC — description is the only mutable field.
256 if (
257 news.description !== undefined &&
258 news.description !== store.description
259 ) {
260 yield* omics.updateAnnotationStore({
261 name,
262 description: news.description,
263 });
264 store = yield* omics.getAnnotationStore({ name });
265 }
266
267 // SYNC TAGS — diff against observed cloud tags so adoption converges.
268 yield* syncOmicsTags(store.storeArn, desiredTags);
269
270 yield* session.note(store.id);
271 return {
272 annotationStoreId: store.id,
273 annotationStoreArn: store.storeArn,
274 name: store.name,
275 status: store.status,
276 };
277 }),
278 delete: Effect.fn(function* ({ output }) {
279 yield* omics
280 .deleteAnnotationStore({ name: output.name, force: true })
281 .pipe(
282 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
283 );
284 }),
285 });
286 }),
287 );