Skip to content

feat(sql-mysql): add a native MySQL client - #8197

Open
jamiehodge wants to merge 1 commit into
Effect-TS:mainfrom
jamiehodge:feat/sql-mysql-native
Open

jamiehodge wants to merge 1 commit into
Effect-TS:mainfrom
jamiehodge:feat/sql-mysql-native

Conversation

@jamiehodge

@jamiehodge jamiehodge commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.

packages/sql/pg is the schematic throughout: the same pooled growable buffer
with start/end cursors, the same zero-copy row views, the same split between
a pure protocol module and a connection that owns the socket. @effect/sql-mysql2
is untouched and stays published; the two share the SqlClient contract.

Why

MySQL users currently inherit mysql2's pool, TLS handling, type coercion and
error 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/pg closed it for Postgres.

Shape

Module Responsibility
MysqlProtocol.ts Pure wire codec: framing, packet decoders, COM_* encoders. No I/O.
MysqlTypes.ts Column model and JS mapping, for both the text and binary protocols.
MysqlAuth.ts Four auth plugins, as pure Result-returning functions.
MysqlConnection.ts Socket, TLS upgrade, handshake, commands, streaming, cancellation.
MysqlPool.ts / MysqlClient.ts Pool.makeWithTTL, and the SqlClient adapter.
MysqlMigrator.ts Core Migrator plus a mysqldump schema dump.
internal/ Buffer, packet-layout DSL, reply readers, escaping, error classification.

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.

Notes for review

  • Authentication. caching_sha2_password (fast and full, including the
    RSA-OAEP 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. Both SHA-256 paths are integration-tested over TLS and RSA.
  • LOAD DATA LOCAL INFILE is refused. CLIENT_LOCAL_FILES is never
    negotiated, so a server requesting one is a protocol violation.
  • internal/escape.ts is injection-critical. MySQL's text protocol has no
    placeholders, 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.
  • States the protocol distinguishes are carried, not inferred. A
    statement's outcome is a tagged Result of ResultSet | Ok, so
    affectedRows and lastInsertId exist only where they mean something and
    nothing has to recover the distinction from an empty column list. A
    session's transport is a tagged Tcp | Unix | Supplied settled once at
    resolution rather than four optional fields of which several could be set at
    once. Result.rowsOf covers the common case of wanting rows.
  • Deliberately dropped: pipelining (MySQL's sequence ids make pg's
    PipelineEntry machinery unsound), LISTEN/NOTIFY, COPY, a type
    registry (MySQL's type space is closed), and server-side cursors.
  • Behaviour that differs from @effect/sql-mysql2BIGINT as bigint,
    DECIMAL as string, temporal types as epoch milliseconds, session time zone
    pinned to UTC — is enumerated with opt-outs in the changeset.

Why not DateTime and BigDecimal

The obvious question about the type mapping is why DECIMAL decodes to a
string and DATETIME to a number, when effect ships BigDecimal and
DateTime and 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/pg already chose: it imports DateTime nowhere, decoding
numeric to string, timestamp to epoch milliseconds and time to bigint.
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 this
client emits:

Schema.BigDecimalFromString     // <- DECIMAL, NEWDECIMAL
Schema.DateTimeUtcFromMillis    // <- DATETIME, TIMESTAMP

That keeps the cost where the benefit is. A user selecting a DECIMAL column to
render it in a table pays for a string; one doing arithmetic on it opts into
BigDecimal through their schema and pays there.

Three reasons specific to the types also point the same way:

  • MySQL TIME is a duration, not an instant. It is signed and spans
    -838:59:59 to 838:59:59, so DateTime cannot represent it at all. Signed
    microseconds map onto Duration instead.
  • DATE carries neither a time nor a zone. Decoding to DateTime would
    have to invent both.
  • DateTime.Utc is millisecond-based (epochMilliseconds: number), so it
    would 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) and TIMESTAMP(6)
— sub-millisecond digits are dropped, and no choice of JS type in effect
currently avoids that. dateStrings: true is the lossless path, and renders
to 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.ts scripts a fake server over net, because
a 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, parameter
escaping 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=1 against a mysql:8.4
container and cover the client, pool, prepared statements, streaming, types,
the migrator, and the shared core suites (KeyValueStoreTest,
PersistedCacheTest, PersistedQueueTest,
SqlEventLogServerUnencryptedStorageTest), which exercises the mysql:
dialect branches across cluster/, persistence/ and eventlog/.

pnpm check, pnpm jsdocs --check, pnpm circular and pnpm lint-fix are
clean.

Two things I would value a maintainer's view on

  • One benchmark is behind. Against mysql2 this client is ahead on the
    single-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 100
    packets, each handed from the parser to the reply reader through a queue, so
    that handoff is the candidate. Written up in benchmark/README.md as a
    hypothesis with a number rather than a finding: an earlier attempt to blame
    per-row Effect steps was measured and disproved.
  • It would benefit from a maintainer's read on the SqlModel multi-statement
    contract. SqlModel.ts compiles insert …; select … LAST_INSERT_ID() for the
    mysql dialect and destructures ([, results]) => results; the observable
    shape this client returns was inferred from mysql2's behaviour rather than
    from a written spec, and is pinned by a test.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0609ae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@effect/sql-mysql Patch
effect Patch
@effect/opentelemetry Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch

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

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
arbitrary-combinators.ts 34.24 KB 34.24 KB 0.00 KB (0.00%)
basic.ts 6.87 KB 6.87 KB 0.00 KB (0.00%)
batching.ts 9.95 KB 9.95 KB 0.00 KB (0.00%)
brand.ts 6.45 KB 6.45 KB 0.00 KB (0.00%)
cache.ts 10.77 KB 10.77 KB 0.00 KB (0.00%)
config.ts 21.51 KB 21.51 KB 0.00 KB (0.00%)
differ.ts 20.32 KB 20.32 KB 0.00 KB (0.00%)
http-client.ts 21.93 KB 21.93 KB 0.00 KB (0.00%)
http-router.ts 33.50 KB 33.50 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 9.02 KB 9.02 KB 0.00 KB (0.00%)
optic.ts 6.70 KB 6.70 KB 0.00 KB (0.00%)
pubsub.ts 15.10 KB 15.10 KB 0.00 KB (0.00%)
queue.ts 11.85 KB 11.85 KB 0.00 KB (0.00%)
schedule.ts 10.96 KB 10.96 KB 0.00 KB (0.00%)
schema-binary.ts 39.51 KB 39.51 KB 0.00 KB (0.00%)
schema-class.ts 20.06 KB 20.06 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 30.93 KB 30.93 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 26.34 KB 26.34 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.63 KB 13.63 KB 0.00 KB (0.00%)
schema-string.ts 11.12 KB 11.12 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.41 KB 15.41 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 33.77 KB 33.77 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.56 KB 24.56 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.27 KB 19.27 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.38 KB 19.38 KB 0.00 KB (0.00%)
schema-toFormatter.ts 19.49 KB 19.49 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 23.77 KB 23.77 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.54 KB 19.54 KB 0.00 KB (0.00%)
schema.ts 19.27 KB 19.27 KB 0.00 KB (0.00%)
stm.ts 12.80 KB 12.80 KB 0.00 KB (0.00%)
stream.ts 9.83 KB 9.83 KB 0.00 KB (0.00%)

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/`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant