MySQL connector
Extracts a MySQL table into canonical events with keyset pagination
(never OFFSET): rows are ordered by (cursor_column, primary key) and
fetched in pages of 50,000 rows through a streaming cursor. The session is
read-only by construction (SET SESSION TRANSACTION READ ONLY) and pinned
to UTC (SET time_zone = '+00:00', so TIMESTAMP values arrive as UTC
instants). The connector never writes to your database — no temp tables,
no DDL.
A primary key is required: MySQL has no ctid-style fallback, so a
PK-less table fails PUT /sources/{id}/mapping with
keyset_tiebreak_unavailable.
Prerequisites
- An API key with the
adminscope. - MySQL 5.7+ over TLS (8.0.16+ recommended: row-value keyset comparisons use indexes natively; older servers get an equivalent expanded predicate).
- A network path from Tessera to the database: a reachable host, or a bastion for the SSH tunnel.
Allowed destinations
Tessera connects only to public internet addresses. It checks config.host,
or config.tunnel.host with a tunnel, before any connection, using the same
rules as the Postgres connector:
- one DNS name or one IP address;
- no private or cluster names;
- every resolved address must be public;
- the port is 3306 (22 for the bastion) or 1024–65535.
Tessera then connects to the address it checked. The TLS server name stays
the configured host. A refused destination fails tcp_or_tunnel with
failure: configuration. PUT /mapping returns 400 invalid_argument, and a
sync fails with extraction_failed. Both carry a field_violation naming the
refused field:
| Field | Refused when |
|---|---|
config/host | the host is malformed, private or cluster-internal, or does not resolve to public addresses |
config/port | the port is privileged and not 3306 |
config/tunnel/host | the bastion is malformed, private or cluster-internal, or does not resolve to public addresses. Use a bastion with a public address; the database behind it can stay private. |
config/tunnel/port | the bastion port is privileged and not 22 |
Create the credential (least privilege)
CREATE USER 'tessera_reader'@'%' IDENTIFIED BY '...' REQUIRE SSL;
GRANT SELECT ON app.orders TO 'tessera_reader'@'%';
SELECT on the one table is all the connector uses: it also makes the
table's columns and primary key visible in information_schema (schema and
keyset discovery), and the session's SET SESSION TRANSACTION READ ONLY and
SET time_zone need no privilege. Narrow the host part ('%') to Tessera's
egress address when you know it.
Create the source
{
"name": "orders",
"kind": "mysql",
"config": {
"host": "db.internal.example.com",
"port": 3306,
"database": "app",
"table": "orders",
"ssl_mode": "required"
},
"credentials": {
"username": "tessera_reader",
"password": "..."
}
}
ssl_mode is required (default: TLS enforced, certificate unverified) or
verify_ca (TLS + CA chain verification) — plaintext connections are never
allowed. The config object validates against the connector's registered
schema:
{
"host": "db.internal.example.com",
"port": 3306,
"database": "app",
"table": "orders",
"ssl_mode": "required"
}
SSH tunnel
When the database is only reachable through a bastion, set config.tunnel
and vault the SSH key alongside the database credentials — identical to the
Postgres connector:
{
"host": "10.0.3.7",
"database": "app",
"table": "orders",
"tunnel": {"host": "bastion.example.com", "port": 22, "username": "tessera"}
}
All database TCP traffic then flows through an SSH local forward
established with credentials.tunnel_private_key.
Test the connection
POST /sources/{id}/connection-diagnostics runs three checks in order, each tagged
with the diagnostic stage it verifies:
| Check | Stage | Verifies |
|---|---|---|
tcp_or_tunnel | network | a TCP connection to the host, or the SSH tunnel to the bastion |
authenticate | authentication | a TLS session with the stored username and password |
table_readable | authorization | a zero-row SELECT on the table succeeds and its columns are visible |
MySQL's error number decides a failed check's failure cause: 1045 (the
password was refused) is authentication; 1044, 1130 and 1142 (no
access to the database, host not allowed, SELECT denied) are
authorization; 1049 and 1146 are not_found; 2003 (unreachable host
or failed TLS handshake) is network. Details quote the number, never the
password or the server's message; later checks report not_attempted. The
response's checked_at is the server time of the test, which is also
recorded in the audit log as source.test_connection.
POST /sources/{id}/test-connection runs the same checks and returns only
their names, statuses and details.
Rotate the credential
The source holds the account's password, and the SSH tunnel private key when a
tunnel is on. To rotate the password: set a new one on the account, or create a
second account with the same grants, then PATCH /sources/{id} with
credentials. The body replaces the stored credentials entirely, so repeat
the username and, with a tunnel, the tunnel_private_key. Confirm
POST /sources/{id}/connection-diagnostics passes before you retire the old
password or account. A sync already running keeps the password it started with.
Type map
Column types map onto canonical attribute types exactly as follows; mapping
a column whose type is unsupported fails PUT /sources/{id}/mapping
naming the column and its type. MySQL has no array type — no column maps to
array.
| MySQL type | Canonical type | Notes |
|---|---|---|
tinyint(1) | boolean | The MySQL boolean convention (BOOL/BOOLEAN): 0 → false, non-zero → true. |
tinyint | integer | Exact integers (display width ≠ 1). |
smallint | integer | Exact integers. |
mediumint | integer | Exact integers. |
int | integer | Exact integers (INTEGER). |
bigint | integer | Exact 64-bit integers. |
decimal | number | NUMERIC — converted to float64 (beyond-precision values convert lossily). A column mapped as an entity or event id lands as an exact decimal instead (up to 38 digits before and after the point), so ids above 2^53 - 1 stay exact; a wider value is refused for its row only, counted in the sync's details (cast such a column to text in a view to keep its ids). |
float | number | IEEE-754 floats. |
double | number | IEEE-754 float64 (DOUBLE PRECISION, REAL), as-is. |
char | string | Fixed-length strings, as returned by MySQL. |
varchar | string | UTF-8 strings, as-is (≤ 4096 chars per 002 §2). |
text | string | TINYTEXT/TEXT/MEDIUMTEXT/LONGTEXT, as UTF-8 strings. |
enum | string | The enum label as a string. |
set | string | Members joined as a comma string ("a,b"), in table-definition order. |
timestamp | timestamp | UTC instants (the extraction session sets time_zone = '+00:00'). |
datetime | timestamp | Naive wall-clock time: the mapping's IANA timezone is required — without it, rows carrying a value are rejected. |
date | timestamp | Midnight UTC of the date (YYYY-MM-DDT00:00:00Z). |
json | unsupported | Cannot be mapped (flatten upstream); PUT /mapping fails naming the column. |
blob | unsupported | TINYBLOB/BLOB/MEDIUMBLOB/LONGBLOB cannot be mapped. |
binary | unsupported | BINARY/VARBINARY cannot be mapped; PUT /mapping fails naming the column. |
geometry | unsupported | All spatial types cannot be mapped; PUT /mapping fails naming the column. |
bit | unsupported | Cannot be mapped (R15 marks BIT(>1) unsupported; BIT(1) is undefined there and denies by default — use TINYINT(1) for booleans). |
DATE maps to midnight UTC by convention; if your dates carry local-day
semantics, expose a naive DATETIME column instead and set the mapping
timezone.
A DECIMAL id value the exact decimal cannot carry lands, with the other
such rows of its fetched page, after that page's other rows and in a landed
part of its own. Row numbers in the rejected-rows report follow that landed
order.
Incremental syncs
Set a mapping cursor on a TIMESTAMP/DATETIME/DATE or integer
column. Each sync extracts only rows with cursor > watermark (strictly
greater), and the watermark advances only when a sync commits — so a
failed sync re-extracts the same rows. Rows whose cursor is NULL are never
extracted by incremental syncs.