1import * as MysqlClient from "@effect/sql-mysql2/MysqlClient";
2import type * as Mysql from "mysql2";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import * as Sql from "effect/sql/SqlClient";
8import { makeExecutionMemo } from "../Runtime/ExecutionMemo.ts";
9import { proxyChain } from "../Util/proxy-chain.ts";
10
11/**
12 * Options for {@link MySQL}: `@effect/sql-mysql2`'s client configuration,
13 * with `url` widened to also accept an Effect (e.g. a Hyperdrive connection
14 * string, which resolves from the Worker environment at runtime).
15 */
16export type MySQLConfig<E = never, R = never> = Omit<
17 MysqlClient.MysqlClientConfig,
18 "url"
19> & {
20 readonly url:
21 | Redacted.Redacted<string>
22 | Effect.Effect<Redacted.Redacted<string>, E, R>;
23};
24
25const isWorkerd = () =>
26 (globalThis as { navigator?: { userAgent?: string } }).navigator
27 ?.userAgent === "Cloudflare-Workers" || "WebSocketPair" in globalThis;
28
29// Query-string params are JSON-parsed into poolConfig entries (mysql2's own
30// URI convention), so `mysql://...?ssl={"rejectUnauthorized":true}` works.
31const parseMySQLUrl = (url: Redacted.Redacted<string>) =>
32 Effect.try({
33 try: () => {
34 const u = new URL(Redacted.value(url));
35 const poolConfig: Record<string, unknown> = {};
36 for (const [key, value] of u.searchParams) {
37 try {
38 poolConfig[key] = JSON.parse(value);
39 } catch {
40 poolConfig[key] = value;
41 }
42 }
43 const database = decodeURIComponent(u.pathname.replace(/^\//, ""));
44 return {
45 host: u.hostname,
46 port: u.port === "" ? 3306 : Number(u.port),
47 database: database === "" ? undefined : database,
48 username:
49 u.username === "" ? undefined : decodeURIComponent(u.username),
50 password:
51 u.password === ""
52 ? undefined
53 : Redacted.make(decodeURIComponent(u.password)),
54 poolConfig: poolConfig as Mysql.PoolOptions,
55 };
56 },
57 catch: (cause) =>
58 new Error(`SQL.MySQL: failed to parse connection url: ${cause}`),
59 }).pipe(Effect.orDie);
60
61/**
62 * Resolve a {@link MySQLConfig} into the `MysqlClientConfig` handed to
63 * `@effect/sql-mysql2`. The `url` is parsed into discrete connection fields
64 * (mysql2's URI code path ignores `poolConfig`), and on workerd the defaults
65 * flip to `poolConfig.disableEval` (no runtime codegen in the isolate) and
66 * `disablePreparedStatements` (Hyperdrive's MySQL proxy has no
67 * `COM_STMT_PREPARE`). Explicit config fields always win over parsed /
68 * detected values.
69 */
70export const resolveMySQLConfig = <E = never, R = never>(
71 config: MySQLConfig<E, R>,
72): Effect.Effect<MysqlClient.MysqlClientConfig, E, R> =>
73 Effect.gen(function* () {
74 const { url, ...overrides } = config;
75 const resolved = Effect.isEffect(url) ? yield* url : url;
76 const parsed = yield* parseMySQLUrl(resolved);
77 const workerd = yield* Effect.sync(isWorkerd);
78 return {
79 ...overrides,
80 host: overrides.host ?? parsed.host,
81 port: overrides.port ?? parsed.port,
82 database: overrides.database ?? parsed.database,
83 username: overrides.username ?? parsed.username,
84 password: overrides.password ?? parsed.password,
85 poolConfig: {
86 ...(workerd ? { disableEval: true } : {}),
87 ...parsed.poolConfig,
88 ...overrides.poolConfig,
89 },
90 disablePreparedStatements: overrides.disablePreparedStatements ?? workerd,
91 } satisfies MysqlClient.MysqlClientConfig;
92 });
93
94/**
95 * Open an `@effect/sql-mysql2` client (a connection pool) from a connection
96 * URL — a plain `Redacted` or an Effect of one, e.g. Hyperdrive's
97 * `connectionString`:
98 *
99 * ```typescript
100 * import * as SQL from "alchemy/SQL/MySQL";
101 *
102 * const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
103 * const sql = yield* SQL.MySQL({ url: hd.connectionString });
104 *
105 * fetch: Effect.gen(function* () {
106 * const users = yield* sql`SELECT * FROM users`;
107 * });
108 * ```
109 *
110 * The pool opens on the first query of an execution, is reused for every
111 * query in it, and closes when the event settles (see
112 * {@link makeExecutionMemo}); plan/deploy never connect. Workers defaults
113 * ({@link resolveMySQLConfig}) are overridden in the config:
114 *
115 * ```typescript
116 * const sql = yield* SQL.MySQL({
117 * url,
118 * disablePreparedStatements: true,
119 * poolConfig: { ssl: { rejectUnauthorized: true } },
120 * });
121 * ```
122 *
123 * @binding
124 */
125export const MySQL = <E = never, R = never>(config: MySQLConfig<E, R>) =>
126 Effect.map(
127 makeExecutionMemo(
128 Effect.gen(function* () {
129 const resolved = yield* resolveMySQLConfig(config);
130 const mysqlCtx = yield* Layer.build(MysqlClient.layer(resolved));
131 return Context.get(mysqlCtx, MysqlClient.MysqlClient);
132 }),
133 ),
134 (client) => proxyChain<MysqlClient.MysqlClient>(client),
135 );
136
137/**
138 * Provide an `@effect/sql-mysql2` client as the `MysqlClient` and generic
139 * `SqlClient` services:
140 *
141 * ```typescript
142 * const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
143 * const app = yield* makeApp.pipe(
144 * Effect.provide(SQL.MySQLLayer({ url: hd.connectionString })),
145 * );
146 * ```
147 *
148 * The layer itself builds synchronously at init; the underlying pool is
149 * created lazily per execution (see {@link MySQL}).
150 */
151export const MySQLLayer = <E = never, R = never>(config: MySQLConfig<E, R>) =>
152 // Derive SqlClient from the single MysqlClient build so both tags share
153 // one per-execution pool.
154 Layer.effect(
155 Sql.SqlClient,
156 Effect.gen(function* () {
157 return yield* MysqlClient.MysqlClient;
158 }),
159 ).pipe(
160 Layer.provideMerge(Layer.effect(MysqlClient.MysqlClient, MySQL(config))),
161 );