1import * as forecast from "@distilled.cloud/aws/forecast";
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 } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 readForecastTags,
14 syncForecastTags,
15 toForecastName,
16} from "./internal.ts";
17
18export interface DatasetGroupProps {
19 /**
20 * Name of the dataset group. If omitted, a unique name is generated from the
21 * app, stage, and logical ID. Changing the name replaces the group.
22 */
23 datasetGroupName?: string;
24 /**
25 * The forecasting domain: `RETAIL`, `CUSTOM`, `INVENTORY_PLANNING`,
26 * `EC2_CAPACITY`, `WORK_FORCE`, `WEB_TRAFFIC`, or `METRICS`. Immutable —
27 * changing it replaces the group.
28 */
29 domain: string;
30 /**
31 * ARNs of the datasets to attach to the group. This is an in-place update —
32 * changing the set re-associates the datasets without replacing the group.
33 */
34 datasetArns?: string[];
35 /**
36 * User-defined tags for the dataset group.
37 */
38 tags?: Record<string, string>;
39}
40
41export interface DatasetGroup extends Resource<
42 "AWS.Forecast.DatasetGroup",
43 DatasetGroupProps,
44 {
45 /** The ARN of the dataset group. */
46 datasetGroupArn: string;
47 /** The name of the dataset group. */
48 datasetGroupName: string;
49 /** The forecasting domain of the group, e.g. `RETAIL` or `CUSTOM`. */
50 domain: string;
51 /** The dataset group status, e.g. `ACTIVE`. */
52 status: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * An Amazon Forecast dataset group — a domain-scoped container that groups the
60 * datasets used to train predictors. Creating the group is cheap; the
61 * expensive training work lives in predictors and forecasts provisioned
62 * separately.
63 *
64 * ### Creating a Dataset Group
65 * **Example:** Custom Dataset Group
66 * ```typescript
67 * const group = yield* Forecast.DatasetGroup("Sales", {
68 * domain: "CUSTOM",
69 * });
70 * ```
71 *
72 * **Example:** Dataset Group with Attached Datasets
73 * ```typescript
74 * const group = yield* Forecast.DatasetGroup("Sales", {
75 * domain: "RETAIL",
76 * datasetArns: [dataset.datasetArn],
77 * tags: { team: "planning" },
78 * });
79 * ```
80 *
81 * @resource
82 */
83export const DatasetGroup = Resource<DatasetGroup>("AWS.Forecast.DatasetGroup");
84
85const sameArns = (a: string[], b: string[]) => {
86 const sa = [...a].sort();
87 const sb = [...b].sort();
88 return sa.length === sb.length && sa.every((v, i) => v === sb[i]);
89};
90
91export const DatasetGroupProvider = () =>
92 Provider.effect(
93 DatasetGroup,
94 Effect.gen(function* () {
95 const createName = Effect.fn(function* (
96 id: string,
97 props: DatasetGroupProps,
98 ) {
99 return (
100 props.datasetGroupName ??
101 toForecastName(yield* createPhysicalName({ id, maxLength: 63 }))
102 );
103 });
104
105 const describe = Effect.fn(function* (datasetGroupArn: string) {
106 return yield* forecast
107 .describeDatasetGroup({ DatasetGroupArn: datasetGroupArn })
108 .pipe(
109 Effect.catchTag("ResourceNotFoundException", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113 });
114
115 const toAttrs = (group: forecast.DescribeDatasetGroupResponse) => ({
116 datasetGroupArn: group.DatasetGroupArn!,
117 datasetGroupName: group.DatasetGroupName!,
118 domain: group.Domain!,
119 status: group.Status!,
120 });
121
122 return {
123 stables: ["datasetGroupArn", "datasetGroupName"],
124
125 diff: Effect.fn(function* ({ id, olds, news }) {
126 if (!isResolved(news)) return undefined;
127 const oldName = yield* createName(id, olds);
128 const newName = yield* createName(id, news);
129 if (
130 oldName !== newName ||
131 (olds.domain ?? undefined) !== (news.domain ?? undefined)
132 ) {
133 return { action: "replace" } as const;
134 }
135 }),
136
137 read: Effect.fn(function* ({ id, output }) {
138 if (!output?.datasetGroupArn) return undefined;
139 const group = yield* describe(output.datasetGroupArn);
140 if (group === undefined) return undefined;
141 const attrs = toAttrs(group);
142 const tags = yield* readForecastTags(group.DatasetGroupArn!);
143 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
144 }),
145
146 reconcile: Effect.fn(function* ({ id, news, output, session }) {
147 const name = yield* createName(id, news);
148 const internalTags = yield* createInternalTags(id);
149 const desiredTags = { ...internalTags, ...news.tags };
150 const desiredArns = news.datasetArns ?? [];
151
152 // 1. Observe — cloud state is authoritative; output is an ARN cache.
153 let group =
154 output?.datasetGroupArn !== undefined
155 ? yield* describe(output.datasetGroupArn)
156 : undefined;
157
158 // 2. Ensure — create if missing.
159 if (group === undefined) {
160 const created = yield* forecast.createDatasetGroup({
161 DatasetGroupName: name,
162 Domain: news.domain,
163 DatasetArns: desiredArns,
164 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
165 Key,
166 Value,
167 })),
168 });
169 group = yield* describe(created.DatasetGroupArn!);
170 } else {
171 // 3. Sync attached datasets — in-place update on drift.
172 if (!sameArns(group.DatasetArns ?? [], desiredArns)) {
173 yield* forecast.updateDatasetGroup({
174 DatasetGroupArn: group.DatasetGroupArn!,
175 DatasetArns: desiredArns,
176 });
177 }
178 // 3b. Sync tags — diff against OBSERVED cloud tags.
179 yield* syncForecastTags(group.DatasetGroupArn!, desiredTags);
180 }
181
182 const arn = group!.DatasetGroupArn!;
183 yield* session.note(arn);
184 const fresh = (yield* describe(arn)) ?? group!;
185 return toAttrs(fresh);
186 }),
187
188 delete: Effect.fn(function* ({ output }) {
189 yield* forecast
190 .deleteDatasetGroup({ DatasetGroupArn: output.datasetGroupArn })
191 .pipe(
192 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
193 Effect.retry({
194 while: (e) => e._tag === "ResourceInUseException",
195 schedule: Schedule.max([
196 Schedule.fixed("3 seconds"),
197 Schedule.recurs(20),
198 ]),
199 }),
200 );
201 }),
202
203 list: () =>
204 forecast.listDatasetGroups.pages({}).pipe(
205 Stream.runCollect,
206 Effect.map((chunk) =>
207 Array.from(chunk).flatMap((page) => page.DatasetGroups ?? []),
208 ),
209 Effect.flatMap(
210 Effect.forEach(
211 (summary) =>
212 describe(summary.DatasetGroupArn!).pipe(
213 Effect.map((g) => (g ? toAttrs(g) : undefined)),
214 ),
215 { concurrency: 4 },
216 ),
217 ),
218 Effect.map((items) => items.filter((item) => item !== undefined)),
219 ),
220 };
221 }),
222 );