MySQL
@postel/mysql — a standalone MySQL storage adapter built on mysql2, with SKIP LOCKED reservation and polling dispatch.
@postel/mysql is a standalone adapter for MySQL: hand it a mysql2 pool you already manage, or a connection string and Postel owns the pool. Requires MySQL ≥ 8.0.1 (for FOR UPDATE SKIP LOCKED); MariaDB ≥ 10.6 also works through the same mysql2 driver.
pnpm add @postel/mysql mysql2npm install @postel/mysql mysql2yarn add @postel/mysql mysql2bun add @postel/mysql mysql2Hand MysqlStorage a mysql2 pool your app already manages — Postel reuses it, so the outbox insert can share your transactions:
import { createPool } from "mysql2/promise";
import { Postel } from "@postel/core";
import { MysqlStorage } from "@postel/mysql";
import { config } from "./config.js";
const pool = createPool(config.databaseUrl);
export const postel = Postel({
outbound: {
storage: MysqlStorage({ pool }),
},
});Or pass a connection string and let Postel open and own the pool:
import { Postel } from "@postel/core";
import { MysqlStorage } from "@postel/mysql";
import { config } from "./config.js";
export const postel = Postel({
outbound: {
storage: MysqlStorage({ connectionString: config.databaseUrl }),
},
});Options
| Option | Notes |
|---|---|
connectionString | Postel opens and owns a mysql2 pool for this URL. |
pool | An existing mysql2 pool to reuse instead — pass this to share a connection (and transactions) with your app. |
autoMigrate | Run migrations on first use (default true). |
clock | Inject a clock for deterministic time in tests. |
Pass exactly one of connectionString or pool. mysql2 is a peer dependency you install alongside.
SKIP LOCKED reservation
Workers reserve outbox rows under FOR UPDATE SKIP LOCKED. MySQL has no RETURNING, so reservation runs as a select-then-update inside one transaction: lock a batch of due rows, stamp the lease, then read them back. Concurrent workers each grab a disjoint batch without blocking — so you can scale delivery across many worker processes against one database.
The reservation runs at READ COMMITTED (the adapter issues SET TRANSACTION ISOLATION LEVEL READ COMMITTED before each reservation). MySQL's default REPEATABLE READ gap-locks the range a SKIP LOCKED scan touches, which makes concurrent workers under-reserve; READ COMMITTED takes only record locks. For multi-worker MySQL deployments, configuring READ COMMITTED as the server or session default is recommended.
Polling dispatch
MySQL has no LISTEN/NOTIFY, so the adapter declares capabilities.notify = false and the worker scheduler polls the outbox at its configured interval. Delivery is identical to Postgres — only dispatch latency differs.
Schema
Timestamps are stored as BIGINT epoch-milliseconds (timezone-independent — it round-trips identically regardless of the connection's timezone setting), JSON as JSON columns, and ids/keys as VARCHAR(191). This is one canonical MySQL schema, shared with the Drizzle / Kysely / Prisma / TypeORM / MikroORM adapters' MySQL dialect — so you can switch adapters on the same database.
Transactions compose with yours
Open a transaction on a mysql2 connection and pass it to send() so the outbox insert commits atomically with your business writes. See the Storage overview.
Receiver-side dedup
@postel/mysql also exports MysqlDedup (and ensureMysqlDedupTable) — the inbound idempotency-dedup helper, independent of the outbound storage. Wire it onto an inbound source, reusing the same pool:
import { MysqlDedup } from "@postel/mysql";
const postel = Postel({
inbound: {
vendor: {
verify: Secret(config.webhookSecret),
dedup: MysqlDedup({ client: pool }),
dedupTtl: "24h",
},
},
});Uses INSERT … ON DUPLICATE KEY UPDATE keyed off the affected-row count — same contract. The table mirrors the others: message_id VARCHAR(191) PRIMARY KEY, expires_at BIGINT (epoch-milliseconds), indexed for cheap cleanup. See Deduplication for the contract and where to place the call.