ReviewOS

also looking at this

stacks/bun-query-builder

fix(drift): give timezone awareness its own type family

#1178
Merged glennmichael123 wants to merge fix/drift-timestamptz into main
2 files +135 -7
packages/bun-query-builder/test/schema-drift.test.tsmodified+79-1
Changes to packages/bun-query-builder/test/schema-drift.test.ts
@@ -47,7 +47,8 @@ describe('familyOfSqlType', () => {
4747 ['boolean', 'boolean'],
4848 ['bool', 'boolean'],
4949 ['jsonb', 'json'],
50 ['timestamp with time zone', 'date'],
50 ['timestamp with time zone', 'timestamptz'],
51 ['timestamp without time zone', 'date'],
5152 ['date', 'date'],
5253 ['bytea', 'binary'],
5354 ])('reads %s as %s', (sqlType, expected) => {
@@ -185,3 +186,80 @@ describe('formatSchemaDrift', () => {
185186 expect(report.split('\n').length).toBeLessThan(25)
186187 })
187188})
189
190/**
191 * Naive-vs-aware timestamps — stacksjs/bun-query-builder#1176.
192 *
193 * Both families used to collapse into `date`, so a live `timestamptz` under a
194 * model declaring `datetime` reported clean in both directions, on every
195 * dialect. It is the one drift class that silently changes every value the
196 * application writes: a naive UTC literal stored in an aware column is read
197 * back shifted by the session offset. Three Stacks applications ran 8 hours
198 * off with the auditor calling those schemas clean on every migrate.
199 */
200describe('timestamp awareness (#1176)', () => {
201 it('separates the two families at the declaration', () => {
202 expect(familyOfDeclared('timestamptz')).toBe('timestamptz')
203 expect(familyOfDeclared('datetime')).toBe('date')
204 expect(familyOfDeclared('date')).toBe('date')
205 })
206
207 it('reads every spelling Postgres and MySQL report', () => {
208 // Measured against live servers, not assumed: Postgres information_schema
209 // says `timestamp with/without time zone`, MySQL says `timestamp`/`datetime`.
210 expect(familyOfSqlType('timestamp with time zone')).toBe('timestamptz')
211 expect(familyOfSqlType('timestamp(6) with time zone')).toBe('timestamptz')
212 expect(familyOfSqlType('timestamp without time zone')).toBe('date')
213 expect(familyOfSqlType('timestamp')).toBe('date')
214 expect(familyOfSqlType('datetime')).toBe('date')
215 })
216
217 it('reads the bare timestamptz spelling, which `\\b` cannot reach', () => {
218 // `\b` matches nothing between `timestamp` and `tz`, so the generic date
219 // branch misses this and it used to fall through to `other` — where the
220 // unknown-is-not-drift rule passed it without comment. SQLite returns the
221 // declared text from PRAGMA, so this is the spelling it reports.
222 expect(familyOfSqlType('timestamptz')).toBe('timestamptz')
223 })
224
225 it('reads a bare MySQL TIMESTAMP as aware, unlike Postgres', () => {
226 // The same four letters mean opposite things. MySQL converts TIMESTAMP to
227 // and from the session timezone; DATETIME is its naive type.
228 expect(familyOfSqlType('timestamp', 'mysql')).toBe('timestamptz')
229 expect(familyOfSqlType('datetime', 'mysql')).toBe('date')
230 expect(familyOfSqlType('timestamp', 'postgres')).toBe('date')
231 })
232
233 it('refuses both directions, because neither is a widening', () => {
234 // The bug this issue is about: a naive model over an aware column.
235 expect(satisfies('date', 'timestamptz', 'postgres')).toBe(false)
236 // And the reverse, which loses the offset instead of inventing one.
237 expect(satisfies('timestamptz', 'date', 'postgres')).toBe(false)
238 expect(satisfies('timestamptz', 'timestamptz', 'postgres')).toBe(true)
239 expect(satisfies('date', 'date', 'postgres')).toBe(true)
240 })
241
242 it('still flags the dangerous direction on MySQL', () => {
243 // bqb never emits MySQL TIMESTAMP, so a live one under a naive model came
244 // from somewhere else and is session-converted on every read.
245 expect(satisfies('date', 'timestamptz', 'mysql')).toBe(false)
246 })
247
248 it('excuses declared timestamptz over a MySQL DATETIME', () => {
249 // drivers/mysql.ts maps BOTH `datetime` and `timestamptz` to DATETIME, so
250 // a correct migration produces exactly this pairing. Flagging it would
251 // make every MySQL schema bqb created report drift — the same false
252 // positive the boolean/tinyint rule exists to prevent.
253 expect(satisfies('timestamptz', 'date', 'mysql')).toBe(true)
254 expect(satisfies('timestamptz', 'date', 'singlestore')).toBe(true)
255 })
256
257 it('stays quiet on SQLite, which has no awareness to disagree about', () => {
258 // Every date family is stored as TEXT and no session conversion happens,
259 // so the distinction cannot arise. Reporting it would make every clean
260 // SQLite database look broken.
261 expect(satisfies('timestamptz', 'text', 'sqlite')).toBe(true)
262 expect(satisfies('date', 'timestamptz', 'sqlite')).toBe(true)
263 expect(satisfies('timestamptz', 'date', 'sqlite')).toBe(true)
264 })
265})