1import * as Lambda from "@/AWS/Lambda";
2import * as RDS from "@/AWS/RDS";
3import * as RDSData from "@/AWS/RDSData";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10import { RDSDataInfra } from "./infra.ts";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14export class RDSDataTestFunction extends Lambda.Function<Lambda.Function>()(
15 "RDSDataTestFunction",
16) {}
17
18/**
19 * Lambda fixture for the RDS Data API bindings.
20 *
21 * Provisions an isolated network (Aurora requires a DB subnet group spanning
22 * two AZs), an ingress-free security group (the Data API is HTTP — the Lambda
23 * never opens a socket to the cluster), and an Aurora Serverless v2 postgres
24 * cluster with the Data API enabled plus a generated admin secret.
25 *
26 * One HTTP route per binding behavior; `Effect.orDie` once at the outer
27 * handler. NOTE: `ExecuteSql` is deprecated (Aurora Serverless v1 era) and is
28 * intentionally NOT exercised here.
29 */
30export default RDSDataTestFunction.make(
31 {
32 main,
33 functionUrl: true,
34 // Data API statements can take tens of seconds while the serverless
35 // cluster scales from idle — keep the Lambda alive through that.
36 timeout: Duration.seconds(60),
37 },
38 Effect.gen(function* () {
39 // Shared Aurora fixture infra (VPC, subnets, SGs, secret, cluster,
40 // writer) — also yielded by the Drizzle IAM fixture; declarations
41 // dedupe by logical ID.
42 const { secret, cluster } = yield* RDSDataInfra;
43
44 const options = { secret, database: "app" };
45
46 const executeStatement = yield* RDSData.ExecuteStatement(cluster, options);
47 const batchExecuteStatement = yield* RDSData.BatchExecuteStatement(
48 cluster,
49 options,
50 );
51 const beginTransaction = yield* RDSData.BeginTransaction(cluster, options);
52 const commitTransaction = yield* RDSData.CommitTransaction(cluster, {
53 secret,
54 });
55 const rollbackTransaction = yield* RDSData.RollbackTransaction(cluster, {
56 secret,
57 });
58 // RDS.Connect resolves connection settings (endpoint + secret credentials)
59 // — no socket is opened, so it is exercisable from this ingress-free
60 // fixture alongside the Data API bindings.
61 const connect = yield* RDS.Connect(cluster, { secret, database: "app" });
62
63 const ClusterIdentifier = yield* cluster.dbClusterIdentifier;
64 const ClusterArn = yield* cluster.dbClusterArn;
65
66 return {
67 fetch: Effect.gen(function* () {
68 const request = yield* HttpServerRequest;
69 const url = new URL(request.originalUrl);
70 const pathname = url.pathname;
71
72 // Deployment metadata so the test can verify cluster deletion
73 // out-of-band after destroy.
74 if (request.method === "GET" && pathname === "/meta") {
75 const clusterIdentifier = yield* ClusterIdentifier;
76 const clusterArn = yield* ClusterArn;
77 return yield* HttpServerResponse.json({
78 clusterIdentifier,
79 clusterArn,
80 });
81 }
82
83 // RDS.Connect binding: resolves host/port from the cluster endpoint
84 // and credentials from the Secrets Manager secret. The password is
85 // never echoed back — only its presence.
86 if (request.method === "GET" && pathname === "/connect-info") {
87 const info = yield* connect;
88 return yield* HttpServerResponse.json({
89 host: info.host,
90 port: info.port,
91 database: info.database,
92 username: info.username,
93 hasPassword:
94 typeof info.password === "string" && info.password.length > 0,
95 ssl: info.ssl,
96 });
97 }
98
99 // Cheap readiness probe (also proves ExecuteStatement end-to-end).
100 if (request.method === "GET" && pathname === "/health") {
101 const result = yield* executeStatement({ sql: "SELECT 1" });
102 return yield* HttpServerResponse.json({
103 records: result.records ?? [],
104 });
105 }
106
107 if (request.method === "POST" && pathname === "/setup") {
108 const result = yield* executeStatement({
109 sql: "CREATE TABLE IF NOT EXISTS todos (id INT PRIMARY KEY, title TEXT NOT NULL)",
110 });
111 return yield* HttpServerResponse.json({
112 success: true,
113 numberOfRecordsUpdated: result.numberOfRecordsUpdated ?? 0,
114 });
115 }
116
117 // Bootstrap the IAM-auth database user for the Drizzle fixture:
118 // `rds_iam` switches the user to token authentication, and the
119 // grants let it read the shared `todos` table. Idempotent so the
120 // suite can re-run against a standing deployment.
121 if (request.method === "POST" && pathname === "/setup-iam-user") {
122 yield* executeStatement({
123 sql: "DO $$ BEGIN CREATE USER app_iam WITH LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$",
124 });
125 yield* executeStatement({ sql: "GRANT rds_iam TO app_iam" });
126 yield* executeStatement({
127 sql: "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_iam",
128 });
129 return yield* HttpServerResponse.json({ success: true });
130 }
131
132 if (request.method === "POST" && pathname === "/insert") {
133 const body = (yield* request.json) as unknown as {
134 id: number;
135 title: string;
136 };
137 const result = yield* executeStatement({
138 sql: "INSERT INTO todos (id, title) VALUES (:id, :title) ON CONFLICT (id) DO UPDATE SET title = :title",
139 parameters: [
140 { name: "id", value: { longValue: body.id } },
141 { name: "title", value: { stringValue: body.title } },
142 ],
143 });
144 return yield* HttpServerResponse.json({
145 success: true,
146 numberOfRecordsUpdated: result.numberOfRecordsUpdated ?? 0,
147 });
148 }
149
150 if (request.method === "GET" && pathname === "/select") {
151 const id = url.searchParams.get("id");
152 if (!id) {
153 return HttpServerResponse.text("Missing id", { status: 400 });
154 }
155 const result = yield* executeStatement({
156 sql: "SELECT id, title FROM todos WHERE id = :id",
157 parameters: [{ name: "id", value: { longValue: Number(id) } }],
158 });
159 return yield* HttpServerResponse.json({
160 records: result.records ?? [],
161 });
162 }
163
164 if (request.method === "GET" && pathname === "/count") {
165 const result = yield* executeStatement({
166 sql: "SELECT count(*)::int FROM todos",
167 });
168 return yield* HttpServerResponse.json({
169 records: result.records ?? [],
170 });
171 }
172
173 if (request.method === "POST" && pathname === "/batch-insert") {
174 const body = (yield* request.json) as unknown as {
175 rows: { id: number; title: string }[];
176 };
177 const result = yield* batchExecuteStatement({
178 sql: "INSERT INTO todos (id, title) VALUES (:id, :title) ON CONFLICT (id) DO UPDATE SET title = :title",
179 parameterSets: body.rows.map((row) => [
180 { name: "id", value: { longValue: row.id } },
181 { name: "title", value: { stringValue: row.title } },
182 ]),
183 });
184 return yield* HttpServerResponse.json({
185 updateResults: result.updateResults ?? [],
186 });
187 }
188
189 // Begin → insert inside the transaction → commit.
190 if (request.method === "POST" && pathname === "/tx-commit") {
191 const body = (yield* request.json) as unknown as {
192 id: number;
193 title: string;
194 };
195 const tx = yield* beginTransaction();
196 yield* executeStatement({
197 sql: "INSERT INTO todos (id, title) VALUES (:id, :title)",
198 parameters: [
199 { name: "id", value: { longValue: body.id } },
200 { name: "title", value: { stringValue: body.title } },
201 ],
202 transactionId: tx.transactionId,
203 });
204 const commit = yield* commitTransaction({
205 transactionId: tx.transactionId!,
206 });
207 return yield* HttpServerResponse.json({
208 transactionId: tx.transactionId,
209 transactionStatus: commit.transactionStatus,
210 });
211 }
212
213 // Begin → insert inside the transaction → rollback (row must vanish).
214 if (request.method === "POST" && pathname === "/tx-rollback") {
215 const body = (yield* request.json) as unknown as {
216 id: number;
217 title: string;
218 };
219 const tx = yield* beginTransaction();
220 yield* executeStatement({
221 sql: "INSERT INTO todos (id, title) VALUES (:id, :title)",
222 parameters: [
223 { name: "id", value: { longValue: body.id } },
224 { name: "title", value: { stringValue: body.title } },
225 ],
226 transactionId: tx.transactionId,
227 });
228 const rollback = yield* rollbackTransaction({
229 transactionId: tx.transactionId!,
230 });
231 return yield* HttpServerResponse.json({
232 transactionId: tx.transactionId,
233 transactionStatus: rollback.transactionStatus,
234 });
235 }
236
237 return yield* HttpServerResponse.json(
238 { error: "Not found", method: request.method, pathname },
239 { status: 404 },
240 );
241 }).pipe(Effect.orDie),
242 };
243 }).pipe(
244 Effect.provide(
245 Layer.mergeAll(
246 RDSData.ExecuteStatementHttp,
247 RDSData.BatchExecuteStatementHttp,
248 RDSData.BeginTransactionHttp,
249 RDSData.CommitTransactionHttp,
250 RDSData.RollbackTransactionHttp,
251 RDS.ConnectHttp,
252 ),
253 ),
254 ),
255);