1import * as hyperdrive from "@distilled.cloud/cloudflare/hyperdrive";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.ts";
9import * as ProviderLayer from "../../Local/ProviderLayer.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { isResourceOfType, Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import { localAccountId } from "../LocalAccount.ts";
15import { generateLocalId } from "../LocalRuntime.ts";
16import type { Providers } from "../Providers.ts";
17
18export type Scheme = "postgres" | "postgresql" | "mysql";
19
20/**
21 * Origin configuration for a public PostgreSQL or MySQL database.
22 */
23export type PublicOrigin = {
24 scheme: Scheme;
25 host: string;
26 port?: number;
27 database: string;
28 user: string;
29 /**
30 * Database password.
31 */
32 password: Redacted.Redacted<string>;
33};
34
35/**
36 * Origin configuration for a database fronted by Cloudflare Access.
37 */
38export type AccessOrigin = {
39 scheme: Scheme;
40 host: string;
41 database: string;
42 user: string;
43 password: Redacted.Redacted<string>;
44 accessClientId: Redacted.Redacted<string>;
45 accessClientSecret: Redacted.Redacted<string>;
46};
47
48export type Origin = PublicOrigin | AccessOrigin;
49
50export type Caching = {
51 /**
52 * Whether caching is disabled.
53 * @default false
54 */
55 disabled?: boolean;
56 /**
57 * Maximum duration items should persist in the cache, in seconds.
58 * @default 60
59 */
60 maxAge?: number;
61 /**
62 * Number of seconds the cache may serve a stale response while revalidating.
63 * @default 15
64 */
65 staleWhileRevalidate?: number;
66};
67
68export type Mtls = {
69 caCertificateId?: string;
70 mtlsCertificateId?: string;
71 /**
72 * @default "require"
73 */
74 sslmode?: "require" | "verify-ca" | "verify-full";
75};
76
77export type DevOrigin = PublicOrigin & {
78 /**
79 * @default "prefer"
80 */
81 sslmode?: "disable" | "prefer" | "require" | "verify-ca" | "verify-full";
82};
83
84export type Props = {
85 /**
86 * Name of the Hyperdrive configuration. If omitted, a unique name will be
87 * generated.
88 * @default ${app}-${stage}-${id}
89 */
90 name?: string;
91 /**
92 * Database connection origin. Hyperdrive supports public Postgres/MySQL
93 * databases and databases fronted by Cloudflare Access.
94 */
95 origin: Origin;
96 /**
97 * Caching configuration.
98 */
99 caching?: Caching;
100 /**
101 * mTLS configuration.
102 */
103 mtls?: Mtls;
104 /**
105 * The (soft) maximum number of connections Hyperdrive is allowed to make to
106 * the origin database.
107 */
108 originConnectionLimit?: number;
109 /**
110 * Local development overrides. When the stack runs in dev mode
111 * connect to a locally running database
112 */
113 dev?: DevOrigin;
114};
115
116export type Connection = Resource<
117 "Cloudflare.Hyperdrive",
118 Props,
119 {
120 hyperdriveId: string;
121 name: string;
122 accountId: string;
123 origin: Origin;
124 mtls: Mtls;
125 dev: DevOrigin | undefined;
126 },
127 never,
128 Providers
129>;
130
131/**
132 * A Cloudflare Hyperdrive configuration.
133 *
134 * Hyperdrive accelerates and pools connections to existing PostgreSQL or
135 * MySQL databases, exposing them to Workers via a binding. Create a config
136 * as a resource, then bind it to a Worker to obtain a connection string.
137 * ### Creating a Hyperdrive
138 * **Example:** Public Postgres origin
139 * ```typescript
140 * const hd = yield* Cloudflare.Hyperdrive.Connection("my-pg", {
141 * origin: {
142 * scheme: "postgres",
143 * host: "db.example.com",
144 * port: 5432,
145 * database: "app",
146 * user: "app",
147 * password: yield* Config.Redacted("DB_PASSWORD"),
148 * },
149 * });
150 * ```
151 *
152 * ### Binding to a Worker
153 * **Example:** Using Hyperdrive inside a Worker
154 * ```typescript
155 * const hd = yield* Cloudflare.Hyperdrive.Connect(MyConnection);
156 * const url = yield* hd.connectionString;
157 * ```
158 *
159 * @resource
160 * @product Hyperdrive
161 * @category Storage & Databases
162 */
163export const Connection = Resource<Connection>("Cloudflare.Hyperdrive");
164
165export const isHyperdriveConnection = (value: unknown): value is Connection =>
166 isResourceOfType(value, "Cloudflare.Hyperdrive");
167
168export const ProviderLive = () =>
169 Provider.succeed(Connection, {
170 // `hyperdriveId` is stable within live mode: `updateConfig` never changes
171 // it, and the dev→deploy transition (which DOES mint a new id) is a
172 // mode-switch the engine plans as a REPLACEMENT, so updates can rely on it.
173 stables: ["hyperdriveId", "accountId"],
174 list: Effect.fn(function* () {
175 const { accountId } = yield* yield* CloudflareEnvironment;
176 return yield* hyperdrive.listConfigs.pages({ accountId }).pipe(
177 Stream.runCollect,
178 Effect.map((chunk) =>
179 Array.from(chunk).flatMap((page) =>
180 (page.result ?? []).map((c) => ({
181 hyperdriveId: c.id,
182 name: c.name,
183 accountId,
184 // The connection-string password is write-only and never
185 // returned by the API, so it is absent here (matching `read`,
186 // which sources it from `olds`).
187 origin: { ...c.origin } as Origin,
188 mtls: {
189 caCertificateId: c.mtls?.caCertificateId ?? undefined,
190 mtlsCertificateId: c.mtls?.mtlsCertificateId ?? undefined,
191 sslmode: c.mtls?.sslmode ?? undefined,
192 } as Mtls,
193 dev: undefined,
194 })),
195 ),
196 ),
197 );
198 }),
199 diff: Effect.fn(function* ({ id, olds, news, output }) {
200 const { accountId } = yield* yield* CloudflareEnvironment;
201 if (!isResolved(news)) return undefined;
202 if ((output?.accountId ?? accountId) !== accountId) {
203 return { action: "replace" } as const;
204 }
205 const oldName = output?.name ?? (yield* createConfigName(id, olds.name));
206 // Auto-generated names are engine-owned: the deployed name stays
207 // authoritative even if the generator would name this id differently
208 // today. Only an explicit user-provided name can force a replace.
209 const name = news.name ?? oldName;
210 if (oldName !== name) {
211 return { action: "replace" } as const;
212 }
213 }),
214 read: Effect.fn(function* ({ id, output, olds }) {
215 const { accountId } = yield* yield* CloudflareEnvironment;
216 if (output?.hyperdriveId) {
217 return yield* hyperdrive
218 .getConfig({
219 accountId: output.accountId,
220 hyperdriveId: output.hyperdriveId,
221 })
222 .pipe(
223 Effect.map((c) => ({
224 hyperdriveId: c.id,
225 name: c.name,
226 accountId: output.accountId,
227 origin: {
228 ...c.origin,
229 password: olds?.origin?.password,
230 } as Origin,
231 mtls: {
232 caCertificateId: c.mtls?.caCertificateId ?? undefined,
233 mtlsCertificateId: c.mtls?.mtlsCertificateId ?? undefined,
234 sslmode: c.mtls?.sslmode ?? undefined,
235 } as Mtls,
236 dev: output?.dev,
237 })),
238 Effect.catchTag("HyperdriveConfigNotFound", () =>
239 Effect.succeed(undefined),
240 ),
241 );
242 }
243 const name = yield* createConfigName(id, olds?.name);
244 const match = yield* findByName(name);
245 if (match) {
246 return {
247 hyperdriveId: match.id,
248 name: match.name,
249 accountId,
250 origin: {
251 ...match.origin,
252 password: olds?.origin?.password,
253 } as Origin,
254 mtls: {
255 caCertificateId: match.mtls?.caCertificateId ?? undefined,
256 mtlsCertificateId: match.mtls?.mtlsCertificateId ?? undefined,
257 sslmode: match.mtls?.sslmode ?? undefined,
258 } as Mtls,
259 dev: output?.dev,
260 };
261 }
262 return undefined;
263 }),
264 reconcile: Effect.fn(function* ({ id, news, output }) {
265 const { accountId } = yield* yield* CloudflareEnvironment;
266 const name = output?.name ?? (yield* createConfigName(id, news.name));
267
268 const requestBody = {
269 origin: toRequestOrigin(news.origin),
270 caching: news.caching,
271 mtls: news.mtls,
272 originConnectionLimit: news.originConnectionLimit,
273 };
274
275 // Observe + ensure. When we know the hyperdriveId we go straight
276 // to update; otherwise we createConfig and fall back to "find by
277 // name then update" if Cloudflare reports the name is already in
278 // use (race or a cold-start adoption).
279 const synced = yield* (
280 output?.hyperdriveId
281 ? hyperdrive.updateConfig({
282 accountId: output.accountId,
283 hyperdriveId: output.hyperdriveId,
284 name: output.name,
285 ...requestBody,
286 })
287 : hyperdrive.createConfig({ accountId, name, ...requestBody }).pipe(
288 Effect.catchTag("InvalidHyperdriveConfig", (originalError) =>
289 Effect.gen(function* () {
290 const match = yield* findByName(name);
291 if (!match) {
292 return yield* Effect.fail(originalError);
293 }
294 return yield* hyperdrive.updateConfig({
295 accountId,
296 hyperdriveId: match.id,
297 name,
298 ...requestBody,
299 });
300 }),
301 ),
302 )
303 ).pipe(
304 Effect.retry({
305 while: (error) => error._tag === "HyperdriveOriginUnavailable",
306 schedule: Schedule.spaced("2 seconds"),
307 times: 10,
308 }),
309 );
310
311 return {
312 hyperdriveId: synced.id,
313 name: synced.name,
314 accountId: output?.accountId ?? accountId,
315 origin: news.origin,
316 mtls: news.mtls ?? {},
317 dev: news.dev,
318 };
319 }),
320 delete: Effect.fn(function* ({ output }) {
321 yield* hyperdrive
322 .deleteConfig({
323 accountId: output.accountId,
324 hyperdriveId: output.hyperdriveId,
325 })
326 .pipe(Effect.catchTag("HyperdriveConfigNotFound", () => Effect.void));
327 }),
328 });
329
330/**
331 * Local (dev) provider — the config is purely virtual: reconcile mints a
332 * `dev:` id and echoes the desired props into attributes, with no cloud API
333 * calls. The actual local behavior lives in the Worker binding: the local
334 * runtime's Hyperdrive plugin is an origin passthrough driven by the
335 * `hyperdrives` record `ConnectBinding` lowers (the `dev` prop when set,
336 * otherwise the real `origin`).
337 */
338export const ProviderLocal = () =>
339 Provider.succeed(Connection, {
340 stables: ["accountId"],
341 diff: Effect.fn(function* ({ news, output }) {
342 const accountId = yield* localAccountId;
343 if (!output?.hyperdriveId) return { action: "update" } as const;
344 if (!isResolved(news)) return undefined;
345 if (output.accountId !== accountId) {
346 return { action: "replace" } as const;
347 }
348 // Fall through to the engine's default prop diff (name/origin/dev
349 // changes update in place; reconcile re-echoes them).
350 }),
351 read: Effect.fn(function* ({ output }) {
352 // Purely virtual — the persisted state row is the source of truth.
353 return output ?? undefined;
354 }),
355 reconcile: Effect.fn(function* ({ id, news, output }) {
356 const accountId = yield* localAccountId;
357 return {
358 hyperdriveId: output?.hyperdriveId ?? generateLocalId(),
359 name: yield* createConfigName(id, news.name),
360 accountId: output?.accountId ?? accountId,
361 origin: news.origin,
362 mtls: news.mtls ?? {},
363 dev: news.dev,
364 };
365 }),
366 delete: Effect.fn(function* () {
367 // Purely virtual — dropping the state row is enough.
368 }),
369 });
370
371export const ConnectionProvider = () =>
372 ProviderLayer.dual(Connection, {
373 local: () => ProviderLocal(),
374 live: () => ProviderLive(),
375 });
376
377const createConfigName = (id: string, name: string | undefined) =>
378 Effect.gen(function* () {
379 if (name) return name;
380 return yield* createPhysicalName({ id, lowercase: true });
381 });
382
383const findByName = (name: string) =>
384 Effect.gen(function* () {
385 const { accountId } = yield* yield* CloudflareEnvironment;
386 return yield* hyperdrive.listConfigs.items({ accountId }).pipe(
387 Stream.filter((c) => c.name === name),
388 Stream.runHead,
389 Effect.map(Option.getOrUndefined),
390 );
391 });
392
393export const defaultPort = (scheme: Scheme): number =>
394 scheme === "mysql" ? 3306 : 5432;
395
396const unwrap = (v: string | Redacted.Redacted<string>): string =>
397 Redacted.isRedacted(v) ? Redacted.value(v) : v;
398
399/**
400 * Build the request body shape that the distilled `createConfig`/`updateConfig`
401 * methods accept. Secrets are unwrapped here because the distilled TS types
402 * declare `password`/`access_client_secret` as plain strings even though the
403 * runtime schema also accepts `Redacted<string>`.
404 */
405const toRequestOrigin = (origin: Origin) => {
406 if ("accessClientId" in origin) {
407 return {
408 accessClientId: unwrap(origin.accessClientId),
409 accessClientSecret: unwrap(origin.accessClientSecret),
410 database: origin.database,
411 host: origin.host,
412 password: unwrap(origin.password),
413 scheme: origin.scheme,
414 user: origin.user,
415 };
416 }
417 return {
418 database: origin.database,
419 host: origin.host,
420 password: unwrap(origin.password),
421 port: origin.port ?? defaultPort(origin.scheme),
422 scheme: origin.scheme,
423 user: origin.user,
424 };
425};