forge-orm targets MySQL 5.7+, MySQL 8.x, MariaDB 10.6+, and HeatWave (for vector). This page documents the dialect-specific behavior: driver picks, the InnoDB transaction model, MySQL's idiosyncratic JSON and spatial APIs, FULLTEXT engines, and the operational concerns (charset, online DDL, replication) that touch the forge layer.
- Driver matrix
- Engine matrix — MySQL 5.7 vs 8.x vs MariaDB 10.6+
- Type emit
- Charset and collation
- JSON columns
- FULLTEXT
- Spatial
- HeatWave vector
- Transactions and isolation
- Implicit DDL commit
- Online DDL
AUTO_INCREMENTvs UUID- Replication
- Connection pool sizing
- Common errors and fixes
- Cross-links
Three wrappers ship for the MySQL adapter; all three implement the
same MysqlDriver port (src/adapters/mysql/driver.ts). The executor
talks to them through mysql2's tuple shape — [rows, fields] for
SELECT, [result, fields] for DML where result carries
affectedRows and insertId. Non-mysql2 wrappers normalise to that
shape internally; outside driver.ts, no executor code branches on
which client is underneath.
| Driver | Best for | Install |
|---|---|---|
mysql2Driver |
Long-lived Node, default pool, MySQL or MariaDB over a real socket | npm i mysql2 |
mariadbDriver |
MariaDB Connector/Node — measurable decoder edge on rowsets >10k | npm i mariadb |
planetscaleDriver |
PlanetScale over fetch — Workers, Edge, Vercel Edge Runtime | npm i @planetscale/database |
The URL prefixes routed to the MySQL adapter are
mysql:// and mariadb://. Other Vitess-compatible engines
(TiDB, Vitess in the wild) all speak the MySQL wire protocol — point
mysql2Driver at them and the same SQL emit works. PlanetScale is
the exception only because the transport is HTTP, not TCP, so
connection pooling and transaction semantics differ.
// (a) URL only — forge picks mysql2 by default.
const db = await createDb({ url: process.env.MYSQL_URL!, schema });
// (b) MariaDB over a custom pool — own the lifecycle for TLS callbacks
// or socket-path connections.
import mariadb from 'mariadb';
const pool = mariadb.createPool({
host: 'db.internal', user: 'app', password: process.env.DB_PASS!,
connectionLimit: 50, bigIntAsNumber: true, insertIdAsNumber: true,
});
const db = await createDb({ schema, driver: mariadbDriver(pool) });
// (c) PlanetScale on Vercel Edge — HTTP transport, stateless conn.
import { connect } from '@planetscale/database';
const conn = connect({ url: process.env.DATABASE_URL });
const db = await createDb({ schema, driver: planetscaleDriver(conn) });mysql://user:pass@host:3306/dbname?ssl=true&timezone=Z is the
shared shape. mysql2 and mariadb both honour ssl, timezone,
connectionLimit, dateStrings, decimalNumbers, and
(mariadb-only) bigIntAsNumber query params. The forge factory
passes the URL straight through to createPool({ uri: url }) on
mysql2; for mariadb you build the pool yourself.
| Capability | mysql2Driver |
mariadbDriver |
planetscaleDriver |
|---|---|---|---|
| Promise pool | Yes | Yes | n/a — single conn |
| Server-side stream | Yes (real cursor) | No — buffers | No — HTTP buffers |
insertId as JS number |
Native | bigIntAsNumber opt-in |
Coerced via Number() |
| Multi-statement | Opt-in | Off by default | Not supported |
| Transactions | START TRANSACTION |
beginTransaction() |
conn.transaction(fn) |
| Distributed XA | Yes (rarely safe) | Yes | No |
mysql2 is the only wrapper that runs a real server-side cursor for
findManyStream — the others fall back to OFFSET/LIMIT chunking.
Three engine families share the wire protocol, but the SQL surface they accept diverges. forge picks the most portable form available across all three; features that only exist on one are gated behind a schema flag.
| Feature | MySQL 5.7 | MySQL 8.x | MariaDB 10.6+ | HeatWave |
|---|---|---|---|---|
WITH / CTE |
No | Yes | Yes (10.2+) | Yes |
| Recursive CTE | No | Yes | Yes | Yes |
| Window functions | No | Yes | Yes (10.2+) | Yes |
JSON column type |
Yes | Yes | Yes (as alias for LONGTEXT) | Yes |
JSON_TABLE |
No | Yes | No | Yes |
JSON_VALUE |
No | Yes | Yes (10.6+) | Yes |
| Expression / functional indexes | No | Yes | Yes (10.5+) | Yes |
INVISIBLE indexes |
No | Yes | Yes (10.6+, called IGNORED) |
Yes |
| Check constraints (enforced) | Parsed, ignored | Yes | Yes | Yes |
| Native partial indexes | No | No | No | No |
CHECK on IN(...) (enum) |
Parsed, ignored | Yes | Yes | Yes |
| FULLTEXT on InnoDB | Yes | Yes | Yes | Yes |
ngram parser |
Yes | Yes | No — use mecab |
Yes |
SPATIAL indexes on InnoDB |
Yes (8.0.4 backport) | Yes | Yes | Yes |
SRID on spatial columns |
No | Yes | Yes | Yes |
ST_Distance_Sphere |
Yes | Yes | Yes (10.2+) | Yes |
VECTOR(N) column |
No | 9.0+ | No | Yes (HNSW/IVF) |
STRING_TO_VECTOR |
No | 9.0+ | No | Yes |
GENERATED ALWAYS AS |
Yes | Yes | Yes | Yes |
INSTANT algorithm for ADD COLUMN |
5.7 limited | 8.0.12+ wide | 10.3+ | 8.0.12+ |
ON DUPLICATE KEY UPDATE |
Yes | Yes | Yes | Yes |
INSERT … RETURNING |
No | No | Yes (10.5+) | No |
ANALYZE TABLE durable stats |
Yes | Yes (persistent) | Yes (persistent) | Yes |
The forge implications:
- No CTE on 5.7 — the IR avoids
WITHfor read paths. Cursor pagination is always emitted as an outer SELECT with a tuple predicate, not as aWITH … SELECT FROM …reshape. If you write$queryRawagainst MySQL 5.7 you have to do the same. JSON_TABLEis 8.x only — the JSON-path operator emitsJSON_EXTRACT+JSON_UNQUOTE(covered below). It works on every supported version. Only reach forJSON_TABLEin raw SQL on 8.x.- Expression indexes are 8.0+ on MySQL, 10.5+ on MariaDB —
index({ expression: 'LOWER(name)' })is the schema form (see INDEXES). On 5.7 the push fails withER_PARSE_ERROR; the doctor probe surfaces this during pre-flight. - Check constraints are advisory on 5.7 —
forge pushstill emits theADD CONSTRAINT … CHECKstatement (it parses) but the server does not enforce it. Enum fields fall back to app-side validation on the inbound coerce path. INSERT … RETURNINGon MariaDB 10.5+ is not yet used by the adapter — everyinsert/updateround-trips a follow-up SELECT when the caller needs the inserted row, on every MySQL flavour including MariaDB. The doctor note mentions it; the adapter's follow-up SELECT path is the only one in the executor.
const report = await db.$doctor();
console.log(report.notes);
// → "Driver: mysql2 (3.10.0). Server: MySQL 8.4.0 (utf8mb4_0900_ai_ci)."The doctor probe queries @@version, @@version_comment, and the
default collation. The collation matters — see below.
The MySQL dialect's column-type mapping lives in
src/adapters/mysql/dialect.ts (columnType on MysqlDialect). The
defaults err on the safe side for InnoDB row-format limits.
| Schema field | Emit |
|---|---|
f.id() (default) |
VARCHAR(64) NOT NULL (app-side gen) |
f.id({ idType: 'bigserial' }) |
BIGINT NOT NULL AUTO_INCREMENT |
f.id({ idType: 'uuid' }) |
CHAR(36) NOT NULL DEFAULT (UUID()) |
f.objectId() |
VARCHAR(64) |
f.string() |
VARCHAR(255) |
f.text() |
TEXT |
f.int() |
INT |
f.float() |
DOUBLE PRECISION |
f.decimal(precision, scale) |
DECIMAL(p,s) |
f.uuid() |
CHAR(36) |
f.bigint() |
BIGINT |
f.bool() |
TINYINT(1) (0/1, never TRUE/FALSE) |
f.dateTime() |
DATETIME(3) — millisecond precision |
f.json() |
JSON |
f.enum(['A','B']) |
VARCHAR(64) + CHECK (col IN ('A','B')) |
f.embed(M) |
JSON |
f.embedMany(M) |
JSON DEFAULT (JSON_ARRAY()) |
f.stringArray() / f.intArray() |
JSON |
f.geoPoint() |
POINT NOT NULL SRID 4326 |
f.vector(N) |
VECTOR(N) (MySQL 9+ / HeatWave) |
The default f.string() width is 255 because that is the maximum
that fits a single-column unique index under utf8mb4 on InnoDB without
running into the historical 767-byte index prefix limit. On MySQL 5.7
that limit only relaxes with innodb_large_prefix=ON and
row_format=DYNAMIC (both defaults on 8.x and on 5.7.7+). 255 chars
× 3 bytes = 765 — under the limit by two bytes; that's the historical
reason the cap is 255 and not 256.
If you need longer keys, override the column with a .raw('VARCHAR(1024)')
emit annotation — or move the value to TEXT and accept that you cannot
declare a plain UNIQUE on it without a key-length prefix (UNIQUE (col(255))).
The default f.text() emits TEXT (64 KB). Override the emit when
you need the bigger forms:
| Storage class | Bytes | Schema |
|---|---|---|
TINYTEXT |
255 | rare; use VARCHAR instead |
TEXT |
64 KB | f.text() |
MEDIUMTEXT |
16 MB | f.text({ raw: 'MEDIUMTEXT' }) |
LONGTEXT |
4 GB | f.text({ raw: 'LONGTEXT' }) |
Picking the right size matters for ROW_FORMAT=DYNAMIC overflow
behaviour: TEXT / BLOB columns get their first 768 bytes stored
inline and the rest on overflow pages. Reads of large columns
incur extra page fetches. If 95% of your rows fit in 64 KB and the
tail spills to multi-MB, model the long tail as a separate table
keyed by id and join only when needed.
forge emits DATETIME(3) — millisecond precision. It does not
emit TIMESTAMP. Three reasons:
TIMESTAMPis 4 bytes and overflows in 2038 (Y2K38).TIMESTAMPis implicitly converted to / from the session timezone — meaning the value you store depends on@@session.time_zone.DATETIMEis a naive wall-clock time stored in UTC by convention.
Always set the session timezone to '+00:00' or 'UTC' on the
connection options:
mysql.createPool({ uri: url, timezone: 'Z', dateStrings: false });mysql2 with timezone: 'Z' and dateStrings: false returns
Date objects assumed-UTC. mariadb behaves the same with
timezone: 'Z'. PlanetScale always returns UTC strings; the
executor wraps them in new Date(str) on the outbound decode path.
mysql2's default preserves decimal precision by returning strings
instead of Number. f.decimal() is typed as string to match.
If you want JS numbers, pass decimalNumbers: true on the pool —
the executor doesn't double-cast, so the wire shape is what you get.
mysql.createPool({ uri: url, decimalNumbers: true });mysql2 returns BIGINT as JS number by default — values above
Number.MAX_SAFE_INTEGER (2^53 - 1) silently lose precision.
Either declare f.bigint() and accept the string return shape, or
pass supportBigNumbers: true, bigNumberStrings: true on the pool.
mariadb returns BigInt natively unless you opt out via
bigIntAsNumber: true, insertIdAsNumber: true — match mysql2's
shape with that combo, or accept the BigInt everywhere.
field.dbGenerated compiles to
GENERATED ALWAYS AS (<expr>) STORED. STORED is mandatory because
VIRTUAL columns cannot be indexed on InnoDB until 8.0.13 and even
then only with extra ceremony. The MySQL adapter emits STORED to keep
the index path universally available — the storage cost of a stored
column is a real column write per row, but the indexable surface is
worth it for most schemas. Override with a .raw() annotation if
you specifically want VIRTUAL.
Every table forge creates emits
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 (see buildCreateTable).
utf8mb4 is the only safe choice — utf8 is the historical 3-byte
truncation that drops most emoji and supplementary-plane characters.
The collation is not named explicitly in the emit. That means it falls back to the server's default, which differs by version:
| Server | Default collation |
|---|---|
| MySQL 5.7 | utf8mb4_general_ci |
| MySQL 8.0+ | utf8mb4_0900_ai_ci |
| MariaDB 10.6+ | utf8mb4_general_ci (still) |
The two are not equivalent. _0900_ai_ci is accent-insensitive
(matches é to e) and uses the Unicode 9 sort order;
_general_ci is a flat byte comparison with ad-hoc folding rules.
A query that matches "Élise" by name with LIKE 'el%' works on 8.x
out of the box, and fails on 5.7 / MariaDB unless you call
LOWER() first or upgrade the collation.
forge's text-operator flag (see QUERIES) is a no-op on MySQL, deliberately. The reasoning:
- On
_0900_ai_ci(the 8.x default) everyLIKEis already case-insensitive; flipping a flag would be redundant. - On
_binor_cscollations the only honest translation isLOWER(col) LIKE LOWER(?), which defeats any index on the column. - The adapter picks the conservative path: the flag is dropped and the query runs with whatever the column's collation says.
If you need predictable case-insensitive search regardless of
collation, store a name_lc generated column and filter on that:
const User = model('users', {
id: f.id(),
name: f.string(),
name_lc: f.string().dbGenerated('LOWER(name)'),
});
// CREATE INDEX … ON users (name_lc);
await db.user.findMany({ where: { name_lc: { contains: 'él' } } });The generated column is stored, indexed, and never written by the app — the trigger fires on every INSERT/UPDATE.
Changing the column collation on a non-empty table re-sorts every index that touches the column. On a 50M-row table that's hours. Plan for it:
ALTER TABLE posts
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
ALGORITHM=COPY, LOCK=SHARED;The copy is unavoidable — the index keys themselves change. Run it
behind pt-online-schema-change or gh-ost on production; see
Online DDL.
f.json() emits a native JSON column on every supported MySQL
flavour. MariaDB stores JSON as LONGTEXT with the CHECK (JSON_VALID(col))
constraint applied internally — same surface, slightly different
storage.
The IR emits JSON_UNQUOTE(JSON_EXTRACT(col, '$.path')). The
JSON_UNQUOTE is required: JSON_EXTRACT returns JSON-encoded
values, meaning a string is wrapped in literal quotes ("foo" not
foo). Comparing that against a plain ? placeholder always returns
false unless you unwrap it first.
// User code:
await db.user.findMany({
where: { meta: { path: 'profile.age', gte: 18 } },
});-- Emit:
SELECT * FROM `users`
WHERE CAST(JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.profile.age')) AS UNSIGNED) >= ?The CAST is added by the IR when the path operator is a number
comparator (lt, lte, gt, gte). String comparisons skip the
cast.
Native expression indexes on JSON paths work on 8.0+, but the ergonomic pattern is the generated-column trick — promote the JSON path to a real column, index that column:
const User = model('users', {
id: f.id(),
meta: f.json(),
// age is auto-extracted and indexed for cheap range scans
age: f.int().dbGenerated("CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.profile.age')) AS UNSIGNED)"),
}, {
indexes: [{ keys: { age: 1 }, name: 'idx_users_age' }],
});The generated column is updated on every write — no app code needs
to know it exists. Filtering on age uses the index; filtering on
meta.profile.age via the path operator still re-extracts.
For un-nesting an embedded array into rows, JSON_TABLE is the
right tool. It is 8.x only — MariaDB does not have it. Drop to
$queryRaw for that:
const sql = forge.sql`
SELECT u.id, jt.tag
FROM users u,
JSON_TABLE(u.meta, '$.tags[*]'
COLUMNS (tag VARCHAR(64) PATH '$')) AS jt
WHERE u.id = ${id}
`;
const rows = await db.$queryRaw(sql);See RAW-SQL for the safe interpolation rules.
null on a JSON column is ambiguous — column NULL vs JSON literal
null. forge exposes ForgeDbNull, ForgeJsonNull, and
ForgeAnyNull markers (see
QUERIES). On MySQL:
| Marker | Emit |
|---|---|
ForgeDbNull |
`col` IS NULL |
ForgeJsonNull |
JSON_TYPE(`col`) = 'NULL' |
ForgeAnyNull |
(`col` IS NULL OR JSON_TYPE(`col`) = 'NULL') |
JSON_TYPE is the canonical MySQL discriminator. PostgreSQL uses
(col::jsonb = 'null'::jsonb); same idea, different emit.
f.text().searchable() (or f.string().searchable()) auto-emits a
FULLTEXT index per searchable column:
ALTER TABLE `posts` ADD FULLTEXT `forge_posts_fts_title` (`title`);The runtime operator is MATCH() AGAINST() in NATURAL LANGUAGE MODE:
WHERE MATCH(`body`) AGAINST (? IN NATURAL LANGUAGE MODE)Historical lore says FULLTEXT lives on MyISAM. That stopped being
true with MySQL 5.6. Both engines now support it. Forge always uses
InnoDB (the only engine emitted by buildCreateTable); the FULLTEXT
index lives on the same table. The difference for InnoDB FULLTEXT:
- Inverted-index updates are batched in the
FTS_DOC_ID_INDEXauxiliary table;OPTIMIZE TABLEflushes them. Heavy write loads build up unflushed entries until the next optimise. - The minimum word length defaults to 3 chars
(
innodb_ft_min_token_size); set to 1 for CJK or partial-match needs. Requires a server restart. - Stopwords live in
INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD(read-only) or a user table named viainnodb_ft_server_stopword_table='db/tbl'.
Default tokenisation splits on whitespace and punctuation — terrible
for Chinese, Japanese, Korean. The ngram parser breaks the text
into n-char windows instead:
const Post = model('posts', {
id: f.id(),
body: f.text().searchable(),
}, {
indexes: [{
keys: { body: 'text' },
method: 'fulltext',
parser: 'ngram',
name: 'forge_posts_fts_body_ngram',
}],
});The dialect renders WITH PARSER ngram after the column list.
Window size is server-level (ngram_token_size, default 2). On
MariaDB the mecab parser is the equivalent for Japanese
morphological tokenisation — same parser: field.
NATURAL LANGUAGE MODE is the default. Switching to BOOLEAN MODE
unlocks +required -excluded "exact phrase" syntax — but the
adapter does not expose a flag. Drop to $queryRaw:
const sql = forge.sql`
SELECT * FROM posts
WHERE MATCH(body) AGAINST (${query} IN BOOLEAN MODE)
ORDER BY MATCH(body) AGAINST (${query} IN BOOLEAN MODE) DESC
LIMIT 20
`;The reason it's a $queryRaw is that the same operator surface
needs to mean the same thing across SQLite FTS5 and Postgres tsquery
— BOOLEAN-mode syntax is MySQL-specific. The cross-portable surface
is search: 'apple banana' (which on MySQL becomes natural language
mode, on PG becomes plainto_tsquery).
See FTS for the deep dive on engines, scoring, and hybrid BM25+vector.
Mass deletes leave tombstones in the FULLTEXT cache. Schedule
OPTIMIZE TABLE posts; weekly on hot search columns. It rebuilds
the auxiliary tables and reclaims space.
f.geoPoint() emits a typed POINT column with an explicit SRID:
`location` POINT NOT NULL SRID 4326The SRID 4326 clause is mandatory on MySQL 8.x for spatial
indexes. Without it, the index is built against a Cartesian plane
and great-circle queries return wrong distances. forge always emits
the SRID — default 4326 (WGS84). Override per-field:
const Pickup = model('pickups', {
id: f.id(),
// Web Mercator — for tile-aligned visualisations
location: f.geoPoint({ srid: 3857 }),
});MySQL SRID 4326 stores points as POINT(lat lng) — latitude first.
PostGIS stores them as ST_MakePoint(lng, lat) — longitude first.
The adapter handles this in valueExpr: a { lng, lat } input is
written as POINT(lat lng) for MySQL. If you go around the adapter
with raw SQL, mirror it — getting the order wrong puts every point
in the wrong hemisphere.
The IR emits these functions for the geo operators:
| Operator | Emit |
|---|---|
near: { lng, lat, withinMeters } |
ST_Distance_Sphere(col, ST_GeomFromText(?, 4326)) < ? |
orderBy nearTo |
Same ST_Distance_Sphere expression in the ORDER BY |
withinPolygon: poly |
ST_Within(col, ST_GeomFromText(?, 4326)) |
geoDistanceExpr (select-side projection) |
ST_Distance_Sphere(col, ref) |
ST_Distance_Sphere returns metres on SRID 4326. The function is
faster than ST_Distance (which considers full ellipsoidal geodesy)
and accurate to within ~0.1% for typical app distances — good enough
for "stores within 5 km".
Spatial indexes are R-tree-backed on InnoDB 5.7.5+. They are
required for ST_Within and friends to use index lookup rather than
a full table scan. forge declares them via the schema's indexes
array with method: 'spatial':
const Store = model('stores', {
id: f.id(),
location: f.geoPoint(),
}, {
indexes: [{ keys: { location: 1 }, method: 'spatial', name: 'idx_stores_loc' }],
});That emits CREATE SPATIAL INDEX idx_stores_loc ON stores(location).
SPATIAL is a statement-prefix keyword, not a USING SPATIAL clause —
the dialect handles the distinction. See GEO for the
per-dialect catalog.
The f.geoPoint({ srid: N }) syntax emits the SRID at DDL time so
the index aligns with the projection. forge does not call
ST_Transform between SRIDs at query time — the input point and the
column must share the SRID, or the query throws
ER_GIS_DIFFERENT_SRIDS. Plan one SRID per column.
f.geoPoint({ dims: 3 }) is a forge convenience for "include the
alt scalar in TypeScript". MySQL 8.x does not natively support
POINT Z — only the 2D ground point is indexed and stored. The
adapter drops alt at the storage layer; the JSON-shape round-trip
happens via a sibling scalar column. See
GEO for the round-trip pattern.
MySQL 9.0 introduced a native VECTOR(N) type and STRING_TO_VECTOR
function for parsing JSON-array literals. forge emits both:
const Doc = model('docs', {
id: f.id(),
embedding: f.vector(1536, { metric: 'cosine' }),
});`embedding` VECTOR(1536)
-- Insert:
INSERT INTO `docs` (`embedding`) VALUES (STRING_TO_VECTOR(?))
-- Query (orderBy nearTo):
ORDER BY DISTANCE(`embedding`, STRING_TO_VECTOR(?), 'COSINE') ASCThe DISTANCE(col, vec, metric) function returns the chosen
distance — 'COSINE', 'DOT', or 'EUCLIDEAN'. forge's metric
field on f.vector() is uppercased and passed through.
- Community 9.x — brute-force scan. No HNSW, no IVF. Acceptable up to ~100k rows; above that the unindexed scan dominates query latency.
- HeatWave Vector Store — the index forms (
HNSW,IVF_FLAT) are HeatWave-only. Sub-millisecond at 10M+ vectors. The schema surface to declare the index ismethod: 'vector'with anindex_kindoption — sameindexesarray, just on the HeatWave-aware version.
forge gates the vector path behind a runtime probe: db.$doctor()
checks SHOW VARIABLES LIKE 'innodb_vector_search' and reports
whether the feature is on. On unsupported versions the field's DDL
fails with ER_PARSE_ERROR at push time.
See VECTOR for the per-dialect distance reference
(<=> on pgvector, vec_distance_cosine on sqlite-vec, the same
DISTANCE on MySQL/HeatWave).
InnoDB is the only engine forge emits. Transactions are MVCC with
record + gap locks; isolation defaults to REPEATABLE READ.
await db.$transaction(async (tx) => {
await tx.user.create({ data: { email } });
await tx.org.update({ where: { id: orgId }, data: { count_inc: 1 } });
});The adapter opens a real START TRANSACTION on a pinned connection
and threads the connection through ExecOpts.session. On commit the
connection is released back to the pool; on throw it's rolled back
first. The wrapper code lives in mysql2Driver.transaction (and
mirrored shapes in mariadbDriver and planetscaleDriver).
Override per transaction via the session settings:
await db.$transaction(async (tx) => {
await tx.$queryRaw(forge.sql`SET TRANSACTION ISOLATION LEVEL READ COMMITTED`);
// … reads inside this txn now see committed snapshots only
}, { isolation: 'READ COMMITTED' }); // pseudo — set via $queryRaw on MySQLThe four MySQL levels and what they cost you:
| Level | Reads | Phantom risk | Gap locks |
|---|---|---|---|
READ UNCOMMITTED |
Dirty reads allowed | Yes | Yes |
READ COMMITTED |
No dirty reads | Yes — re-runs see new rows | No — only record locks |
REPEATABLE READ (default) |
Snapshot-stable | No — gap locks block inserts | Yes |
SERIALIZABLE |
As REPEATABLE READ + range S-locks | No | Yes, more aggressive |
The big practical difference is READ COMMITTED vs the default:
under READ COMMITTED InnoDB does not take gap locks, which kills
most deadlock cascades on hot write paths. The tradeoff is that
SELECT … FOR UPDATE inside a transaction can race with concurrent
inserts. Pick READ COMMITTED for high-concurrency OLTP, leave the
default for batch jobs that need a stable snapshot.
The REPEATABLE READ default is unusual — most databases use
READ COMMITTED. The MySQL choice means a transaction that does
SELECT … FOR UPDATE WHERE order_id BETWEEN 100 AND 200 locks not
just the rows in that range but also the gaps between them.
Concurrent inserts of order_id = 150 block until the transaction
commits.
This is how InnoDB prevents phantom reads under REPEATABLE READ
without doing serial scheduling. It also means a hot range filter
under load becomes a deadlock factory.
The fix is one of three:
- Drop to
READ COMMITTEDfor transactions that don't need snapshot stability. - Switch the index access pattern so the predicate hits a unique key (gap locks degenerate to record locks on uniques).
- Use
SELECT … LOCK IN SHARE MODEonly when really needed, and outside transactions where possible.
ER_LOCK_DEADLOCK (1213) is mapped to P2034 — Transaction deadlock — please retry. The adapter does not auto-retry; that's the caller's
job because retrying the same transaction has side-effect implications
the adapter cannot reason about. The pattern:
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let i = 0; i < max; i++) {
try {
return await fn();
} catch (e: any) {
if (e?.code !== 'P2034' || i === max - 1) throw e;
await new Promise((r) => setTimeout(r, 50 * 2 ** i));
}
}
throw new Error('unreachable');
}
await withRetry(() => db.$transaction(async (tx) => { /* … */ }));Exponential backoff with three attempts is enough for the common case. Beyond that the contention is structural — find the hot write and reshape it.
ER_LOCK_WAIT_TIMEOUT (1205) is a different beast — it means a
single lock wait exceeded innodb_lock_wait_timeout (default 50s).
Same P2034 code; same retry approach, but if you see it often the
transaction is just too long. Move read-only work outside the txn.
DDL implicitly commits. This is critical and rarely what callers expect:
Every DDL statement on MySQL implicitly commits the current transaction. There are no savepoints around DDL the way there are on PostgreSQL. Concretely:
await db.$transaction(async (tx) => {
await tx.user.create({ data: { email } });
await tx.$queryRaw(forge.sql`CREATE TABLE temp_stage (id INT)`);
// The user.create just got committed. The transaction is now
// over. A subsequent throw will not roll back the create.
await tx.user.update({ /* … */ });
});This affects the migration runner (src/adapters/mysql/migrate.ts).
Unlike Postgres, forge cannot wrap the entire forge push batch in
a single transaction — DDL statements implicitly close any open txn.
Instead, the runner:
- Acquires a session-level lock via
SELECT GET_LOCK('forge_migrate', 60). This serialises concurrent pushes (CI + local + the human running it ad-hoc). - Reads
INFORMATION_SCHEMA.TABLES,TABLE_CONSTRAINTS,STATISTICSonce to know what already exists. - Runs each statement in order, outside a transaction. A failed
statement leaves prior successes applied — the same semantic as
prisma db push. TheApplyReportcarries{ applied, skipped, failures }so the caller knows where the break happened. - Releases the lock via
SELECT RELEASE_LOCK(?).
This means recovering from a half-applied push requires manual
inspection. The doctor probe's diff command tells you what's still
missing, and the migration runner is idempotent — re-running it
picks up where the failure left off.
DDL inside $transaction is never safe on MySQL. If you need
atomic schema changes, take application downtime and run them via
forge push outside any application transaction.
See MIGRATIONS for the safe-DDL playbook.
MySQL's online DDL framework lets some ALTER TABLE operations run
without blocking concurrent reads or writes. The framework picks
between ALGORITHM=INSTANT, INPLACE, and COPY, and between
LOCK=NONE, SHARED, and EXCLUSIVE. forge does not pin these
algorithms — every ALTER falls back to whatever MySQL picks by
default. If you need predictability, run the ALTER yourself with
explicit clauses and let forge's introspection pick up the change
on the next push.
| Operation | INSTANT? | INPLACE? | LOCK=NONE? |
|---|---|---|---|
ADD COLUMN at end with default (8.0.12+) |
Yes | Yes | Yes |
ADD COLUMN in the middle |
No | Yes | Yes |
DROP COLUMN |
8.0.29+ | Yes | Yes |
ADD INDEX (non-FULLTEXT) |
No | Yes | Yes |
ADD FULLTEXT INDEX |
No | No (rebuilds table) | No |
ADD FOREIGN KEY |
No | Yes | Yes |
MODIFY COLUMN (widen) |
8.0.29+ some | Yes | Yes |
CONVERT TO CHARACTER SET |
No | No | No |
Routine schema evolution (ADD COLUMN, ADD INDEX) is online on
8.0.12+. Anything that touches FULLTEXT, charset, or row format is
offline and copies the whole table.
On older versions or for offline operations, the standard tool is
pt-online-schema-change from Percona Toolkit. It:
- Creates a shadow table with the new schema.
- Installs triggers that propagate writes from the old table to the shadow.
- Backfills the old table's rows into the shadow in chunks.
RENAME TABLEatomically swaps the two.
pt-online-schema-change \
--alter "ADD COLUMN dropoff_window TINYINT(1) NOT NULL DEFAULT 0" \
--execute D=app,t=orders,h=db.internalThe forge schema must be updated to match the post-rename state, or
the next forge push will try to re-add the column. The introspect
path reads from INFORMATION_SCHEMA, so once the rename completes
the next push is a no-op.
gh-ost (from GitHub) is the same idea without triggers — it
replays from the binlog instead. Pick gh-ost when triggers are
forbidden by the app architecture (e.g., binlog replication that
filters trigger output), otherwise pt-osc is simpler.
Three id strategies. The default is app-side string UUID v4.
const User = model('users', { id: f.id() });
// → VARCHAR(64) NOT NULL, value generated app-side by `cuid()` / `nanoid()` / similarThe override matrix:
// bigserial — BIGINT NOT NULL AUTO_INCREMENT. lastInsertId comes back via insertId.
const Audit = model('audit', { id: f.id({ idType: 'bigserial' }) });
// uuid — CHAR(36) NOT NULL DEFAULT (UUID()). DB-side gen via the UUID() function.
const Doc = model('docs', { id: f.id({ idType: 'uuid' }) });| Strategy | Picks for | Avoid when |
|---|---|---|
auto (app-side string) |
Distributed inserts, ID known at client, cross-DB joins by string key | High write rate to a single hot table — strings are 2× the index size of bigints |
bigserial |
Heavy single-master writes, audit logs, analytics fact tables | Sharded writes, offline-first clients that need a local id pre-sync |
uuid (CHAR(36)) |
Cross-shard inserts, predictable string format | Hot append-only tables — random UUIDs are terrible for B-tree locality |
UUID() returns random v1-like UUIDs that scatter writes across the
entire B-tree. On a 100M-row table this turns every insert into a
random page write — your buffer pool churns and write throughput
collapses.
The fix is UUID_TO_BIN(UUID(), 1) — the 1 flag rearranges the
timestamp components so consecutive UUIDs land on adjacent pages.
forge does not emit this by default because not every workload
benefits; if you need it, override:
const Order = model('orders', {
// BINARY(16) instead of CHAR(36) — half the size, ordered inserts.
id: f.id({ idType: 'uuid' }).raw('BINARY(16) NOT NULL DEFAULT (UUID_TO_BIN(UUID(), 1))'),
});The tradeoff is that the column is now binary — every read needs
BIN_TO_UUID(id, 1) to render as a string. Hide that behind a
view or accept that the app sees Buffer.
InnoDB allocates AUTO_INCREMENT values in batches controlled by
innodb_autoinc_lock_mode:
| Mode | Behaviour |
|---|---|
| 0 (legacy) | Table-level lock per insert. Serial; safe; slow. |
| 1 (consecutive, default on 5.7) | Bulk inserts pre-allocate a contiguous range; gaps possible on rollback. |
| 2 (interleaved, default on 8.x) | Per-row allocation, fully parallel; gaps and non-monotonic order. |
Mode 2 is fast and the new default but breaks the assumption that
"higher id == later in time". If your code reads sort order from the
id (audit logs, cursor pagination on id), set
innodb_autoinc_lock_mode=1 or add a real created_at.
MySQL has two replication formats: STATEMENT and ROW. The choice ripples into how the forge schema is interpreted on replicas.
| Format | Replicated payload | Determinism risk |
|---|---|---|
STATEMENT (SBR) |
The SQL statement | NOW(), RAND(), non-deterministic UDFs drift |
ROW (RBR) |
The actual changed rows | None — replicas apply byte-for-byte |
| MIXED | STATEMENT, ROW when needed | Less drift than pure SBR, smaller binlog than pure RBR |
Forge's emits are deterministic enough for either format, but two gotchas:
UUID()defaults — under SBR, the replica re-evaluatesUUID()and gets a different value. If you rely onid = UUID()on the master, switch the binlog format toROWor pre-compute the UUID app-side.CURRENT_TIMESTAMP(3)defaults —now()differs by microseconds between master and replica under SBR, which can breakWHERE created_at = ?lookups on replicas. Same fix — useROWformat, or always pass the timestamp from the app.
The forge driver does not natively split reads to replicas. The pattern is two adapters:
const writeDb = await createDb({ url: process.env.MYSQL_PRIMARY!, schema });
const readDb = await createDb({ url: process.env.MYSQL_REPLICA!, schema });
await writeDb.user.create({ data }); // writes go to primary
await readDb.user.findMany({ where: { … } }); // reads go to replicaReplication lag is the perennial bug — a read right after a write might miss the write. The mitigation patterns:
- Sticky session — route reads after a recent write to the primary for a short window (e.g., 1 second).
MASTER_POS_WAIT— block on the replica until it catches up to a known binlog position before reading.- GTID sync — wait for the master's GTID set to apply.
forge does not implement any of these — they live above the adapter.
The transactional read inside a $transaction always uses the
primary because the session is pinned to one connection.
The pool size question is: how many concurrent queries can the server handle, divided by how many app processes share it.
| Side | Knob | Typical |
|---|---|---|
| Server | max_connections |
151 (default) — bump to 500–1000 for OLTP |
| Server | wait_timeout |
28800s (8 hrs) — drop to 600s for idle reaping |
| App | mysql2 connectionLimit |
50 (forge default) |
| App | mariadb connectionLimit |
50 |
| App | PlanetScale | n/a — HTTP, per-request |
The forge default of 50 is calibrated for a single Node process serving moderate OLTP. If you run 4 app processes against a single MySQL server, that's 200 total connections — comfortable under the default 151 cap once you bump it.
A pattern that scales well:
// Hot pool — short transactions, small queries
const writePool = mysql.createPool({ uri: url, connectionLimit: 50 });
// Cold pool — long analytics queries
const readPool = mysql.createPool({ uri: url, connectionLimit: 5 });
const writeDb = await createDb({ schema, driver: mysql2Driver(writePool.promise()) });
const readDb = await createDb({ schema, driver: mysql2Driver(readPool.promise()) });
// Long report queries never starve the OLTP path
await readDb.$queryRaw(forge.sql`SELECT … with window funcs and joins`);A long analytics query that holds a connection for 60s would otherwise eat into the 50-connection pool that serves user requests. Separating them keeps p99 stable.
Pools that hold idle connections beyond wait_timeout see them get
silently killed by the server. The next query returns EHOSTUNREACH
or ECONNRESET. mysql2 reconnects on the next query; mariadb does
the same. PlanetScale is HTTP — no idle connections to lose.
If you see "MySQL server has gone away" (errno 2006), the pool's
idle timeout exceeds the server's wait_timeout. Drop the pool's
idleTimeout below it:
mysql.createPool({ uri: url, connectionLimit: 50, idleTimeout: 60_000 });The forge emitter surfaces server-gone errors as P1001 and
includes the errno in the detail — see below.
process.on('SIGTERM', async () => {
await db.$disconnect();
process.exit(0);
});$disconnect calls pool.end() on the underlying client. Without
it, in-flight queries finish but the pool sockets close on FIN,
which can confuse load balancers expecting graceful drains.
The full mapping lives in src/adapters/mysql/errors.ts and surfaces
through withMysqlErrors. Selected entries:
| errno | code | Symptom | Fix |
|---|---|---|---|
| 1062 | P2002 |
ER_DUP_ENTRY — unique constraint failed |
Check the index name in the message; on upsert paths reach for db.X.upsert({ where, create, update }) |
| 1452 | P2003 |
ER_NO_REFERENCED_ROW_2 — fk insert fails |
Parent row missing. Order writes parent-first inside $transaction |
| 1451 | P2003 |
ER_ROW_IS_REFERENCED_2 — fk delete blocked |
Declare relation({ onDelete: 'Cascade' }) or delete children explicitly |
| 1048 | P2011 |
ER_BAD_NULL_ERROR — null on NOT NULL |
Field is .optional()? if not, app forgot to pass it |
| 3819 | P2004 |
ER_CHECK_CONSTRAINT_VIOLATED |
Enum value not in list. On 5.7 the check is ignored — validation happens app-side |
| 1146 | P2021 |
ER_NO_SUCH_TABLE |
Forgot forge push on the env, or you pointed at the wrong schema |
| 1054 | P2022 |
ER_BAD_FIELD_ERROR |
Pre-push code referencing a not-yet-applied column. Run db.$diff() |
| 1213 | P2034 |
ER_LOCK_DEADLOCK |
Retry the transaction with backoff (see Transactions) |
| 1205 | P2034 |
ER_LOCK_WAIT_TIMEOUT |
Long transactions on hot rows. Shorten the txn or drop to READ COMMITTED |
| 1317 | P2024 |
Query interrupted (timeout) | The client cancelled — usually upstream HTTP timeout |
| 2002 | P1001 |
Connection refused | Server down or wrong host/port |
| 2003 | P1001 |
Can't connect to MySQL server | Firewall, security group, or TLS handshake fail |
| 2006 | P1001 |
MySQL server has gone away | Pool idle > wait_timeout. Drop idleTimeout (see above) |
| 1045 | P1010 |
ER_ACCESS_DENIED_ERROR |
Wrong credentials. Try mysql -u … -p to confirm |
InnoDB's per-index key limit is 3072 bytes on ROW_FORMAT=DYNAMIC
(default on 8.x), 767 on legacy COMPACT. A composite unique on
three VARCHAR(255) utf8mb4 columns is 3 × 255 × 4 = 3060 bytes —
under the limit but cutting it close. Either drop a prefix length
(UNIQUE (col1(100), col2(100), col3(100))), hash the column
(SHA256(col) as BINARY(32)), or confirm the row format is
dynamic with SELECT @@innodb_default_row_format.
Hit net_read_timeout or net_write_timeout (default 60s + 60s).
Bump them server-side or chunk via cursor pagination
(see QUERIES).
max_connections exceeded. Bump the server cap, lower the pool size,
or front the server with ProxySQL / mysql-router. PlanetScale's
HTTP transport sidesteps it entirely.
Column charset is utf8, not utf8mb4. forge emits utf8mb4 on
every CREATE TABLE; the column predates the upgrade. Convert per-
column with ALTER TABLE t MODIFY col … CHARACTER SET utf8mb4 or
per-table with ALTER TABLE t CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci. Both rebuild the table — run them
under pt-online-schema-change on production.
- DRIVERS —
MysqlDriverport shape, transaction semantics across wrappers - QUERIES — operator emit tables,
MATCH() AGAINST()forsearch, themode: 'insensitive'notes - INDEXES —
method: 'fulltext',method: 'spatial', expression indexes,INVISIBLE, partial-unique-via-CASE rewrite - FTS — FULLTEXT engine deep dive,
ngram/mecabparsers, BOOLEAN mode, hybrid BM25+vector - GEO —
ST_*reference, lat-first axis order, 3D point handling, SRID alignment, MultiPolygon emit - VECTOR —
VECTOR(N), HeatWave HNSW,DISTANCE(col, vec, metric)vs pgvector<=> - MIGRATIONS — implicit-DDL-commit constraint,
forge_migrateadvisory lock,pt-online-schema-change - TYPES —
decimalNumbers,bigIntAsNumber,dateStringsand their TypeScript shape effects - TRANSACTIONS —
$transaction, isolation overrides, deadlock retry, gap locks underREPEATABLE READ - JSON-PATH —
JSON_UNQUOTE(JSON_EXTRACT(…)),JSON_TYPEnull disambiguation, generated-column indexes