1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Neon from "@/Neon/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import type * as HttpClientResponse from "effect/http/HttpClientResponse";
12import Stack from "./fixtures/stack.ts";
13
14const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
15 providers: Layer.merge(Cloudflare.providers(), Neon.providers()),
16 state: Cloudflare.state(),
17});
18
19const HOOK_TIMEOUT = 300_000;
20const TEST_TIMEOUT = 120_000;
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
28 status: number;
29 body: string;
30}> {}
31
32// Bounded spaced schedule — caps total wait so a genuine failure surfaces
33// fast instead of an uncapped exponential blowing past the test timeout
34// while riding out cold-start propagation.
35const ready = Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(30)]);
36
37/** Retry an HTTP call until it returns 200 (rides out cold-start 404s). */
38const untilOk = <E, R>(
39 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
40) =>
41 eff.pipe(
42 Effect.flatMap((res) =>
43 res.status === 200
44 ? Effect.succeed(res)
45 : res.text.pipe(
46 Effect.flatMap((body) =>
47 Effect.fail(new WorkerNotReady({ status: res.status, body })),
48 ),
49 ),
50 ),
51 Effect.retry({
52 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
53 schedule: ready,
54 }),
55 );
56
57interface Meta {
58 host: string;
59 port: number;
60 user: string;
61 database: string;
62 hasConnectionString: boolean;
63 hasPassword: boolean;
64 connectionStringProtocol: string;
65 connectionStringHost: string;
66}
67
68/** GET `${base}/meta` and assert the Hyperdrive binding resolved at runtime. */
69const assertMeta = (base: string) =>
70 Effect.gen(function* () {
71 const res = yield* untilOk(HttpClient.get(`${base}/meta`));
72 const meta = (yield* res.json) as unknown as Meta;
73
74 // The binding resolved to a real runtime Hyperdrive object: every field
75 // is populated and the connection string is a well-formed Postgres URI
76 // pointing at Hyperdrive's local proxy host (NOT the origin host — the
77 // proxy is what Workers connect through).
78 expect(meta.host).toBeTruthy();
79 expect(meta.port).toBeGreaterThan(0);
80 expect(meta.user).toBeTruthy();
81 expect(meta.database).toBeTruthy();
82 expect(meta.hasConnectionString).toBe(true);
83 expect(meta.hasPassword).toBe(true);
84 expect(meta.connectionStringProtocol).toMatch(/^postgres(ql)?:$/);
85 // The connection string host matches the binding's `host` (the local
86 // Hyperdrive proxy), proving the parsed URI and discrete fields agree.
87 expect(meta.connectionStringHost).toBe(meta.host);
88 return meta;
89 });
90
91/**
92 * Deploys two Workers that bind ONE shared Hyperdrive Connection (fronting a
93 * real Neon Postgres origin) via {@link Stack} — the effect-worker
94 * (`Cloudflare.Hyperdrive.Connect`) and the async-worker (`env: { HD }`) —
95 * then drives each binding flavor over `fetch`, asserting the runtime
96 * Hyperdrive object resolves with a well-formed connection string and the
97 * discrete host/port/user/database fields.
98 *
99 * Hyperdrive needs a reachable Postgres origin; the stack provisions one via
100 * `Neon.Project`, so the binding is exercised against a real database config.
101 */
102const stack = beforeAll(deploy(Stack), { timeout: HOOK_TIMEOUT });
103afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
104 timeout: HOOK_TIMEOUT,
105});
106
107// ── Effect-native binding ── `yield* Cloudflare.Hyperdrive.Connect(connection)`
108test(
109 "effect-worker: Hyperdrive.Connect resolves the runtime binding",
110 Effect.gen(function* () {
111 const out = yield* stack;
112 yield* assertMeta(out.effectWorkerUrl);
113 }).pipe(logLevel),
114 {
115 tags: [
116 "provider:cloudflare",
117 "provider:cloudflare:hyperdrive",
118 "provider:cloudflare:worker",
119 "provider:neon",
120 "provider:neon:project",
121 "live",
122 ],
123 timeout: TEST_TIMEOUT,
124 },
125);
126
127// `raw` escape hatch resolves to the underlying runtime Hyperdrive object.
128test(
129 "effect-worker: raw escape hatch exposes the runtime Hyperdrive",
130 Effect.gen(function* () {
131 const out = yield* stack;
132 const res = yield* untilOk(HttpClient.get(`${out.effectWorkerUrl}/raw`));
133 const raw = (yield* res.json) as {
134 host: string;
135 port: number;
136 user: string;
137 database: string;
138 };
139 expect(raw.host).toBeTruthy();
140 expect(raw.port).toBeGreaterThan(0);
141 expect(raw.user).toBeTruthy();
142 expect(raw.database).toBeTruthy();
143 }).pipe(logLevel),
144 {
145 tags: [
146 "provider:cloudflare",
147 "provider:cloudflare:hyperdrive",
148 "provider:cloudflare:worker",
149 "provider:neon",
150 "provider:neon:project",
151 "live",
152 ],
153 timeout: TEST_TIMEOUT,
154 },
155);
156
157// ── Async (non-Effect) binding ── declared on `env`, resolved by `InferEnv`.
158test(
159 "async-worker: env.HD exposes the runtime Hyperdrive binding",
160 Effect.gen(function* () {
161 const out = yield* stack;
162 yield* assertMeta(out.asyncWorkerUrl);
163 }).pipe(logLevel),
164 {
165 tags: [
166 "provider:cloudflare",
167 "provider:cloudflare:hyperdrive",
168 "provider:cloudflare:worker",
169 "provider:neon",
170 "provider:neon:project",
171 "live",
172 ],
173 timeout: TEST_TIMEOUT,
174 },
175);