1import * as AWS from "@/AWS";
2import { Dataset, DatasetGroup } from "@/AWS/Forecast";
3import { toTagRecord } from "@/AWS/Forecast/internal.ts";
4import * as Test from "@/Test/Alchemy";
5import * as forecast from "@distilled.cloud/aws/forecast";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13class DatasetGroupStillExists extends Data.TaggedError(
14 "DatasetGroupStillExists",
15)<{ readonly arn: string }> {}
16
17const assertDatasetGroupDeleted = (arn: string) =>
18 forecast.describeDatasetGroup({ DatasetGroupArn: arn }).pipe(
19 Effect.flatMap(() => Effect.fail(new DatasetGroupStillExists({ arn }))),
20 Effect.catchTag("ResourceNotFoundException", () =>
21 Effect.succeed(undefined),
22 ),
23 Effect.retry({
24 while: (e) => e._tag === "DatasetGroupStillExists",
25 schedule: Schedule.max([
26 Schedule.spaced("3 seconds"),
27 Schedule.recurs(20),
28 ]),
29 }),
30 );
31
32// Typed-error probe: proves the SDK decodes Forecast errors as typed tags.
33// A missing dataset surfaces as ResourceNotFoundException. Training resources
34// (predictors, forecasts) take ~an hour of paid training — they are
35// intentionally NOT implemented; only the cheap definition resources below
36// have a live lifecycle.
37test.provider(
38 "describeDataset on a nonexistent ARN fails with a typed error",
39 () =>
40 Effect.gen(function* () {
41 const region = yield* yield* AWS.Region;
42 const error = yield* Effect.flip(
43 forecast.describeDataset({
44 DatasetArn: `arn:aws:forecast:${region}:000000000000:dataset/does_not_exist`,
45 }),
46 );
47 expect(
48 ["ResourceNotFoundException", "AccessDeniedException"].includes(
49 error._tag,
50 ),
51 ).toBe(true);
52 }),
53 { tags: ["provider:aws", "provider:aws:forecast", "live"] },
54);
55
56// Entitlement probe: Amazon Forecast is closed to new customers — accounts
57// without prior Forecast usage get a typed AccessDeniedException on every
58// create ("Amazon Forecast is no longer available to new customers. Existing
59// customers of Amazon Forecast can continue to use the service as normal.").
60// In an entitled (grandfathered) account the create succeeds, so this probe
61// accepts either outcome and cleans up after itself.
62test.provider(
63 "createDatasetGroup is either entitled or a typed AccessDeniedException",
64 () =>
65 Effect.gen(function* () {
66 const arn = yield* forecast
67 .createDatasetGroup({
68 DatasetGroupName: "alchemy_forecast_entitlement_probe",
69 Domain: "CUSTOM",
70 })
71 .pipe(
72 Effect.map((r) => r.DatasetGroupArn),
73 Effect.catchTag(
74 // A leftover probe group from a crashed prior run is also fine.
75 ["AccessDeniedException", "ResourceAlreadyExistsException"],
76 () => Effect.succeed(undefined),
77 ),
78 );
79 if (arn !== undefined) {
80 // Entitled account — clean up the probe group.
81 yield* forecast
82 .deleteDatasetGroup({ DatasetGroupArn: arn })
83 .pipe(
84 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
85 );
86 }
87 }),
88 { tags: ["provider:aws", "provider:aws:forecast", "live"] },
89);
90
91// Forecast datasets and dataset groups are cheap, fast metadata objects, but
92// the standing test account is blocked by the new-customer closure above —
93// createDatasetGroup fails with AccessDeniedException ("Amazon Forecast is no
94// longer available to new customers"). The lifecycle is implemented fully and
95// gated behind AWS_TEST_FORECAST=1 so a grandfathered account can run it
96// unchanged. The expensive training work (predictors, forecasts, import jobs)
97// is out of scope.
98test.provider.skipIf(!process.env.AWS_TEST_FORECAST)(
99 "create, attach, and destroy a dataset and dataset group",
100 (stack) =>
101 Effect.gen(function* () {
102 yield* stack.destroy();
103
104 const created = yield* stack.deploy(
105 Effect.gen(function* () {
106 const dataset = yield* Dataset("Demand", {
107 domain: "CUSTOM",
108 datasetType: "TARGET_TIME_SERIES",
109 dataFrequency: "D",
110 schema: {
111 attributes: [
112 { attributeName: "item_id", attributeType: "string" },
113 { attributeName: "timestamp", attributeType: "timestamp" },
114 { attributeName: "target_value", attributeType: "float" },
115 ],
116 },
117 tags: { Environment: "test" },
118 });
119 const group = yield* DatasetGroup("Sales", {
120 domain: "CUSTOM",
121 datasetArns: [dataset.datasetArn],
122 tags: { Environment: "test" },
123 });
124 return { dataset, group };
125 }),
126 );
127
128 expect(created.dataset.datasetArn).toContain(":dataset/");
129 expect(created.dataset.datasetType).toBe("TARGET_TIME_SERIES");
130 expect(created.group.datasetGroupArn).toContain(":dataset-group/");
131 expect(created.group.domain).toBe("CUSTOM");
132
133 // Out-of-band verification via distilled.
134 const describedDataset = yield* forecast.describeDataset({
135 DatasetArn: created.dataset.datasetArn,
136 });
137 expect(describedDataset.Domain).toBe("CUSTOM");
138 expect(describedDataset.DataFrequency).toBe("D");
139
140 const describedGroup = yield* forecast.describeDatasetGroup({
141 DatasetGroupArn: created.group.datasetGroupArn,
142 });
143 expect(describedGroup.DatasetArns).toContain(created.dataset.datasetArn);
144
145 const groupTags = yield* forecast.listTagsForResource({
146 ResourceArn: created.group.datasetGroupArn,
147 });
148 expect(toTagRecord(groupTags.Tags)["alchemy::id"]).toBe("Sales");
149
150 // Update: detach the dataset and change tags in place.
151 const updated = yield* stack.deploy(
152 Effect.gen(function* () {
153 const dataset = yield* Dataset("Demand", {
154 domain: "CUSTOM",
155 datasetType: "TARGET_TIME_SERIES",
156 dataFrequency: "D",
157 schema: {
158 attributes: [
159 { attributeName: "item_id", attributeType: "string" },
160 { attributeName: "timestamp", attributeType: "timestamp" },
161 { attributeName: "target_value", attributeType: "float" },
162 ],
163 },
164 tags: { Environment: "test" },
165 });
166 const group = yield* DatasetGroup("Sales", {
167 domain: "CUSTOM",
168 datasetArns: [],
169 tags: { Environment: "test", Extra: "yes" },
170 });
171 return { dataset, group };
172 }),
173 );
174 expect(updated.group.datasetGroupArn).toBe(created.group.datasetGroupArn);
175
176 const reDescribedGroup = yield* forecast.describeDatasetGroup({
177 DatasetGroupArn: created.group.datasetGroupArn,
178 });
179 expect(reDescribedGroup.DatasetArns ?? []).not.toContain(
180 created.dataset.datasetArn,
181 );
182 const updatedTags = yield* forecast.listTagsForResource({
183 ResourceArn: created.group.datasetGroupArn,
184 });
185 expect(toTagRecord(updatedTags.Tags).Extra).toBe("yes");
186
187 // Destroy and verify deletion out-of-band.
188 yield* stack.destroy();
189 yield* assertDatasetGroupDeleted(created.group.datasetGroupArn);
190 const datasetError = yield* Effect.flip(
191 forecast.describeDataset({ DatasetArn: created.dataset.datasetArn }),
192 );
193 expect(datasetError._tag).toBe("ResourceNotFoundException");
194 }),
195 { tags: ["provider:aws", "provider:aws:forecast", "live"], timeout: 300_000 },
196);