also looking at this
docs(mysql): say that timestamptz is naive there, and warn once
#1180
2 files
+60
-1
| @@ -591,6 +591,26 @@ This holds with `pool.preparedStatements: false` as well. Bun's driver on its ow | ||
| 591 | 591 | |
| 592 | 592 | An 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`. |
| 593 | 593 | |
| 594 | ### `timestamptz` is not timezone-aware on MySQL | |
| 595 | ||
| 596 | A 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 | ||
| 603 | MySQL'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 | ||
| 605 | In 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 | ||
| 607 | Two 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 | ||
| 612 | If 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 | ||
| 594 | 614 | ## Performance Tips Per Dialect |
| 595 | 615 | |
| 596 | 616 | - Postgres: leverage `EXPLAIN (ANALYZE, BUFFERS)` during tuning; create appropriate indexes for JSON paths if needed |
| @@ -3,6 +3,43 @@ import type { DateBucketGrain } from './postgres' | ||
| 3 | 3 | import { decimalTypeSql } from './decimal' |
| 4 | 4 | import { qualifiedIndexName } from './index-name' |
| 5 | 5 | |
| 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 | */ | |
| 29 | const warnedTimestamptzColumns = new Set<string>() | |
| 30 | function 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 | ||
| 6 | 43 | export interface DialectDriver { |
| 7 | 44 | createEnumType: (enumTypeName: string, values: string[]) => string |
| 8 | 45 | createTable: (table: TablePlan) => string |
| @@ -53,7 +90,9 @@ export class MySQLDriver implements DialectDriver { | ||
| 53 | 90 | case 'decimal': return decimalTypeSql(column) |
| 54 | 91 | case 'date': return 'date' |
| 55 | 92 | case 'datetime': return 'datetime' |
| 56 | case 'timestamptz': return 'datetime' | |
| 93 | case 'timestamptz': | |
| 94 | warnOnceTimestamptzIsNaive(column.name) | |
| 95 | return 'datetime' | |
| 57 | 96 | case 'json': return 'json' |
| 58 | 97 | case 'enum': |
| 59 | 98 | if (column.enumValues && column.enumValues.length > 0) { |