easy-pingv0.10.0

Get in touch

Questions, bug reports, or anything about easy-ping. Either of these reaches me.

Emailteklumo.jembere@gmail.comTelegram@teklumt

For anything others would benefit from, a GitHub issue is better than a DM, because it's searchable.

GitHub

Adapters / MySQL

Edit this page

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

notify.ts
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 passedthe claim isskips a row another sweep holds?
transactionSELECT … FOR UPDATE SKIP LOCKED, then UPDATE, then a re-select — one transactionyes (MySQL 8.0+)
nothingone UPDATE d JOIN (SELECT id … ORDER BY … LIMIT ?) s … WHERE d.status = 'pending' OR lease expiredno, 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:

PluginSchemaTables
pushpushSchema from easy-ping/plugins/pushnotification_push_device
telegramtelegramSchema from easy-ping/plugins/telegramnotification_telegram_chat, notification_telegram_link
mobilePushmobilePushSchema from easy-ping/plugins/mobile-pushnotification_mobile_push_device, notification_mobile_push_ticket
preferencespreferences().schemanotification_preference
digestsdigests().schemaits 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 no CREATE INDEX IF NOT EXISTS and 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 is VARCHAR(255); one with a default is VARCHAR(255) because TEXT cannot carry one; everything else is TEXT. Booleans are TINYINT(1), dates DATETIME(3), JSON is JSON.

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

  • affectedRows counts rows changed, not rows matched, unless the pool sets the FOUND_ROWS flag. The library counts with a SELECT where it needs a matched count (markRead), so its own behaviour is right either way; PluginStore.update returns what the driver says.
  • MariaDB works. The upsert uses VALUES() rather than MySQL 8.0.19's AS new alias for exactly that reason. SKIP LOCKED needs MariaDB 10.6+.
  • Dates come back as Date with timezone: "Z", or as strings with dateStrings: true, which the adapter parses as UTC. Any other timezone setting shifts every timestamp.