MySQL store
@inkanjs/mysql: an inkan Store on MySQL 8, on your own mysql2 pool. Exact binary keys, the database's clock, prepared statements only.
New with inkan 0.8
@inkanjs/mysql 0.1 needs inkan 0.8.0 or newer, below 1.0. It is a Store:
what it guarantees, and how it compares with the other adapters, is on the Storage page.
A Store for inkan on MySQL: background jobs, rate
limits, sessions, logins and idempotency keys kept where every process of your app sees
them. You pass your own mysql2/promise pool; the package has no dependencies.
npm install @vxnsin/inkan @inkanjs/mysql mysql2import mysql from "mysql2/promise";
import { inkan, rateLimit } from "@vxnsin/inkan";
import { mysqlStore } from "@inkanjs/mysql";
const pool = mysql.createPool(process.env.MYSQL_URL!);
const store = mysqlStore(pool);
const app = inkan({ workers: 4 })
.register(rateLimit({ max: 100, window: 60_000, store })) // counted across all workers
.job("/exports", { store }, async (job) => buildExport(job)); // progress and cancels reach every workerThe same store works for the middleware packages that take a Store:
app.register(session({ secret, store })); // @inkanjs/session
app.register(auth({ secret, users, session: { store } })); // @inkanjs/auth
app.register(idempotency({ store })); // @inkanjs/idempotencyGive two uses that might pick the same keys a prefix of their own:
mysqlStore(pool, { prefix: "sessions:" }).
Options
| option | default | |
|---|---|---|
table | "inkan_store" | the table for the keys |
events | "inkan_events" | the table for publish/subscribe messages |
prefix | "inkan:" | put in front of every key and channel; ends in : (added when missing) |
create | true | CREATE TABLE IF NOT EXISTS both tables on first use |
sweep | 60000 | ms between two deletes of expired keys and old messages; 0 for none |
poll | 1000 | ms between two reads of the messages while someone listens |
Table names must match /^[A-Za-z_][A-Za-z0-9_]*$/; anything else throws. store.sweep()
deletes what is over now; store.close() stops the sweep and the polling. The pool is
yours: end it yourself. It has to be a pool (createPool), not one connection, since
take and increment run in transactions of their own.
How it keeps things
CREATE TABLE inkan_store (
k VARBINARY(255) NOT NULL PRIMARY KEY, -- the key, byte for byte
v JSON NOT NULL,
e DATETIME(3) NULL, -- UTC; NULL for no TTL
KEY inkan_e (e)
);
CREATE TABLE inkan_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
c VARBINARY(255) NOT NULL, m LONGTEXT NOT NULL, at DATETIME(3) NOT NULL, KEY inkan_at (at)
);- Exact keys.
kis binary, soa,A,aandäare four keys. (Autf8mb4_bincolumn would pad: MySQL compares"a"and"a "as equal there.) - Atomic.
adddeletes the key if it is expired, thenINSERT IGNOREs: the insert decides, and of many at once exactly one sees a row inserted.takeisSELECT … FOR UPDATEandDELETEin one transaction.incrementisINSERT … ON DUPLICATE KEY UPDATE(add to a live count, or start over atbywith a new TTL) and a read of the new number in one transaction. Fifty at once on one key, from any number of processes: oneaddwins, onetakegets the value, the counts go 1 to 50. Deadlocks are retried. - One clock. Expiry is computed and checked with
UTC_TIMESTAMP(3), the database's clock, whatever the session time zone, so app servers whose clocks differ agree. - SQL. Every key, value and TTL is a parameter of a server-side prepared statement
(
pool.execute), never escaped into the SQL text, soNO_BACKSLASH_ESCAPESor a multibyte client charset (gbk, big5, sjis) cannot turn a key into SQL. The only names in the SQL text are the two checked, backtick-quoted table names.
Security
- Publish/subscribe has no authentication. A message is whatever arrives on the channel:
anyone who can
INSERTinto theeventstable can send one, and inkan's jobs act on them (a forged message can cancel a running job or report false progress). Use a MySQL user of its own for the app with grants on the two tables only, and no other user withINSERTon them. Do not share the database with tenants you do not trust. - Writing the store is trusting it. Whoever can write the keys can forge sessions,
idempotent replies, job records and rate-limit counts, and whoever can read them reads
every session. Give the app's database user access to the store's two tables (
SELECT, INSERT, UPDATE, DELETEon them, plusCREATEwithcreate: true) and nothing else, and nobody else write access to them. - Values are stored as JSON text, as they are given: not encrypted, not signed. Keep secrets out of them or encrypt them yourself; keys are stored as given too (under the prefix), so do not put secrets in keys.
- Errors carry no keys or values. A failing statement rejects with a new
Errorthat keepscode,errnoandsqlState, its message with every quoted part replaced by'…'(MySQL quotes values: "Duplicate entry '…'"); mysql2'ssqlis not passed on. Log them freely.
Limits
- Keys and the prefix. The prefix (default
"inkan:") ends in:; one without gets it ("app"is"app:"), so prefix"app"with key"1x"and prefix"app1"with key"x"stay apart.""puts nothing in front. Do not give one use a prefix that starts another's ("app:"and"app:sessions:"can meet). A key or channel holding NUL (\0) or a lone UTF-16 surrogate is refused with aTypeError, in all four inkan stores alike. - Long keys. A key (with its prefix) longer than 255 bytes of UTF-8 is kept under
\x01sha256:<hex>of it. Two different keys share a row only if SHA-256 collides. Keys starting with\x01are hashed too, so no key can pose as a hash. - Expired rows stay until the sweep (once when the store starts, then every
sweepms, unref'd). A sweep deletes 10000 rows a statement until a statement comes back short, at most a million rows a sweep; the next one goes on. Nothing returns expired rows in the meantime: every read filters one > UTC_TIMESTAMP(3). - Messages are polled. MySQL has no notifications, so subscribers read the
eventstable everypollms: a message arrives up to about a second later, one query a second per store that has subscribers. Messages are kept a minute. One whose insert commits more than five seconds after a newer one can be missed. - Versions. MySQL 8.0 or later (
CAST(… AS JSON),JSON_TYPE); tested on 8.4 withmysql23. MariaDB is not supported (its JSON is text). publishreaches subscribers in the order one process published, when it awaits eachpublish. A message sent while nobody listens is lost, as theStorecontract allows.