1import * as DAX from "@/AWS/DAX";
2import { Role } from "@/AWS/IAM/Role.ts";
3import * as Lambda from "@/AWS/Lambda";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "slow-handler.ts");
11
12/**
13 * Deterministic name of the subnet group the gated test creates out-of-band
14 * (subnet ids come from a runtime default-VPC lookup the fixture module
15 * cannot perform) before deploying this fixture, and deletes after the
16 * cluster is destroyed.
17 */
18export const SLOW_SUBNET_GROUP_NAME = "alchemy-dax-bindings-subnets";
19
20export class DAXSlowTestFunction extends Lambda.Function<Lambda.Function>()(
21 "DAXSlowTestFunction",
22) {}
23
24/**
25 * Cluster-scoped binding fixture: deploys a real single-node DAX cluster
26 * (~10 minutes, billed per node-hour — gated behind AWS_TEST_SLOW) and a
27 * Lambda bound to it with ConnectReadWrite + RebootNode + DescribeClusters +
28 * IncreaseReplicationFactor + DecreaseReplicationFactor.
29 * The Lambda is not VPC-attached: the connect binding only resolves endpoint
30 * env/attributes and RebootNode is a control-plane call.
31 */
32export default DAXSlowTestFunction.make(
33 {
34 main,
35 functionUrl: true,
36 },
37 Effect.gen(function* () {
38 const role = yield* Role("DaxBindingsRole", {
39 assumeRolePolicyDocument: {
40 Version: "2012-10-17",
41 Statement: [
42 {
43 Effect: "Allow",
44 Principal: { Service: "dax.amazonaws.com" },
45 Action: ["sts:AssumeRole"],
46 },
47 ],
48 },
49 managedPolicyArns: [
50 "arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess",
51 ],
52 });
53 const cluster = yield* DAX.Cluster("Cache", {
54 nodeType: "dax.t3.small",
55 replicationFactor: 1,
56 iamRoleArn: role.roleArn,
57 subnetGroupName: SLOW_SUBNET_GROUP_NAME,
58 description: "alchemy dax bindings test cluster",
59 tags: { fixture: "dax-bindings" },
60 });
61
62 const connect = yield* DAX.ConnectReadWrite(cluster);
63 const rebootNode = yield* DAX.RebootNode(cluster);
64 const describeClusters = yield* DAX.DescribeClusters();
65 const increaseReplicationFactor =
66 yield* DAX.IncreaseReplicationFactor(cluster);
67 const decreaseReplicationFactor =
68 yield* DAX.DecreaseReplicationFactor(cluster);
69
70 return {
71 fetch: Effect.gen(function* () {
72 const request = yield* HttpServerRequest;
73 const url = new URL(request.originalUrl);
74 const pathname = url.pathname;
75
76 if (request.method === "GET" && pathname === "/connect") {
77 const info = yield* connect;
78 const prefix = DAX.connectEnvPrefix("Cache");
79 const env = yield* Effect.sync(() => ({
80 host: process.env[`${prefix}_HOST`],
81 port: process.env[`${prefix}_PORT`],
82 url: process.env[`${prefix}_URL`],
83 tls: process.env[`${prefix}_TLS`],
84 }));
85 return yield* HttpServerResponse.json({ info, env });
86 }
87
88 if (request.method === "GET" && pathname === "/reboot") {
89 const nodeId = url.searchParams.get("nodeId");
90 if (!nodeId) {
91 return yield* HttpServerResponse.json(
92 { error: "nodeId query parameter is required" },
93 { status: 400 },
94 );
95 }
96 const result = yield* rebootNode({ NodeId: nodeId });
97 const node = (result.Cluster?.Nodes ?? []).find(
98 (n) => n.NodeId === nodeId,
99 );
100 return yield* HttpServerResponse.json({
101 nodeStatus: node?.NodeStatus,
102 });
103 }
104
105 if (request.method === "GET" && pathname === "/scale-probe") {
106 // Cheap probes for the scaling bindings: invalid target factors
107 // must reach service-side validation and surface a typed tag.
108 // An IAM gap (or a ClusterName-injection bug) would surface
109 // AccessDeniedException / ClusterNotFoundFault instead.
110 const increaseTag = yield* increaseReplicationFactor({
111 // A single-node cluster cannot "increase" to its current size.
112 NewReplicationFactor: 1,
113 }).pipe(
114 Effect.map(() => "Increased"),
115 Effect.catchTag(
116 ["InvalidParameterValueException", "InvalidClusterStateFault"],
117 (e) => Effect.succeed(e._tag),
118 ),
119 );
120 const decreaseTag = yield* decreaseReplicationFactor({
121 // Zero nodes is never a valid replication factor.
122 NewReplicationFactor: 0,
123 }).pipe(
124 Effect.map(() => "Decreased"),
125 Effect.catchTag(
126 ["InvalidParameterValueException", "InvalidClusterStateFault"],
127 (e) => Effect.succeed(e._tag),
128 ),
129 );
130 return yield* HttpServerResponse.json({ increaseTag, decreaseTag });
131 }
132
133 if (request.method === "GET" && pathname === "/nodes") {
134 const info = yield* connect;
135 const clusterName = info.host.split(".")[0] ?? "";
136 const result = yield* describeClusters({
137 ClusterNames: [clusterName],
138 });
139 return yield* HttpServerResponse.json({
140 nodeIds: (result.Clusters?.[0]?.Nodes ?? []).map((n) => n.NodeId),
141 });
142 }
143
144 return yield* HttpServerResponse.json(
145 { error: "Not found", method: request.method, pathname },
146 { status: 404 },
147 );
148 }).pipe(Effect.orDie),
149 };
150 }).pipe(
151 Effect.provide(
152 Layer.mergeAll(
153 DAX.ConnectReadWriteHttp,
154 DAX.RebootNodeHttp,
155 DAX.DescribeClustersHttp,
156 DAX.IncreaseReplicationFactorHttp,
157 DAX.DecreaseReplicationFactorHttp,
158 ),
159 ),
160 ),
161);