1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as rds from "@distilled.cloud/aws/rds";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import RDSDrizzleIamFunctionLive, {
13 RDSDrizzleIamFunction,
14} from "./drizzle-iam-handler";
15import RDSDataTestFunctionLive, { RDSDataTestFunction } from "./handler";
16import { reapRDSDataOrphans } from "./reap";
17
18// Aurora Serverless v2 cluster + writer-instance provisioning takes 5-15
19// minutes — far beyond the speed doctrine's budget — so the ENTIRE live suite
20// (deploy hook, every test, destroy hook) is opt-in behind AWS_TEST_SLOW=1.
21// A run without the env var is skip-clean: no AWS calls, no side effects.
22const SLOW = !!process.env.AWS_TEST_SLOW;
23
24const testOptions = { providers: AWS.providers() };
25const { test, beforeAll, afterAll } = Test.make(testOptions);
26const sharedStack = Core.scratchStack(testOptions, "RDSDataBindings");
27
28let baseUrl: string;
29let drizzleUrl: string;
30let clusterIdentifier: string | undefined;
31
32class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
33 readonly status: number;
34 readonly body: string;
35}> {}
36
37class ClusterStillPresent extends Data.TaggedError("ClusterStillPresent")<{
38 readonly clusterIdentifier: string;
39}> {}
40
41// The fixture surfaces every failure as a 500 (handler-level `Effect.orDie`).
42// A 5xx from this Lambda is either a cold re-init or an rds-data transient
43// (DatabaseResumingException / DatabaseUnavailableException while the
44// serverless cluster scales from idle) — retry those on a generous bounded
45// schedule; a 4xx/assertion failure is surfaced immediately.
46const send = (request: HttpClientRequest.HttpClientRequest) =>
47 HttpClient.execute(request).pipe(
48 Effect.flatMap((response) =>
49 response.status >= 500
50 ? response.text.pipe(
51 Effect.flatMap((body) =>
52 Effect.fail(
53 new TransientUpstream({ status: response.status, body }),
54 ),
55 ),
56 )
57 : Effect.succeed(response),
58 ),
59 Effect.retry({
60 while: (e) => e._tag === "TransientUpstream",
61 schedule: Schedule.max([
62 Schedule.spaced("5 seconds"),
63 Schedule.recurs(8),
64 ]),
65 }),
66 );
67
68const postJson = (url: string, body: unknown) =>
69 send(
70 HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), body),
71 ).pipe(Effect.flatMap((r) => r.json));
72
73// The scratch stack's state store is IN MEMORY, so `sharedStack.destroy()`
74// can only see resources deployed by THIS process. A previous run killed
75// mid-deploy/mid-destroy (Aurora provisioning + teardown run 10+ minutes)
76// leaves the whole VPC/RDS fixture orphaned with no state to destroy from.
77// The reaper deletes fixture leftovers out-of-band by deterministic
78// name-prefix / ownership tag; it runs as a pre-clean before deploy and as
79// an `ensuring` finalizer after destroy.
80const reapOrphans = Core.withProviders(
81 reapRDSDataOrphans,
82 testOptions,
83 sharedStack.name,
84);
85
86describe(
87 "RDSData Bindings",
88 {
89 tags: [
90 "provider:aws",
91 "provider:aws:batch",
92 "provider:aws:ec2",
93 "provider:aws:iam",
94 "provider:aws:lambda",
95 "provider:aws:rds",
96 "provider:aws:rdsdata",
97 "provider:aws:secretsmanager",
98 "live",
99 ],
100 },
101 () => {
102 beforeAll(
103 SLOW
104 ? Effect.gen(function* () {
105 yield* Effect.logInfo(
106 "RDSData test setup: destroying previous resources",
107 );
108 yield* sharedStack.destroy();
109
110 yield* Effect.logInfo(
111 "RDSData test setup: reaping orphans from prior interrupted runs",
112 );
113 yield* reapOrphans;
114
115 yield* Effect.logInfo(
116 "RDSData test setup: deploying Aurora SV2 + Lambda fixture (5-15 min)",
117 );
118 const { dataApiUrl, drizzleIamUrl } = yield* sharedStack.deploy(
119 Effect.gen(function* () {
120 const dataApi = yield* RDSDataTestFunction;
121 const drizzleIam = yield* RDSDrizzleIamFunction;
122 return {
123 dataApiUrl: dataApi.functionUrl,
124 drizzleIamUrl: drizzleIam.functionUrl,
125 };
126 }).pipe(
127 Effect.provide(
128 Layer.mergeAll(
129 RDSDataTestFunctionLive,
130 RDSDrizzleIamFunctionLive,
131 ),
132 ),
133 ),
134 );
135
136 expect(dataApiUrl).toBeTruthy();
137 expect(drizzleIamUrl).toBeTruthy();
138 baseUrl = dataApiUrl!.replace(/\/+$/, "");
139 drizzleUrl = drizzleIamUrl!.replace(/\/+$/, "");
140
141 // Bounded readiness poll (gated-suite only): the cluster is already
142 // `available` when deploy returns (the DBCluster/DBInstance
143 // reconcilers wait for it), so this covers Lambda URL cold start
144 // plus first Data API connection setup. Generous spaced schedule:
145 // 10s x 42 ≈ 7 min ceiling.
146 yield* HttpClient.get(`${baseUrl}/health`).pipe(
147 Effect.flatMap((response) =>
148 response.status === 200
149 ? Effect.succeed(response)
150 : Effect.fail(
151 new Error(`Fixture not ready: ${response.status}`),
152 ),
153 ),
154 Effect.tapError((error) =>
155 Effect.logWarning(
156 `RDSData test setup: fixture not ready yet (${String(error)})`,
157 ),
158 ),
159 Effect.retry({
160 schedule: Schedule.max([
161 Schedule.spaced("10 seconds"),
162 Schedule.recurs(42),
163 ]),
164 }),
165 );
166
167 // Capture the cluster identifier so afterAll can verify deletion
168 // out-of-band after destroy.
169 const meta = (yield* HttpClient.get(`${baseUrl}/meta`).pipe(
170 Effect.flatMap((r) => r.json),
171 )) as { clusterIdentifier: string };
172 clusterIdentifier = meta.clusterIdentifier;
173 yield* Effect.logInfo(
174 `RDSData test setup: fixture ready (cluster ${clusterIdentifier})`,
175 );
176
177 // Create the shared table once for all binding tests.
178 yield* postJson(`${baseUrl}/setup`, {});
179
180 // Bootstrap the IAM-auth DB user (rds_iam + table grants) via the
181 // Data API — the VPC-attached Drizzle fixture can't reach the Data
182 // API itself.
183 yield* postJson(`${baseUrl}/setup-iam-user`, {});
184 })
185 : Effect.void,
186 { timeout: 1_500_000 },
187 );
188
189 afterAll(
190 SLOW
191 ? Effect.gen(function* () {
192 yield* sharedStack.destroy();
193
194 // Out-of-band deletion check via distilled: the DBCluster provider
195 // already waits until the cluster is gone, so this is a bounded
196 // confirmation, not a long poll.
197 if (clusterIdentifier) {
198 yield* Core.withProviders(
199 rds
200 .describeDBClusters({
201 DBClusterIdentifier: clusterIdentifier,
202 })
203 .pipe(
204 Effect.flatMap((response) =>
205 (response.DBClusters ?? []).length === 0
206 ? Effect.void
207 : Effect.fail(
208 new ClusterStillPresent({
209 clusterIdentifier: clusterIdentifier!,
210 }),
211 ),
212 ),
213 Effect.catchTag(
214 "DBClusterNotFoundFault",
215 () => Effect.void,
216 ),
217 Effect.retry({
218 schedule: Schedule.max([
219 Schedule.spaced("15 seconds"),
220 Schedule.recurs(8),
221 ]),
222 }),
223 ),
224 testOptions,
225 sharedStack.name,
226 );
227 }
228 }).pipe(
229 // Always sweep out-of-band leftovers — even when destroy itself
230 // fails partway (e.g. a lingering Lambda ENI blocking SG deletion).
231 // Once this process exits, the in-memory state is gone and nothing
232 // else can ever find these resources again.
233 Effect.ensuring(reapOrphans.pipe(Effect.orDie)),
234 )
235 : Effect.void,
236 { timeout: 1_500_000 },
237 );
238
239 describe("ExecuteStatement", () => {
240 test.provider.skipIf(!SLOW)(
241 "inserts and selects a row with typed parameters",
242 (_stack) =>
243 Effect.gen(function* () {
244 const insert = (yield* postJson(`${baseUrl}/insert`, {
245 id: 1,
246 title: "first",
247 })) as { success: boolean; numberOfRecordsUpdated: number };
248 expect(insert.success).toBe(true);
249 expect(insert.numberOfRecordsUpdated).toBe(1);
250
251 const select = (yield* send(
252 HttpClientRequest.get(`${baseUrl}/select?id=1`),
253 ).pipe(Effect.flatMap((r) => r.json))) as {
254 records: { longValue?: number; stringValue?: string }[][];
255 };
256 expect(select.records).toHaveLength(1);
257 expect(select.records[0]![0]!.longValue).toBe(1);
258 expect(select.records[0]![1]!.stringValue).toBe("first");
259 }),
260 { timeout: 120_000 },
261 );
262
263 test.provider.skipIf(!SLOW)(
264 "returns no records for a missing row",
265 (_stack) =>
266 Effect.gen(function* () {
267 const select = (yield* send(
268 HttpClientRequest.get(`${baseUrl}/select?id=999999`),
269 ).pipe(Effect.flatMap((r) => r.json))) as { records: unknown[] };
270 expect(select.records).toHaveLength(0);
271 }),
272 { timeout: 120_000 },
273 );
274 });
275
276 describe("BatchExecuteStatement", () => {
277 test.provider.skipIf(!SLOW)(
278 "inserts multiple rows via parameterSets",
279 (_stack) =>
280 Effect.gen(function* () {
281 const batch = (yield* postJson(`${baseUrl}/batch-insert`, {
282 rows: [
283 { id: 10, title: "batch-a" },
284 { id: 11, title: "batch-b" },
285 ],
286 })) as { updateResults: unknown[] };
287 expect(batch.updateResults).toHaveLength(2);
288
289 const select = (yield* send(
290 HttpClientRequest.get(`${baseUrl}/select?id=11`),
291 ).pipe(Effect.flatMap((r) => r.json))) as {
292 records: { longValue?: number; stringValue?: string }[][];
293 };
294 expect(select.records[0]![1]!.stringValue).toBe("batch-b");
295 }),
296 { timeout: 120_000 },
297 );
298 });
299
300 describe("BeginTransaction", () => {
301 test.provider.skipIf(!SLOW)(
302 "returns a transaction id",
303 (_stack) =>
304 Effect.gen(function* () {
305 const result = (yield* postJson(`${baseUrl}/tx-commit`, {
306 id: 20,
307 title: "begin",
308 })) as { transactionId: string };
309 expect(result.transactionId).toBeTruthy();
310 }),
311 { timeout: 120_000 },
312 );
313 });
314
315 describe("CommitTransaction", () => {
316 test.provider.skipIf(!SLOW)(
317 "commits an insert so it is visible afterwards",
318 (_stack) =>
319 Effect.gen(function* () {
320 const result = (yield* postJson(`${baseUrl}/tx-commit`, {
321 id: 30,
322 title: "committed",
323 })) as { transactionId: string; transactionStatus: string };
324 expect(result.transactionId).toBeTruthy();
325
326 const select = (yield* send(
327 HttpClientRequest.get(`${baseUrl}/select?id=30`),
328 ).pipe(Effect.flatMap((r) => r.json))) as {
329 records: { longValue?: number; stringValue?: string }[][];
330 };
331 expect(select.records).toHaveLength(1);
332 expect(select.records[0]![1]!.stringValue).toBe("committed");
333 }),
334 { timeout: 120_000 },
335 );
336 });
337
338 describe("RollbackTransaction", () => {
339 test.provider.skipIf(!SLOW)(
340 "rolls back an insert so it never becomes visible",
341 (_stack) =>
342 Effect.gen(function* () {
343 const result = (yield* postJson(`${baseUrl}/tx-rollback`, {
344 id: 40,
345 title: "rolled-back",
346 })) as { transactionId: string; transactionStatus: string };
347 expect(result.transactionId).toBeTruthy();
348
349 const select = (yield* send(
350 HttpClientRequest.get(`${baseUrl}/select?id=40`),
351 ).pipe(Effect.flatMap((r) => r.json))) as { records: unknown[] };
352 expect(select.records).toHaveLength(0);
353 }),
354 { timeout: 120_000 },
355 );
356 });
357
358 // RDS.Connect is not a Data API binding, but it targets the same cluster
359 // and only resolves connection settings (endpoint + secret credentials), so
360 // it shares this fixture instead of provisioning a second Aurora cluster.
361 describe("RDS.Connect", () => {
362 test.provider.skipIf(!SLOW)(
363 "resolves host, port, database and credentials from the secret",
364 (_stack) =>
365 Effect.gen(function* () {
366 const info = (yield* send(
367 HttpClientRequest.get(`${baseUrl}/connect-info`),
368 ).pipe(Effect.flatMap((r) => r.json))) as {
369 host: string;
370 port: number;
371 database?: string;
372 username?: string;
373 hasPassword: boolean;
374 ssl: boolean;
375 };
376 expect(info.host).toMatch(/\.rds\.amazonaws\.com$/);
377 expect(info.port).toBe(5432);
378 expect(info.database).toBe("app");
379 expect(info.username).toBe("app");
380 expect(info.hasPassword).toBe(true);
381 expect(info.ssl).toBe(true);
382 }),
383 { timeout: 120_000 },
384 );
385 });
386
387 // IAM database authentication (db-drivers §3 "RDS/Aurora"): the deploy
388 // half binds `rds-db:connect` on `dbuser:{resourceId}/app_iam` and attaches
389 // the Lambda to the fixture VPC through the binding contract's `vpc`
390 // channel (DECISION #5); the runtime half presigns a 15-minute token as the
391 // password and `Drizzle.Postgres` consumes `ConnectionInfo.url` directly.
392 describe("RDS.Connect (IAM auth)", () => {
393 test.provider.skipIf(!SLOW)(
394 "mints an IAM auth token as the password and can re-mint via refreshPassword",
395 (_stack) =>
396 Effect.gen(function* () {
397 const info = (yield* send(
398 HttpClientRequest.get(`${drizzleUrl}/connect-info`),
399 ).pipe(Effect.flatMap((r) => r.json))) as {
400 host: string;
401 port: number;
402 database?: string;
403 username?: string;
404 hasToken: boolean;
405 ssl: boolean;
406 canRefresh: boolean;
407 };
408 expect(info.host).toMatch(/\.rds\.amazonaws\.com$/);
409 expect(info.port).toBe(5432);
410 expect(info.database).toBe("app");
411 expect(info.username).toBe("app_iam");
412 expect(info.hasToken).toBe(true);
413 expect(info.ssl).toBe(true);
414 expect(info.canRefresh).toBe(true);
415 }),
416 { timeout: 120_000 },
417 );
418
419 test.provider.skipIf(!SLOW)(
420 "Drizzle round-trips over the IAM-auth connection URL",
421 (_stack) =>
422 Effect.gen(function* () {
423 // Socket sanity check first — TLS + token auth + in-VPC route.
424 const health = (yield* send(
425 HttpClientRequest.get(`${drizzleUrl}/drizzle-health`),
426 ).pipe(Effect.flatMap((r) => r.json))) as {
427 rows: { one: number }[];
428 };
429 expect(health.rows[0]?.one).toBe(1);
430
431 // Cross-path consistency: write via the Data API Lambda, read over
432 // the wire protocol from the VPC-attached Lambda.
433 yield* postJson(`${baseUrl}/insert`, {
434 id: 60,
435 title: "written-via-data-api",
436 });
437 const todos = (yield* send(
438 HttpClientRequest.get(`${drizzleUrl}/drizzle-todos`),
439 ).pipe(Effect.flatMap((r) => r.json))) as {
440 rows: { count: number }[];
441 };
442 expect(todos.rows[0]!.count).toBeGreaterThanOrEqual(1);
443 }),
444 { timeout: 180_000 },
445 );
446 });
447
448 // `ExecuteSql` is deprecated (Aurora Serverless v1 era API) — implemented
449 // for completeness but intentionally not exercised against live AWS.
450 },
451);