drivers/mysql.ts:56 maps a declared timestamptz to DATETIME, the same type datetime maps to. The two declarations are indistinguishable once a MySQL schema exists.
Split out from #1176, where it forced a carve-out in the drift auditor (#1178).
First, what this is NOT
I expected to find silent data corruption and measured instead that there is none for data bqb writes. temporalLiteral (client.ts:368) sends a Date to MySQL as toISOString() digits — UTC wall-clock — and a DATETIME returns them unconverted. UTC in, UTC out, self-consistent.
It is also not an obviously wrong mapping. MySQL's aware type is TIMESTAMP, and on live MySQL 8.4.5 it will not hold a date past 2038:
INSERT ... DATETIME '2045-06-01 00:00:00' -> stored
INSERT ... TIMESTAMP '2045-06-01 00:00:00' -> ERROR 1292 Incorrect datetime valueexpires_at is exactly the column that wants a far-future date, so "just map timestamptz to TIMESTAMP" trades a semantic gap for a Y2038 range limit. The current mapping looks like a deliberate choice.
The behavioural difference it gives up, same server, same row:
SET time_zone = '+00:00'; INSERT '2026-01-01 12:00:00' into both
read at +00:00 DATETIME 2026-01-01 12:00:00 TIMESTAMP 2026-01-01 12:00:00
read at +08:00 DATETIME 2026-01-01 12:00:00 TIMESTAMP 2026-01-01 20:00:00What the actual costs are
1. It does not round-trip. Postgres keeps the declaration, MySQL loses it:
| declared | column | introspects as | |
|---|---|---|---|
| Postgres | timestamptz | timestamptz | timestamptz |
| MySQL | timestamptz | DATETIME | datetime |
Verified via sqlTypeToNormalized:
datetime -> datetime
timestamp -> datetime
timestamp with time zone -> timestamptz
timestamptz -> timestamptzSo reconcile-from-database rewrites a timestamptz model to datetime on MySQL. That is the same shape as the bug the comment at introspect-db.ts:221-226 says was fixed for Postgres — where collapsing the two made every reconcile propose a destructive type change for every such column, on every run.
2. It forced a carve-out in the drift auditor. #1178 has to excuse declared-timestamptz-over-live-DATETIME on MySQL, because that pairing is what a correct migration produces. The exclusion is sound today only because bqb never emits MySQL TIMESTAMP; it is a rule that exists to paper over this mapping.
3. Nothing says so. A model declaring timestamptz gets Postgres semantics on Postgres and naive-UTC-by-convention on MySQL, with no warning, no documentation, and no error. The convention holds only while every writer is bqb — a raw NOW(), another service, or a dump restored from a tz-aware source all break it, and the auditor cannot see it.
Options
Roughly in order of how much I would trust them:
- Document the mapping and leave it. Cheapest, keeps the 2038 headroom, fixes nothing about round-tripping.
- Make the loss explicit — warn (or refuse) when a model declares
timestamptzunder a MySQL-family dialect, so the semantic gap is a decision rather than a surprise. - Keep the declaration round-trippable so reconcile stops flipping the model. The information has to live somewhere MySQL preserves; a column comment is the usual trick and is introspectable.
- Map to
TIMESTAMP. Real aware semantics, and a 2038 ceiling on exactly the columns most likely to exceed it. I would not do this without opt-in.
Happy to implement whichever you prefer. I have not touched the mapping.
Verified on
MySQL 8.4.5, live. A throwaway database was created for the measurements and dropped afterwards.