1import * as AWS from "@/AWS";
2import { ScalableTarget } from "@/AWS/ApplicationAutoScaling";
3import { Table } from "@/AWS/DynamoDB";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as aas from "@distilled.cloud/aws/application-auto-scaling";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const describeTarget = (
14 resourceId: string,
15 scalableDimension: aas.ScalableDimension,
16) =>
17 aas
18 .describeScalableTargets({
19 ServiceNamespace: "dynamodb",
20 ResourceIds: [resourceId],
21 ScalableDimension: scalableDimension,
22 })
23 .pipe(
24 Effect.map((res) =>
25 res.ScalableTargets?.find(
26 (t) =>
27 t.ResourceId === resourceId &&
28 t.ScalableDimension === scalableDimension,
29 ),
30 ),
31 );
32
33const waitUntilTargetGone = (
34 resourceId: string,
35 scalableDimension: aas.ScalableDimension,
36) =>
37 describeTarget(resourceId, scalableDimension).pipe(
38 Effect.repeat({
39 schedule: Schedule.spaced("2 seconds"),
40 until: (target) => target === undefined,
41 times: 10,
42 }),
43 );
44
45// Full lifecycle against a provisioned DynamoDB table (tables are near-instant
46// and free at 1 RCU/WCU): register a read-capacity scalable target with tags,
47// update min/max + tags in place (same ARN), replace by switching the
48// scalable dimension, then destroy and verify deregistration out-of-band.
49test.provider(
50 "dynamodb read-capacity target: register, update in place, replace on dimension change",
51 (stack) =>
52 Effect.gen(function* () {
53 yield* stack.destroy();
54
55 const deploy = (props: {
56 scalableDimension: aas.ScalableDimension;
57 maxCapacity: number;
58 tags: Record<string, string>;
59 }) =>
60 stack.deploy(
61 Effect.gen(function* () {
62 const table = yield* Table("AasTable", {
63 partitionKey: "id",
64 attributes: { id: "S" },
65 billingMode: "PROVISIONED",
66 provisionedThroughput: {
67 ReadCapacityUnits: 1,
68 WriteCapacityUnits: 1,
69 },
70 });
71 const target = yield* ScalableTarget("AasTarget", {
72 serviceNamespace: "dynamodb",
73 resourceId: Output.interpolate`table/${table.tableName}`,
74 scalableDimension: props.scalableDimension,
75 minCapacity: 1,
76 maxCapacity: props.maxCapacity,
77 tags: props.tags,
78 });
79 return {
80 tableName: table.tableName.as<string>(),
81 resourceId: target.resourceId.as<string>(),
82 scalableTargetArn: target.scalableTargetArn.as<string>(),
83 minCapacity: target.minCapacity.as<number>(),
84 maxCapacity: target.maxCapacity.as<number>(),
85 };
86 }),
87 );
88
89 // Create.
90 const created = yield* deploy({
91 scalableDimension: "dynamodb:table:ReadCapacityUnits",
92 maxCapacity: 5,
93 tags: { env: "test", keep: "v1" },
94 });
95 expect(created.resourceId).toEqual(`table/${created.tableName}`);
96 expect(created.scalableTargetArn).toContain("scalable-target/");
97 expect(created.minCapacity).toBe(1);
98 expect(created.maxCapacity).toBe(5);
99
100 // Out-of-band: the target is registered with the requested capacity
101 // range and carries both the internal alchemy tags and the user tags.
102 const observed = yield* describeTarget(
103 created.resourceId,
104 "dynamodb:table:ReadCapacityUnits",
105 );
106 expect(observed?.MinCapacity).toBe(1);
107 expect(observed?.MaxCapacity).toBe(5);
108 const tags = yield* aas.listTagsForResource({
109 ResourceARN: created.scalableTargetArn,
110 });
111 expect(tags.Tags?.["alchemy::id"]).toBe("AasTarget");
112 expect(tags.Tags?.env).toBe("test");
113 expect(tags.Tags?.keep).toBe("v1");
114
115 // Update in place — capacity range and tags are mutable; the identity
116 // triple (and thus the ARN) is stable.
117 const updated = yield* deploy({
118 scalableDimension: "dynamodb:table:ReadCapacityUnits",
119 maxCapacity: 6,
120 tags: { env: "test", added: "new" },
121 });
122 expect(updated.scalableTargetArn).toEqual(created.scalableTargetArn);
123 expect(updated.maxCapacity).toBe(6);
124 const observedAfterUpdate = yield* describeTarget(
125 created.resourceId,
126 "dynamodb:table:ReadCapacityUnits",
127 );
128 expect(observedAfterUpdate?.MaxCapacity).toBe(6);
129 const tagsAfterUpdate = yield* aas.listTagsForResource({
130 ResourceARN: created.scalableTargetArn,
131 });
132 expect(tagsAfterUpdate.Tags?.added).toBe("new");
133 expect(tagsAfterUpdate.Tags?.keep).toBeUndefined();
134 expect(tagsAfterUpdate.Tags?.["alchemy::id"]).toBe("AasTarget");
135
136 // Replace — the scalable dimension is part of the identity triple.
137 const replaced = yield* deploy({
138 scalableDimension: "dynamodb:table:WriteCapacityUnits",
139 maxCapacity: 6,
140 tags: { env: "test" },
141 });
142 expect(replaced.scalableTargetArn).not.toEqual(created.scalableTargetArn);
143 const writeTarget = yield* describeTarget(
144 created.resourceId,
145 "dynamodb:table:WriteCapacityUnits",
146 );
147 expect(writeTarget?.MaxCapacity).toBe(6);
148 const readTargetGone = yield* waitUntilTargetGone(
149 created.resourceId,
150 "dynamodb:table:ReadCapacityUnits",
151 );
152 expect(readTargetGone).toBeUndefined();
153
154 // Destroy — the target deregisters.
155 yield* stack.destroy();
156 const gone = yield* waitUntilTargetGone(
157 created.resourceId,
158 "dynamodb:table:WriteCapacityUnits",
159 );
160 expect(gone).toBeUndefined();
161 }),
162 {
163 tags: [
164 "provider:aws",
165 "provider:aws:applicationautoscaling",
166 "provider:aws:dynamodb",
167 "live",
168 ],
169 timeout: 240_000,
170 },
171);