Adapters / MySQL
MySQL
MySQL 8 or MariaDB through any driver. The same query-function contract as Postgres, plus the row count MySQL needs because it has no RETURNING.
Setup
import {
createMysqlTables,
mysql2Query,
mysqlAdapter,
mysqlTransaction,
} from "easy-ping/adapters/mysql";
import mysql from "mysql2/promise";
// timezone "Z" is not optional: the adapter writes DATETIME as UTC and must
// read it back unshifted. Without it mysql2 assumes local time.
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, timezone: "Z" });
const query = mysql2Query(pool);
await createMysqlTables(query);
export const notify = easyPing({
database: mysqlAdapter(query, { transaction: mysqlTransaction(pool) }),
// ...
});Everything above the adapter is identical to every other database.
The contract
type MysqlQuery = (
text: string,
params: readonly unknown[],
) => Promise<{ rows: readonly Record<string, unknown>[]; affectedRows: number }>;
One field more than the Postgres contract: MySQL has no RETURNING,
so the number of rows an UPDATE touched has to come from the driver. mysql2Query adapts a
mysql2 pool or connection; any other driver is a few lines to the same shape.
How a claim works here
The claim primitive is the one operation with no portable spelling
(RFC 0003). MySQL gets two implementations, chosen by whether you
passed transaction:
| you passed | the claim is | skips a row another sweep holds? |
|---|---|---|
transaction | SELECT … FOR UPDATE SKIP LOCKED, then UPDATE, then a re-select — one transaction | yes (MySQL 8.0+) |
| nothing | one UPDATE d JOIN (SELECT id … ORDER BY … LIMIT ?) s … WHERE d.status = 'pending' OR lease expired | no, but it never double-claims |
The lock-free form works because InnoDB evaluates the outer WHERE against the row after
locking it, so a row another sweep won in the meantime fails the predicate and is left alone.
Under real contention two such statements can deadlock; InnoDB rolls one back and asks for a
retry, and the adapter retries it, bounded and jittered. Pass transaction in production: it is
what the rowLock conformance case exercises, and SKIP LOCKED never waits.
Creating the tables
createMysqlTables(query, { plugins?, prefix? }) renders and runs CREATE TABLE IF NOT EXISTS for
the core tables and any plugin schemas you pass, safe on every boot. Or take the statements
yourself with renderMysqlDdl from easy-ping/schema.
const plugins = [preferences(), push({ provider, render })];
await createMysqlTables(query, {
plugins: plugins.flatMap((plugin) => (plugin.schema ? [plugin.schema] : [])),
prefix: "", // must match mysqlAdapter's prefix and easyPing's tablePrefix
});
Which plugins own storage, and where their declaration comes from:
| Plugin | Schema | Tables |
|---|---|---|
| push | pushSchema from easy-ping/plugins/push | notification_push_device |
| telegram | telegramSchema from easy-ping/plugins/telegram | notification_telegram_chat, notification_telegram_link |
| mobilePush | mobilePushSchema from easy-ping/plugins/mobile-push | notification_mobile_push_device, notification_mobile_push_ticket |
| preferences | preferences().schema | notification_preference |
| digests | digests().schema | its bucket table |
A plugin whose table is missing fails on its first read or write, not at startup, so create the
tables for every plugin you pass to easyPing. Looping over that same array is the way to make
sure none is forgotten.
Two MySQL-specific choices in that DDL:
- Indexes are declared inline in
CREATE TABLE, because MySQL has noCREATE INDEX IF NOT EXISTSand the bootstrap has to stay re-runnable. - String columns are sized to InnoDB's 3072-byte key limit. A string in a single-column key
or index is
VARCHAR(768)(push endpoints can be long); one in a composite key isVARCHAR(255); one with a default isVARCHAR(255)becauseTEXTcannot carry one; everything else isTEXT. Booleans areTINYINT(1), datesDATETIME(3), JSON isJSON.
Upgrading an existing database
createMysqlTables creates what is missing and never alters what exists, so after an upgrade that
adds a column run planMysqlMigration instead. It reads information_schema, diffs it against the
declaration, and returns only the statements that are missing:
import { coreSchema, planMysqlMigration } from "easy-ping/schema";
for (const schema of [coreSchema, ...plugins.flatMap((p) => (p.schema ? [p.schema] : []))]) {
const plan = await planMysqlMigration(query, schema, ""); // same prefix as the adapter
for (const statement of plan.statements) await query(statement, []);
if (plan.unsupported.length) console.warn(plan.unsupported);
}
Additive only, like the Postgres planner: it creates missing tables, adds missing columns and
creates missing indexes (checked against information_schema.STATISTICS, since MySQL has no
CREATE INDEX IF NOT EXISTS). A type change, an undeclared column, a required column with no
default, or an index over a column that is still TEXT land in plan.unsupported for a human.
MySQL commits DDL implicitly, so the statements run one by one rather than in a transaction; a
rerun after a failure picks up where it stopped.
Wake-ups across replicas
MySQL has no LISTEN/NOTIFY equivalent, so there is no mysqlSignals. Within one process the
default in-memory signals are instant. With several replicas, a send on one reaches the others
through the fallbacks every database has: each event stream probes a
cheap fingerprint every events.probeIntervalMs (30 s), the worker's idle interval (10 s) bounds
delivery, and the cron sweep is the floor. Nothing is lost; only the cross-replica latency is
seconds instead of milliseconds.
What to know
affectedRowscounts rows changed, not rows matched, unless the pool sets theFOUND_ROWSflag. The library counts with aSELECTwhere it needs a matched count (markRead), so its own behaviour is right either way;PluginStore.updatereturns what the driver says.- MariaDB works. The upsert uses
VALUES()rather than MySQL 8.0.19'sAS newalias for exactly that reason.SKIP LOCKEDneeds MariaDB 10.6+. - Dates come back as
Datewithtimezone: "Z", or as strings withdateStrings: true, which the adapter parses as UTC. Any other timezone setting shifts every timestamp.