Closes #1176.
auditSchemaDrift compared type families, and naive and aware timestamps collapsed into one. A live timestamptz column under a model declaring datetime reported clean — in both directions, on every dialect.
Before and after, live Postgres 16
Same table, same call, control column included so a clean result cannot be a miswired harness:
| case | before | after |
|---|---|---|
expires_at declared datetime, live timestamptz | clean=true | clean=false |
created_at declared timestamptz, live naive | clean=true | clean=false |
declared timestamptz, live timestamptz | clean=true | clean=true |
declared datetime, live naive | clean=true | clean=true |
CONTROL amount declared decimal, live integer | clean=false | clean=false |
The two routes
timestamp with time zonematched the generic/^(?:date|time|timestamp|datetime)\b/branch, so declared and actual were bothdate.- The bare
timestamptzspelling matched nothing —\bcannot match betweentimestampandtz— so it classified asotherand left through the unknown-is-not-drift rule.
timestamptz is now its own family, tested before the generic branch and against the whole string so timestamp(6) with time zone still matches. That mirrors normalizePlanType in introspect-db.ts, which already drew this distinction the same way.
Why familyOfSqlType now takes a dialect
Awareness is not a property of the spelling. Postgres timestamp is naive; MySQL's TIMESTAMP is converted to and from the session timezone and DATETIME is the naive one. The same word means opposite things, so a classifier without the dialect gets one of them wrong whichever way it guesses. The parameter is optional and, omitted, reads timestamp as naive — the previous behaviour.
Measured, not assumed — this is what the live servers actually report through introspectDatabase:
Postgres naive -> "timestamp without time zone" aware -> "timestamp with time zone"
MySQL naive -> "datetime" aware -> "timestamp"Two carve-outs, both to avoid firing on schemas bqb itself creates
MySQL:
drivers/mysql.tsmaps bothdatetimeandtimestamptztoDATETIME, so a model declaringtimestamptzgets a naive column from a correct migration. Flagging that would make every MySQL schema bqb created report drift — the same false positive the boolean/tinyint rule exists to prevent. The opposite direction is still flagged: bqb never emits MySQLTIMESTAMP, so a live one under a naive model came from elsewhere and is the dangerous half. Verified live:declared datetime, live TIMESTAMP (aware) clean=false <- caught declared timestamptz, live DATETIME (bqb's) clean=true <- not a false positive CONTROL amount decimal, live int clean=falseSQLite: every date family is stored as
TEXTand no session conversion happens, so the distinction cannot arise.timestamptzis added to the existing TEXT carve-out, anddate/timestamptzsatisfy each other there.
Note on an existing test
reads timestamp with time zone as date asserted the old behaviour, so it encoded the bug. Corrected rather than deleted, and timestamp without time zone added beside it.
Verification
- Full suite: 2447 pass, 26 skip, 0 fail
tscandpickierclean- 8 new tests covering both families, every spelling both servers report, the dialect asymmetry, both carve-outs, and both directions of
satisfies - Live Postgres 16 and MySQL, each with a control column
The MySQL probe database was created for this and dropped afterwards; no existing database was touched.
🤖 Generated with Claude Code