1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as dsql from "@distilled.cloud/aws/dsql";
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 { Db } from "./fixtures/db";
13import DsqlDirectFunctionLive, {
14 DsqlDirectFunction,
15} from "./fixtures/direct-handler";
16import DsqlDrizzleFunctionLive, {
17 DsqlDrizzleFunction,
18} from "./fixtures/drizzle-handler";
19
20// DSQL clusters are serverless and pay-per-use: provisioning reaches ACTIVE
21// in seconds, so the FULL fixture (cluster + IAM roles + two Lambdas bundling
22// pg/drizzle + live token-auth round-trips + destroy) measures ~30-40s wall —
23// well inside the speed-doctrine budget, hence un-gated.
24const testOptions = { providers: AWS.providers() };
25const { test, beforeAll, afterAll } = Test.make(testOptions);
26const sharedStack = Core.scratchStack(testOptions, "DSQLConnect");
27
28let drizzleUrl: string;
29let directUrl: string;
30let clusterId: string | undefined;
31
32class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
33 readonly status: number;
34 readonly body: string;
35}> {
36 override get message() {
37 return `fixture answered ${this.status}: ${this.body.slice(0, 500)}`;
38 }
39}
40
41class FixtureReadinessFailed extends Data.TaggedError(
42 "FixtureReadinessFailed",
43)<{
44 readonly status: number;
45 readonly body: string;
46}> {
47 override get message() {
48 return `fixture answered ${this.status}: ${this.body.slice(0, 500)}`;
49 }
50}
51
52class ClusterStillPresent extends Data.TaggedError("ClusterStillPresent")<{
53 readonly clusterId: string;
54}> {}
55
56// The fixtures surface every failure as a 500 (handler-level `Effect.orDie`).
57// A 5xx here is a cold init or a first-connect transient — retry bounded;
58// 4xx/assertion failures surface immediately.
59const send = (request: () => HttpClientRequest.HttpClientRequest) =>
60 Effect.suspend(() => HttpClient.execute(request())).pipe(
61 Effect.flatMap((response) =>
62 response.status >= 500
63 ? response.text.pipe(
64 Effect.flatMap((body) =>
65 Effect.fail(
66 new TransientUpstream({ status: response.status, body }),
67 ),
68 ),
69 )
70 : Effect.succeed(response),
71 ),
72 Effect.retry({
73 while: (e) => e._tag === "TransientUpstream",
74 schedule: Schedule.spaced("5 seconds"),
75 times: 8,
76 }),
77 );
78
79const postJson = (url: string, body: unknown) =>
80 send(() =>
81 HttpClientRequest.bodyJsonUnsafe(HttpClientRequest.post(url), body),
82 ).pipe(Effect.flatMap((r) => r.json));
83
84const waitForFixture = (url: string) =>
85 Effect.suspend(() => HttpClient.get(url)).pipe(
86 Effect.flatMap((response) =>
87 response.status === 200
88 ? Effect.void
89 : response.text.pipe(
90 Effect.flatMap((body) => {
91 const error: TransientUpstream | FixtureReadinessFailed =
92 response.status >= 500
93 ? new TransientUpstream({ status: response.status, body })
94 : new FixtureReadinessFailed({
95 status: response.status,
96 body,
97 });
98 return Effect.fail(error);
99 }),
100 ),
101 ),
102 Effect.retry({
103 while: (error) => error._tag === "TransientUpstream",
104 schedule: Schedule.spaced("5 seconds"),
105 times: 18,
106 }),
107 );
108
109describe(
110 "DSQL.Connect",
111 {
112 tags: ["provider:aws", "provider:aws:dsql", "provider:aws:lambda", "live"],
113 },
114 () => {
115 beforeAll(
116 Effect.gen(function* () {
117 yield* Effect.logInfo(
118 "DSQL.Connect setup: destroying previous resources",
119 );
120 yield* sharedStack.destroy();
121
122 yield* Effect.logInfo(
123 "DSQL.Connect setup: deploying DSQL cluster + 2 Lambda fixtures",
124 );
125 const outputs = yield* sharedStack.deploy(
126 Effect.gen(function* () {
127 const cluster = yield* Db;
128 const drizzleFn = yield* DsqlDrizzleFunction;
129 const directFn = yield* DsqlDirectFunction;
130 return {
131 clusterId: cluster.clusterId,
132 drizzleUrl: drizzleFn.functionUrl,
133 directUrl: directFn.functionUrl,
134 };
135 }).pipe(
136 Effect.provide(
137 Layer.mergeAll(DsqlDrizzleFunctionLive, DsqlDirectFunctionLive),
138 ),
139 ),
140 );
141
142 expect(outputs.drizzleUrl).toBeTruthy();
143 expect(outputs.directUrl).toBeTruthy();
144 drizzleUrl = outputs.drizzleUrl!.replace(/\/+$/, "");
145 directUrl = outputs.directUrl!.replace(/\/+$/, "");
146 clusterId = outputs.clusterId;
147
148 // Function URL propagation + cold start + first token-auth connect.
149 // Bounded: retry transient 5xx responses for at most 90 seconds. Each
150 // attempt creates a fresh request; non-transient statuses fail directly.
151 yield* waitForFixture(`${drizzleUrl}/health`);
152 yield* Effect.logInfo(
153 `DSQL.Connect setup: fixtures ready (cluster ${clusterId})`,
154 );
155 }),
156 { timeout: 900_000 },
157 );
158
159 afterAll(
160 Effect.gen(function* () {
161 yield* sharedStack.destroy();
162
163 // Out-of-band deletion check via distilled — a deleted DSQL
164 // cluster is gone or reports DELETING/DELETED.
165 if (clusterId) {
166 yield* Core.withProviders(
167 dsql.getCluster({ identifier: clusterId }).pipe(
168 Effect.flatMap((cluster) =>
169 cluster.status === "DELETING" || cluster.status === "DELETED"
170 ? Effect.void
171 : Effect.fail(
172 new ClusterStillPresent({ clusterId: clusterId! }),
173 ),
174 ),
175 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
176 Effect.retry({
177 while: (e) => e._tag === "ClusterStillPresent",
178 schedule: Schedule.max([
179 Schedule.spaced("5 seconds"),
180 Schedule.recurs(12),
181 ]),
182 }),
183 ),
184 testOptions,
185 sharedStack.name,
186 );
187 }
188 }),
189 { timeout: 900_000 },
190 );
191
192 test.provider(
193 "Drizzle over DSQL.Connect round-trips CREATE TABLE / INSERT / SELECT",
194 (_stack) =>
195 Effect.gen(function* () {
196 const setup = (yield* postJson(`${drizzleUrl}/setup`, {})) as {
197 success: boolean;
198 };
199 expect(setup.success).toBe(true);
200
201 const insert = (yield* postJson(`${drizzleUrl}/insert`, {
202 id: 1,
203 title: "first",
204 })) as { success: boolean };
205 expect(insert.success).toBe(true);
206
207 const select = (yield* send(() =>
208 HttpClientRequest.get(`${drizzleUrl}/select?id=1`),
209 ).pipe(Effect.flatMap((r) => r.json))) as {
210 rows: { id: number; title: string }[];
211 };
212 expect(select.rows).toHaveLength(1);
213 expect(select.rows[0]).toEqual({ id: 1, title: "first" });
214 }),
215 { timeout: 180_000 },
216 );
217
218 test.provider(
219 "resolves connection info with a fresh IAM auth token",
220 (_stack) =>
221 Effect.gen(function* () {
222 const info = (yield* send(() =>
223 HttpClientRequest.get(`${directUrl}/info`),
224 ).pipe(Effect.flatMap((r) => r.json))) as {
225 host: string;
226 port: number;
227 database?: string;
228 username?: string;
229 hasPassword: boolean;
230 ssl: boolean;
231 urlScheme: string;
232 };
233 expect(info.host).toMatch(/^[a-z0-9]+\.dsql\.[a-z0-9-]+\.on\.aws$/);
234 expect(info.port).toBe(5432);
235 expect(info.database).toBe("postgres");
236 expect(info.username).toBe("admin");
237 expect(info.hasPassword).toBe(true);
238 expect(info.ssl).toBe(true);
239 expect(info.urlScheme).toBe("postgresql");
240 }),
241 { timeout: 180_000 },
242 );
243
244 test.provider(
245 "GetVpcEndpointServiceName binding resolves the PrivateLink service name",
246 (_stack) =>
247 Effect.gen(function* () {
248 const response = (yield* send(() =>
249 HttpClientRequest.get(`${directUrl}/vpc-endpoint-service`),
250 ).pipe(Effect.flatMap((r) => r.json))) as {
251 serviceName: string;
252 clusterVpcEndpoint: string | null;
253 };
254 expect(response.serviceName).toMatch(/^com\.amazonaws\./);
255 expect(response.serviceName).toContain("dsql");
256 }),
257 { timeout: 180_000 },
258 );
259
260 test.provider(
261 "connects DIRECTLY to the public endpoint (raw pg, no Drizzle, no VPC)",
262 (_stack) =>
263 Effect.gen(function* () {
264 const roundtrip = (yield* postJson(`${directUrl}/roundtrip`, {})) as {
265 rows: { id: number; title: string }[];
266 host: string;
267 };
268 expect(roundtrip.host).toMatch(/\.dsql\./);
269 expect(roundtrip.rows).toHaveLength(1);
270 expect(roundtrip.rows[0]).toEqual({ id: 1, title: "direct" });
271 }),
272 { timeout: 180_000 },
273 );
274 },
275);