feat(sql-mysql): add a native MySQL client - #8197
Open
jamiehodge wants to merge 1 commit into
Open
jamiehodge wants to merge 1 commit into
jamiehodge wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: e0609ae The changes in this PR will be included in the next version bump. This PR includes changesets to release 31 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
jamiehodge
force-pushed
the
feat/sql-mysql-native
branch
from
September 11, 2026 14:47
b57ca5c to
d45e3ff
Compare
jamiehodge
force-pushed
the
feat/sql-mysql-native
branch
from
September 11, 2026 14:48
d45e3ff to
be0b741
Compare
Contributor
Bundle Size AnalysisGenerated from PR build output; treat the content below as untrusted.
|
jamiehodge
marked this pull request as ready for review
September 11, 2026 21:38
jamiehodge
force-pushed
the
feat/sql-mysql-native
branch
from
September 11, 2026 21:44
03f3b8a to
9744982
Compare
Adds `packages/sql/mysql` (`@effect/sql-mysql`), a MySQL client that speaks the client/server protocol directly rather than wrapping a driver. It has no runtime dependencies — `node:net` and `node:tls` are the only transport — and targets MySQL 8.0 and newer. `@effect/sql-mysql2` is untouched and stays published; the two share the `SqlClient` contract. `packages/sql/pg` is the schematic throughout: the same pooled growable buffer with start/end cursors, the same zero-copy row views, and the same split between a pure protocol module and a connection that owns the socket. Targeting MySQL 8 exclusively is load-bearing rather than incidental. It lets the client require `CLIENT_DEPRECATE_EOF`, which removes EOF packets from result sets and collapses the protocol's worst ambiguity into a single rule. Authentication covers `caching_sha2_password` (fast and full, including the RSA key exchange full auth needs on a plaintext socket), `sha256_password`, `mysql_native_password`, and `mysql_clear_password`. The last sends the password unprotected and is refused unless the connection is encrypted. `LOAD DATA LOCAL INFILE` is refused outright: the capability is never negotiated, so a server requesting one is a protocol violation. States the protocol distinguishes are carried rather than inferred. A statement's outcome is a tagged `Result` of `ResultSet | Ok`, so `affectedRows` and `lastInsertId` exist only where they mean something and no consumer has to recover the distinction from an empty column list. A session's transport is a tagged `Tcp | Unix | Supplied` decided once at resolution, rather than four optional fields of which several could be set at once. Values decode to lossless primitives rather than domain types, which is the layer `sql/pg` already chose: `BIGINT` to `bigint`, `DECIMAL` to `string`, `DATETIME` and `TIMESTAMP` to epoch milliseconds, `TIME` to signed microseconds because it is a duration and not an instant. `Schema` is where those become `BigDecimal` and `DateTime`, and the bridges consume exactly what this client emits. The session time zone is pinned to UTC so temporal decoding does not depend on the server's. Deliberately dropped: pipelining, since MySQL's sequence ids make pg's `PipelineEntry` machinery unsound; `LISTEN`/`NOTIFY`; `COPY`; a type registry, since MySQL's type space is closed; and server-side cursors. 264 tests, the majority with no server at all. The protocol, types, auth, reply readers, parameter escaping and error classification are pure and driven straight from bytes. A scripted in-process server covers what a real server will not produce — a sequence-id gap, a withheld capability, an ERR where a greeting belongs, a socket that dies mid-command. Integration tests run under `EFFECT_INTEGRATION_TESTS=1` against a `mysql:8.4` container and include the shared core suites, which exercise the `mysql:` dialect branches across `cluster/`, `persistence/` and `eventlog/`.
jamiehodge
force-pushed
the
feat/sql-mysql-native
branch
from
September 11, 2026 22:22
9744982 to
e0609ae
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
packages/sql/mysql(@effect/sql-mysql), a MySQL client that speaks theclient/server protocol directly rather than wrapping a driver. It has no runtime
dependencies —
node:netandnode:tlsare the only transport — and targetsMySQL 8.0 and newer.
packages/sql/pgis the schematic throughout: the same pooled growable bufferwith
start/endcursors, the same zero-copy row views, the same split betweena pure protocol module and a connection that owns the socket.
@effect/sql-mysql2is untouched and stays published; the two share the
SqlClientcontract.Why
MySQL users currently inherit
mysql2's pool, TLS handling, type coercion anderror objects — a dependency Effect does not control, allocation behaviour it
cannot tune, and errors that have to be reverse-engineered from driver
exceptions rather than read off the wire. This closes that gap the same way
sql/pgclosed it for Postgres.Shape
MysqlProtocol.tsCOM_*encoders. No I/O.MysqlTypes.tsMysqlAuth.tsResult-returning functions.MysqlConnection.tsMysqlPool.ts/MysqlClient.tsPool.makeWithTTL, and theSqlClientadapter.MysqlMigrator.tsMigratorplus amysqldumpschema dump.internal/Targeting MySQL 8 exclusively is load-bearing rather than incidental: it lets the
client require
CLIENT_DEPRECATE_EOF, which removes EOF packets from resultsets and collapses the protocol's worst ambiguity into a single rule.
Notes for review
caching_sha2_password(fast and full, including theRSA-OAEP key exchange full auth needs on a plaintext socket),
sha256_password,mysql_native_password, andmysql_clear_password.The last sends the password unprotected and is refused unless the connection
is encrypted. Both SHA-256 paths are integration-tested over TLS and RSA.
LOAD DATA LOCAL INFILEis refused.CLIENT_LOCAL_FILESis nevernegotiated, so a server requesting one is a protocol violation.
internal/escape.tsis injection-critical. MySQL's text protocol has noplaceholders, so parameters are written into SQL there. 24 tests cover quote,
backslash and control-character escaping, and placeholders appearing inside
string literals, quoted identifiers and comments. Worth the closest look.
statement's outcome is a tagged
ResultofResultSet | Ok, soaffectedRowsandlastInsertIdexist only where they mean something andnothing has to recover the distinction from an empty column list. A
session's transport is a tagged
Tcp | Unix | Suppliedsettled once atresolution rather than four optional fields of which several could be set at
once.
Result.rowsOfcovers the common case of wanting rows.PipelineEntrymachinery unsound),LISTEN/NOTIFY,COPY, a typeregistry (MySQL's type space is closed), and server-side cursors.
@effect/sql-mysql2—BIGINTasbigint,DECIMALasstring, temporal types as epoch milliseconds, session time zonepinned to UTC — is enumerated with opt-outs in the changeset.
Why not
DateTimeandBigDecimalThe obvious question about the type mapping is why
DECIMALdecodes to astringandDATETIMEto a number, wheneffectshipsBigDecimalandDateTimeand both are plainly the nicer domain types.The short answer is that decoding them here would be the wrong layer, and it is
the layer
sql/pgalready chose: it importsDateTimenowhere, decodingnumerictostring,timestampto epoch milliseconds andtimetobigint.No SQL driver in the repo decodes to either type. A driver's job is to get the
value off the wire without losing anything; turning it into a domain type is
Schema's job, and the bridges already exist and consume exactly what thisclient emits:
That keeps the cost where the benefit is. A user selecting a
DECIMALcolumn torender it in a table pays for a string; one doing arithmetic on it opts into
BigDecimalthrough their schema and pays there.Three reasons specific to the types also point the same way:
TIMEis a duration, not an instant. It is signed and spans-838:59:59 to 838:59:59, so
DateTimecannot represent it at all. Signedmicroseconds map onto
Durationinstead.DATEcarries neither a time nor a zone. Decoding toDateTimewouldhave to invent both.
DateTime.Utcis millisecond-based (epochMilliseconds: number), so itwould not even be a precision win over the epoch milliseconds returned today.
That last point is worth stating plainly rather than leaving implied: the
default temporal representation is lossy for
DATETIME(6)andTIMESTAMP(6)— sub-millisecond digits are dropped, and no choice of JS type in
effectcurrently avoids that.
dateStrings: trueis the lossless path, and rendersto the column's declared precision identically on both the text and the binary
protocol.
Testing
264 tests, the majority with no server at all.
MysqlConnection.in-process.test.tsscripts a fake server overnet, becausea real MySQL server is reliable and so cannot produce the cases that matter: a
sequence-id gap, a capability it will never withhold, an ERR where a greeting
belongs, a socket that dies mid-command. It earned its place on the first run
by catching a defect — a server refusing the connection outright (too many
connections, host blocked) sends an ERR instead of its greeting, and that was
being handed to the handshake decoder, which reported a malformed protocol
version and buried the real cause.
MysqlProtocol,MysqlTypes,MysqlAuth, the reply readers, parameterescaping and error classification are all pure and tested directly against
bytes, which covers the edges a server will not readily produce: signed and
unsigned boundaries, BIGINT past 2^53, truncated fields, invalid UTF-8, the
zero date. The error-classification tests pin that the error number beats the
SQLSTATE class where they disagree — 1213 is a deadlock even though it arrives
under 40001, and whether a retry can help differs between the two.
Integration tests run under
EFFECT_INTEGRATION_TESTS=1against amysql:8.4container and cover the client, pool, prepared statements, streaming, types,
the migrator, and the shared core suites (
KeyValueStoreTest,PersistedCacheTest,PersistedQueueTest,SqlEventLogServerUnencryptedStorageTest), which exercises themysql:dialect branches across
cluster/,persistence/andeventlog/.pnpm check,pnpm jsdocs --check,pnpm circularandpnpm lint-fixareclean.
Two things I would value a maintainer's view on
mysql2this client is ahead on thesingle-row query (1.06×), the transaction (1.04×) and 20 concurrent queries
(1.04×), and behind on the 100-row read (0.85×). The gap is measured rather
than guessed: decode is about a ninth of a 100-row query, and a CPU profile
of that workload is 86% idle with this package under a seventh of the active
time, so the client is waiting rather than computing. What remains is
per-row — roughly 0.65µs against
mysql2's 0.27µs — and 100 rows is 100packets, each handed from the parser to the reply reader through a queue, so
that handoff is the candidate. Written up in
benchmark/README.mdas ahypothesis with a number rather than a finding: an earlier attempt to blame
per-row Effect steps was measured and disproved.
SqlModelmulti-statementcontract.
SqlModel.tscompilesinsert …; select … LAST_INSERT_ID()for themysqldialect and destructures([, results]) => results; the observableshape this client returns was inferred from
mysql2's behaviour rather thanfrom a written spec, and is pinned by a test.
🤖 Generated with Claude Code