1import * as AWS from "@/AWS";
2import { Dataset, DatasetGroup, EventTracker, Schema } from "@/AWS/Personalize";
3import { toTagRecord } from "@/AWS/Personalize/internal.ts";
4import * as Test from "@/Test/Alchemy";
5import * as personalize from "@distilled.cloud/aws/personalize";
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
13const INTERACTIONS_SCHEMA = JSON.stringify({
14 type: "record",
15 name: "Interactions",
16 namespace: "com.amazonaws.personalize.schema",
17 fields: [
18 { name: "USER_ID", type: "string" },
19 { name: "ITEM_ID", type: "string" },
20 { name: "TIMESTAMP", type: "long" },
21 ],
22 version: "1.0",
23});
24
25class DatasetGroupStillExists extends Data.TaggedError(
26 "DatasetGroupStillExists",
27)<{ readonly arn: string }> {}
28
29const assertDatasetGroupDeleted = (arn: string) =>
30 personalize.describeDatasetGroup({ datasetGroupArn: arn }).pipe(
31 Effect.flatMap(() => Effect.fail(new DatasetGroupStillExists({ arn }))),
32 Effect.catchTag("ResourceNotFoundException", () =>
33 Effect.succeed(undefined),
34 ),
35 Effect.retry({
36 while: (e) => e._tag === "DatasetGroupStillExists",
37 schedule: Schedule.max([
38 Schedule.spaced("3 seconds"),
39 Schedule.recurs(20),
40 ]),
41 }),
42 );
43
44// Typed-error probe: proves the SDK decodes Personalize errors as typed tags.
45// A missing dataset group surfaces as ResourceNotFoundException. Training
46// resources (solutions, campaigns) take ~an hour of paid training — they are
47// intentionally NOT implemented; only the cheap definition resources below
48// have a live lifecycle.
49test.provider(
50 "describeDatasetGroup on a nonexistent ARN fails with a typed error",
51 () =>
52 Effect.gen(function* () {
53 const region = yield* yield* AWS.Region;
54 const error = yield* Effect.flip(
55 personalize.describeDatasetGroup({
56 datasetGroupArn: `arn:aws:personalize:${region}:000000000000:dataset-group/does-not-exist`,
57 }),
58 );
59 expect(
60 ["ResourceNotFoundException", "AccessDeniedException"].includes(
61 error._tag,
62 ),
63 ).toBe(true);
64 }),
65 { tags: ["provider:aws", "provider:aws:personalize", "live"] },
66);
67
68// Personalize schemas, dataset groups, datasets, and event trackers are
69// cheap, fast metadata objects — the live lifecycle runs ungated with
70// out-of-band verification. The expensive training work (solutions,
71// campaigns, import jobs) runs at runtime through the MLOps bindings instead
72// of IaC resources.
73test.provider(
74 "create and destroy a schema, dataset group, dataset, and event tracker",
75 (stack) =>
76 Effect.gen(function* () {
77 yield* stack.destroy();
78
79 const created = yield* stack.deploy(
80 Effect.gen(function* () {
81 const schema = yield* Schema("Interactions", {
82 schema: INTERACTIONS_SCHEMA,
83 });
84 const group = yield* DatasetGroup("Group", {
85 tags: { Environment: "test" },
86 });
87 const dataset = yield* Dataset("Dataset", {
88 schemaArn: schema.schemaArn,
89 datasetGroupArn: group.datasetGroupArn,
90 datasetType: "Interactions",
91 tags: { Environment: "test" },
92 });
93 // Depend on the DATASET's group ARN (not the group's): Personalize
94 // rejects creating an event tracker until the group has an
95 // Interactions dataset, so the tracker must be sequenced after it.
96 const tracker = yield* EventTracker("Tracker", {
97 datasetGroupArn: dataset.datasetGroupArn,
98 tags: { Environment: "test" },
99 });
100 return { schema, group, dataset, tracker };
101 }),
102 );
103
104 expect(created.schema.schemaArn).toContain(":schema/");
105 expect(created.group.datasetGroupArn).toContain(":dataset-group/");
106 expect(created.group.status).toBe("ACTIVE");
107 expect(created.dataset.datasetArn).toContain(":dataset/");
108 expect(created.dataset.status).toBe("ACTIVE");
109 // Personalize canonicalizes the dataset type to uppercase on describe.
110 expect(created.dataset.datasetType).toBe("INTERACTIONS");
111
112 // Out-of-band verification via distilled.
113 const describedSchema = yield* personalize.describeSchema({
114 schemaArn: created.schema.schemaArn,
115 });
116 expect(JSON.parse(describedSchema.schema!.schema!)).toEqual(
117 JSON.parse(INTERACTIONS_SCHEMA),
118 );
119
120 const describedGroup = yield* personalize.describeDatasetGroup({
121 datasetGroupArn: created.group.datasetGroupArn,
122 });
123 expect(describedGroup.datasetGroup?.status).toBe("ACTIVE");
124
125 const groupTags = yield* personalize.listTagsForResource({
126 resourceArn: created.group.datasetGroupArn,
127 });
128 expect(toTagRecord(groupTags.tags)["alchemy::id"]).toBe("Group");
129
130 const describedDataset = yield* personalize.describeDataset({
131 datasetArn: created.dataset.datasetArn,
132 });
133 expect(describedDataset.dataset?.datasetType).toBe("INTERACTIONS");
134 expect(describedDataset.dataset?.schemaArn).toBe(
135 created.schema.schemaArn,
136 );
137
138 expect(created.tracker.eventTrackerArn).toContain(":event-tracker/");
139 expect(created.tracker.status).toBe("ACTIVE");
140 expect(created.tracker.trackingId).toBeTruthy();
141 const describedTracker = yield* personalize.describeEventTracker({
142 eventTrackerArn: created.tracker.eventTrackerArn,
143 });
144 expect(describedTracker.eventTracker?.trackingId).toBe(
145 created.tracker.trackingId,
146 );
147 expect(describedTracker.eventTracker?.datasetGroupArn).toBe(
148 created.group.datasetGroupArn,
149 );
150
151 // Update: change the dataset group's tags in place.
152 const updated = yield* stack.deploy(
153 Effect.gen(function* () {
154 const schema = yield* Schema("Interactions", {
155 schema: INTERACTIONS_SCHEMA,
156 });
157 const group = yield* DatasetGroup("Group", {
158 tags: { Environment: "test", Extra: "yes" },
159 });
160 const dataset = yield* Dataset("Dataset", {
161 schemaArn: schema.schemaArn,
162 datasetGroupArn: group.datasetGroupArn,
163 datasetType: "Interactions",
164 tags: { Environment: "test" },
165 });
166 const tracker = yield* EventTracker("Tracker", {
167 datasetGroupArn: dataset.datasetGroupArn,
168 tags: { Environment: "test", Extra: "yes" },
169 });
170 return { schema, group, dataset, tracker };
171 }),
172 );
173 // Stable identifiers are preserved across the update.
174 expect(updated.group.datasetGroupArn).toBe(created.group.datasetGroupArn);
175 expect(updated.dataset.datasetArn).toBe(created.dataset.datasetArn);
176 expect(updated.tracker.eventTrackerArn).toBe(
177 created.tracker.eventTrackerArn,
178 );
179 expect(updated.tracker.trackingId).toBe(created.tracker.trackingId);
180
181 const updatedTags = yield* personalize.listTagsForResource({
182 resourceArn: created.group.datasetGroupArn,
183 });
184 expect(toTagRecord(updatedTags.tags).Extra).toBe("yes");
185 const updatedTrackerTags = yield* personalize.listTagsForResource({
186 resourceArn: created.tracker.eventTrackerArn,
187 });
188 expect(toTagRecord(updatedTrackerTags.tags).Extra).toBe("yes");
189
190 // Destroy and verify deletion out-of-band.
191 yield* stack.destroy();
192 yield* assertDatasetGroupDeleted(created.group.datasetGroupArn);
193 const datasetError = yield* Effect.flip(
194 personalize.describeDataset({
195 datasetArn: created.dataset.datasetArn,
196 }),
197 );
198 expect(datasetError._tag).toBe("ResourceNotFoundException");
199 const trackerError = yield* Effect.flip(
200 personalize.describeEventTracker({
201 eventTrackerArn: created.tracker.eventTrackerArn,
202 }),
203 );
204 expect(trackerError._tag).toBe("ResourceNotFoundException");
205 }),
206 {
207 tags: ["provider:aws", "provider:aws:personalize", "live"],
208 timeout: 480_000,
209 },
210);