auditSchemaDrift compares type families, and naive-vs-aware timestamps collapse into one family. So a live timestamptz column under a model declaring datetime is reported as clean, in both directions, on every dialect. It is the one drift class the auditor cannot express, and it is also the one that silently shifts every value an application writes.
Why this one matters more than the usual drift
A naive UTC literal written into an aware column is interpreted in the session timezone, so the stored instant is shifted by that offset and the same row read from two sessions yields two different times. Stacks writes naive UTC literals everywhere, so on a timestamptz column every timestamp is wrong by the server's offset. We have that live right now: three applications are 8 hours off, and the login path 500s on one of them (stacksjs/stacks#2902).
The auditor is exactly the tool that should have caught it, and it has been reporting those schemas clean on every buddy migrate since they were created. Worth saying plainly: bqb did not create these columns. columnTypeForPostgres maps a declared datetime to plain timestamp and only emits timestamptz when a model asks for it by name. The only failure here is not noticing.
Reproduction
Live Postgres 16.14, a table whose expires_at is timestamptz and whose created_at is naive, audited against a plan declaring both as datetime:
expires_at declared datetime, live timestamptz clean=true mismatches=[]
expires_at declared timestamptz, live timestamptz clean=true mismatches=[]
created_at declared timestamptz, live naive clean=true mismatches=[]
CONTROL amount declared decimal, live integer clean=false mismatches=["amount:integer"]The control is on the same table through the same call, so the harness is live and the auditor is otherwise working. formatSchemaDrift returns an empty string for the first three.
const plan = { tables: [{ table: 'oauth_access_tokens', columns: [{ name: 'expires_at', type: 'datetime' }] }] }
const drift = await auditSchemaDrift(plan, { dialect: 'postgres' })
// drift.clean === true, against a live `expires_at timestamptz`Root cause
Two routes to the same pass, in src/actions/schema-drift.ts:
familyOfDeclaredmapsdate,datetimeandtimestamptzall to'date'(lines 77-80).familyOfSqlTypemaps anything matching/^(?:date|time|timestamp|datetime)\b/to'date'(line 103). Postgresinformation_schema.columns.data_typereports atimestamptzcolumn astimestamp with time zone, which matches that branch, so declared and actual are both'date'andsatisfiesreturns true on its first line.The literal spelling
timestamptzis classified'other', because\baftertimestampcannot match before thetoftz.satisfiesthen passes it through the "unknown is not evidence of drift" rule at line 169. This is the path a SQLite column declaredtimestamptztakes, sincePRAGMA table_inforeturns the declared text.
Verified spelling, same server:
naive -> timestamp without time zone
aware -> timestamp with time zone
awarelong -> timestamp with time zoneSuggested fix
Give awareness its own family rather than widening satisfies:
familyOfDeclared:timestamptzreturns'timestamptz';dateanddatetimekeep'date'.familyOfSqlType: test/with time zone/and the baretimestamptzspelling before the generic date branch, and return'timestamptz'.satisfies: neither family satisfies the other. Nothing is lost in a naive-to-aware move, so the existing widening rationale does not apply: the value changes.
One subtlety that makes this more than a two-line change. familyOfSqlType currently takes no dialect, but for the bare word timestamp awareness is dialect-dependent: on Postgres timestamp is naive, while MySQL's TIMESTAMP is converted to and from the session timezone and DATETIME is the naive one. So MySQL has this same bug in mirror image, and the classifier needs the dialect threaded in to call it correctly. The existing SQLite carve-out at lines 141-146 also needs timestamptz added alongside date, or every SQLite database starts reporting drift.
Happy to send the patch if you would rather not take it on.