ReviewOS

also looking at this

stacks/bun-query-builder

docs(mysql): say that timestamptz is naive there, and warn once

#1180
Merged glennmichael123 wants to merge docs/mysql-timestamptz-warning into main
2 files +60 -1

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/advanced/dialects.mdmodified+20-0
Changes to docs/advanced/dialects.md
@@ -591,6 +591,26 @@ This holds with `pool.preparedStatements: false` as well. Bun's driver on its ow
591591
592592An invalid `Date` (`new Date('nope')`) throws a `RangeError` naming the parameter before anything is sent. MySQL connections are left to the driver, and model rows are reshaped to `YYYY-MM-DD HH:MM:SS` there, since a `DATETIME` literal cannot carry the ISO `T` and `Z`.
593593
594### `timestamptz` is not timezone-aware on MySQL
595
596A column declared `timestamptz` is genuinely aware on Postgres, and naive on MySQL:
597
598| declared | Postgres | MySQL |
599| --- | --- | --- |
600| `datetime` | `timestamp` (naive) | `DATETIME` (naive) |
601| `timestamptz` | `timestamptz` (aware) | `DATETIME` (**naive**) |
602
603MySQL's only aware type is `TIMESTAMP`, and it cannot hold a date past 2038-01-19 — MySQL 8.4 rejects an insert of `2045-06-01` outright. Since `timestamptz` columns are usually the far-future ones (`expires_at`, period ends), `DATETIME` is the safer storage, so that is what the driver emits. Declaring `timestamptz` under a MySQL dialect logs a warning once per column.
604
605In practice values still round-trip, because everything this library writes is UTC: `temporalLiteral` sends UTC wall-clock digits and MySQL returns them unconverted. What you lose is the server enforcing it. The column is an instant **by convention**, and the convention breaks for anything writing directly — a raw `NOW()`, another service, or a dump restored from a timezone-aware source. Nothing detects that afterwards, because the stored value looks the same either way.
606
607Two consequences worth knowing:
608
609- Regenerating model source from a MySQL database rewrites a `timestamptz` attribute to `datetime`, since that is what the column reports. It does not cause migration churn — both declarations map to the same storage type, so a reconcile compares equal.
610- `auditSchemaDrift` cannot flag a `timestamptz` model over a MySQL `DATETIME`, because that pairing is what a correct migration produces. It still flags the dangerous direction: a live MySQL `TIMESTAMP` under a naive model is session-converted on every read, and this library never emits one.
611
612If you want the server to enforce the instant and your dates stay before 2038, declare the column `TIMESTAMP` in a hand-written migration rather than through a model.
613
594614## Performance Tips Per Dialect
595615
596616- Postgres: leverage `EXPLAIN (ANALYZE, BUFFERS)` during tuning; create appropriate indexes for JSON paths if needed
packages/bun-query-builder/src/drivers/mysql.tsmodified+40-1
Changes to packages/bun-query-builder/src/drivers/mysql.ts
@@ -3,6 +3,43 @@ import type { DateBucketGrain } from './postgres'
33import { decimalTypeSql } from './decimal'
44import { qualifiedIndexName } from './index-name'
55
6/**
7 * Warn, once per column per process, that `timestamptz` is not aware on MySQL.
8 *
9 * MySQL's only timezone-aware type is `TIMESTAMP`, and it cannot hold a date
10 * past 2038-01-19 - on 8.4 an insert of `2045-06-01` is rejected outright with
11 * ERROR 1292. Since `timestamptz` columns are usually the far-future ones
12 * (`expires_at`, period ends), `DATETIME` is the safer storage and is what
13 * this driver emits.
14 *
15 * That is a real loss, though, and nothing else reports it. The column is
16 * naive: the server performs no session conversion, so the value is only an
17 * instant by convention. The convention holds for anything this library
18 * writes, because `temporalLiteral` sends UTC wall-clock digits, and breaks
19 * for a raw `NOW()`, another service, or a dump restored from a tz-aware
20 * source. On Postgres the same declaration is genuinely aware, so a model
21 * that is correct on one dialect is only conventionally correct on the other.
22 *
23 * Nor does the declaration survive a round trip: MySQL reports the column as
24 * `datetime`, so regenerating model source from the database rewrites the
25 * attribute. It does not cause migration churn - `canonicalStorageType` maps
26 * both declarations to `DATETIME`, so a reconcile compares equal - which is
27 * why this warns rather than refuses. stacksjs/bun-query-builder#1179
28 */
29const warnedTimestamptzColumns = new Set<string>()
30function warnOnceTimestamptzIsNaive(columnName: string): void {
31 if (warnedTimestamptzColumns.has(columnName))
32 return
33 warnedTimestamptzColumns.add(columnName)
34 console.warn(
35 `[query-builder] ${columnName}: MySQL has no timezone-aware type that can hold a date past `
36 + `2038, so \`timestamptz\` is stored as DATETIME, which the server never converts. Values `
37 + `this library writes are UTC, so they round-trip - but the column is only an instant by `
38 + `convention, and anything writing to it directly (a raw NOW(), another service, a restored `
39 + `dump) can break that. Declare \`datetime\` to say so explicitly.`,
40 )
41}
42
643export interface DialectDriver {
744 createEnumType: (enumTypeName: string, values: string[]) => string
845 createTable: (table: TablePlan) => string
@@ -53,7 +90,9 @@ export class MySQLDriver implements DialectDriver {
5390 case 'decimal': return decimalTypeSql(column)
5491 case 'date': return 'date'
5592 case 'datetime': return 'datetime'
56 case 'timestamptz': return 'datetime'
93 case 'timestamptz':
94 warnOnceTimestamptzIsNaive(column.name)
95 return 'datetime'
5796 case 'json': return 'json'
5897 case 'enum':
5998 if (column.enumValues && column.enumValues.length > 0) {