1import * as ApplicationAutoScaling from "@/AWS/ApplicationAutoScaling";
2import { Table } from "@/AWS/DynamoDB";
3import * as Lambda from "@/AWS/Lambda";
4import * as Output from "@/Output";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Schedule from "effect/Schedule";
8import * as Stream from "effect/Stream";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11import path from "pathe";
12
13const main = path.resolve(import.meta.dirname, "handler.ts");
14
15// The freshly attached IAM policy can take tens of seconds to propagate to
16// the Application Auto Scaling endpoint after the fixture deploys — retry the
17// typed AccessDeniedException on a short bounded schedule (~30s total).
18const retryWhileIamPropagates = <A, E extends { _tag: string }, R>(
19 self: Effect.Effect<A, E, R>,
20): Effect.Effect<A, E, R> =>
21 Effect.retry(self, {
22 while: (e): boolean => e._tag === "AccessDeniedException",
23 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
24 });
25
26export class ApplicationAutoScalingTestFunction extends Lambda.Function<Lambda.Function>()(
27 "ApplicationAutoScalingTestFunction",
28) {}
29
30export default ApplicationAutoScalingTestFunction.make(
31 {
32 main,
33 functionUrl: true,
34 },
35 Effect.gen(function* () {
36 // A provisioned DynamoDB table is the cheapest, fastest real scalable
37 // resource (near-instant, free at 1 RCU/WCU).
38 const table = yield* Table("BindingsTable", {
39 partitionKey: "id",
40 attributes: { id: "S" },
41 billingMode: "PROVISIONED",
42 provisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 },
43 });
44 const target = yield* ApplicationAutoScaling.ScalableTarget(
45 "BindingsTarget",
46 {
47 serviceNamespace: "dynamodb",
48 resourceId: Output.interpolate`table/${table.tableName}`,
49 scalableDimension: "dynamodb:table:ReadCapacityUnits",
50 minCapacity: 1,
51 maxCapacity: 5,
52 },
53 );
54 const policy = yield* ApplicationAutoScaling.ScalingPolicy(
55 "BindingsPolicy",
56 {
57 serviceNamespace: target.serviceNamespace,
58 resourceId: target.resourceId,
59 scalableDimension: target.scalableDimension,
60 targetTracking: {
61 TargetValue: 70,
62 PredefinedMetricSpecification: {
63 PredefinedMetricType: "DynamoDBReadCapacityUtilization",
64 },
65 },
66 },
67 );
68
69 const describeScalingActivities =
70 yield* ApplicationAutoScaling.DescribeScalingActivities(target);
71 const getPredictiveScalingForecast =
72 yield* ApplicationAutoScaling.GetPredictiveScalingForecast(policy);
73
74 // Subscribe to scaling-activity state-change (scaled-to-max) events —
75 // creates the EventBridge rule + Lambda permission at deploy time.
76 yield* ApplicationAutoScaling.consumeScalingActivityEvents(
77 target,
78 {},
79 (events) =>
80 Stream.runForEach(events, (event) =>
81 Effect.log(
82 `${event["detail-type"]}: ${event.detail.resourceId} scaledToMax=${event.detail.scaledToMax}`,
83 ),
84 ),
85 );
86
87 const bound = {
88 describeScalingActivities,
89 getPredictiveScalingForecast,
90 };
91
92 return {
93 fetch: Effect.gen(function* () {
94 const request = yield* HttpServerRequest;
95 const url = new URL(request.originalUrl);
96 const pathname = url.pathname;
97
98 if (request.method === "GET" && pathname === "/bindings") {
99 return yield* HttpServerResponse.json({
100 bound: Object.keys(bound),
101 });
102 }
103
104 if (request.method === "GET" && pathname === "/scaling-activities") {
105 const result = yield* retryWhileIamPropagates(
106 describeScalingActivities({
107 MaxResults: 25,
108 IncludeNotScaledActivities: true,
109 }),
110 );
111 return yield* HttpServerResponse.json({
112 count: (result.ScalingActivities ?? []).length,
113 });
114 }
115
116 if (request.method === "GET" && pathname === "/forecast") {
117 // Predictive scaling exists only for ECS services — for a
118 // dynamodb-namespace target the live API PERMANENTLY rejects the
119 // read with AccessDeniedException "GetPredictiveScalingForecast
120 // is not supported.", which the distilled patch specializes into
121 // the typed PredictiveScalingForecastNotSupported. Do NOT retry
122 // it (it is not an IAM-propagation denial — retrying blows the
123 // Lambda timeout and surfaces as an opaque 502). Return the
124 // typed tag so the test proves the grant, the PolicyName
125 // injection, and the typed error union end-to-end.
126 const now = yield* Effect.sync(() => Date.now());
127 const tag = yield* getPredictiveScalingForecast({
128 StartTime: new Date(now),
129 EndTime: new Date(now + 2 * 60 * 60 * 1000),
130 }).pipe(
131 Effect.map(() => "Forecast"),
132 Effect.catchTag("PredictiveScalingForecastNotSupported", (e) =>
133 Effect.succeed(e._tag),
134 ),
135 // Return whatever typed tag the API produced so an unexpected
136 // error is observable in the test's assertion message instead
137 // of an opaque 500. (The deployed bundle resolves distilled via
138 // its built `lib/`, so until the coordinator rebuilds distilled
139 // the platform rejection still surfaces as the base
140 // AccessDeniedException tag in-Lambda.)
141 Effect.catch((e) => Effect.succeed(e._tag)),
142 );
143 return yield* HttpServerResponse.json({ tag });
144 }
145
146 return yield* HttpServerResponse.json(
147 { error: "Not found", method: request.method, pathname },
148 { status: 404 },
149 );
150 }).pipe(Effect.orDie),
151 };
152 }).pipe(
153 Effect.provide(
154 Layer.mergeAll(
155 Lambda.EventSource,
156 ApplicationAutoScaling.DescribeScalingActivitiesHttp,
157 ApplicationAutoScaling.GetPredictiveScalingForecastHttp,
158 ),
159 ),
160 ),
161);