Commit 440cd32
fix(driver-sql): bucket a Field.date as its calendar day on PostgreSQL and MySQL (#21611)
Fixes #21485
Clause-②: no
## What changes
`SqlDriver.buildDateBucketExpr` now reads the column's declared type
from the object it already receives. A declared `Field.date` is bucketed
as its own calendar day with no zone conversion. A `Field.datetime`, or
a column with no declaration, keeps the UTC-instant expression byte for
byte.
| dialect | `Field.date` (new) | `Field.datetime` / undeclared
(unchanged) |
|:--|:--|:--|
| PostgreSQL | `to_char((col)::date::timestamp, FMT)` |
`to_char((col)::timestamptz AT TIME ZONE 'UTC', FMT)` |
| MySQL | `date_format(col, FMT)` | `date_format(convert_tz(col,
@@session.time_zone, '+00:00'), FMT)` |
| SQLite | unchanged (`strftime` on TEXT, no zone) | unchanged |
Both callers render this one function, so both follow without a second
edit: `aggregate()`'s GROUP BY (`buildDateBucketExpr(g.field,
g.dateGranularity, table)`, with `table = coercionKey(builder)`), and
the public `dateBucketSql(objectName, …)` member that the analytics SQL
echo reads. No `service-analytics` source is touched.
## The mechanism, measured
PostgreSQL 16.14 with the server's `TimeZone` at `Asia/Shanghai`, at the
expression:
| `date` value | old day | old week | old month | new (all five
granularities) |
|:--|:--|:--|:--|:--|
| `2026-06-01` | `2026-05-31` | `2026-W22` | `2026-05` | `2026-06-01` /
`2026-W23` / `2026-06` / `2026-Q2` / `2026` |
| `2026-01-01` | `2025-12-31` | `2026-W01` | `2025-12` | `2026-01-01` /
`2026-W01` / `2026-01` / `2026-Q1` / `2026` |
| `2024-12-30` | `2024-12-29` | `2024-W52` | `2024-12` | `2024-12-30` /
`2025-W01` / `2024-12` / `2024-Q4` / `2024` |
Casting a `date` to `timestamptz` makes midnight in the session zone;
`AT TIME ZONE 'UTC'` then reads the previous UTC day on any session east
of UTC. A session west of UTC does not shift (midnight local is later
the same UTC day), which is why only east-of-UTC servers show it.
**Why `::date::timestamp` and not the bare `::date` the dispatch
suggested.** `EXPLAIN VERBOSE` shows `to_char((d)::date, …)` resolves to
`to_char((d)::timestamp with time zone, …)` through the implicit cast,
so the bare form still round-trips the session zone. Measured over every
day 1900..2100 in ten zones, that round trip differs from the zone-free
form on exactly one day each in `Pacific/Apia` (`2011-12-30` prints
`2011-12-31`) and `Pacific/Kiritimati` (`1994-12-31` prints
`1995-01-01`), the days those zones skipped. `::date::timestamp`
resolves `to_char(timestamp without time zone, …)` and consults no zone.
**MySQL (H2), measured on MySQL 8.0.46** (the Ubuntu
`mysql-server-core-8.0` binary, run from a private datadir in this
container) at a global `+08:00`. At the expression, `convert_tz(d,
'+08:00', '+00:00')` shifts a `DATE` exactly like PostgreSQL:
`2026-06-01` → day `2026-05-31`, month `2026-05`; `2024-12-30` →
`2024-W52`. Through the driver's default composition it does not,
because the driver pins its own session to `+00:00` (`withUtcSession`),
which makes `convert_tz` the identity. A host `pool.afterCreate` that
sets the session zone (the driver chains it after its own hook) brings
the shift back. Per triage's ruling ("gets the same split if a `DATE`
shifts"; "No server-timezone dependence for a `date` on any dialect"),
the MySQL arm gets the same split.
## Pins
-
`packages/drivers/driver-sql/src/sql-driver-21485-date-bucket-calendar-day.test.ts`
(new). One matrix over `DIALECT_CELLS` through `declareDialectCell`:
`date` and `datetime` × every granularity the dialect buckets in SQL
(declared per dialect and asserted equal to
`supports.queryDateGranularity`, so the iterated set cannot shrink
silently) × session zone. Each cell asserts both doors: `aggregate()`
and the `dateBucketSql()` text run as SQL. The rows sit on month,
quarter, year and ISO-week boundaries, plus the empty bucket.
- sqlite: the zone-free control.
- live postgres / live mysql, each twice: **as provisioned** (both axes,
after `assertThreeWayZoneSkew`) and **session at +08:00 through a host
`pool.afterCreate`** (the `date` axis, after asserting the session
really is at `+08:00`). The second run is red-capable on any server, and
it is the only red-capable route on MySQL.
- `sql-driver-temporal-dialect.test.ts`: a no-server pin (runs in Test
Core too). On `pg` and `mysql2`, a declared `Field.date` expression
names no `timestamptz`, `time zone`, `convert_tz` or `time_zone`; on
`pg` it carries `::date::timestamp`; the undeclared control keeps the
UTC-instant arm.
**Where the live pins run in CI (H4).** Job `Temporal Conformance (live
PG + MySQL)` (required), step `Run driver-sql suite against both live
servers`. It runs the whole driver-sql package with
`OS_TEST_POSTGRES_URL`, `OS_TEST_MYSQL_URL`,
`OS_EXPECT_LIVE_DIALECT_MATRIX=1` and `TZ=America/New_York`, with
PostgreSQL at `Asia/Shanghai` and MySQL at a global `+08:00`. No
workflow edit is needed. PR #21577 (#21564) wires the separate
`service-analytics` step and is not part of this PR.
## Reverse verification and ablation
All runs: PostgreSQL 16.14 at `Asia/Shanghai`, MySQL 8.0.46 at `+08:00`,
process `TZ=America/New_York`, `OS_EXPECT_LIVE_DIALECT_MATRIX=1`; the
new file plus `sql-driver-temporal-dialect.test.ts`.
1. **Before the fix was written** (the test committed alone,
`26b8310fb`): the new file 15 failed / 32 passed of 47. The `date` axis
was red at all five granularities on live postgres as provisioned, live
postgres at +08:00, and live mysql at +08:00. Live mysql as provisioned
(the driver's UTC pin), every `datetime` cell and sqlite were green.
2. **Fix committed (`aa061685b`), then `sql-driver.ts` reverted to the
base text** (`git restore --source=f6b752083`; on-disk anchors
`calendarDay` 3 → 0, the old PostgreSQL month text 0 → 1): **16 failed /
45 passed of 61**. That is the same 15 live cells, plus the no-server
pin. Restored with `git checkout HEAD --
packages/drivers/driver-sql/src/sql-driver.ts`. The `hash-object` was
`be60fd469…`, equal to the HEAD blob; `git diff HEAD` and `git status
--porcelain` were empty.
3. **Fix in place:** 61 of 61 green.
4. **Ablation, the `::timestamp` hop** (`scripts/ablation-replace.mjs`,
anchor 1 → 0): `(??)::date::timestamp` replaced by the bare
`(??)::date`. Predicted: the live pins stay green at `Asia/Shanghai`
(the round trip is the identity there) and only the no-server pin reds.
Observed: **1 failed / 60 passed**, the no-server pin. Restored blob =
HEAD, `git diff HEAD` empty.
5. **The four `service-analytics` cells the card measured**
(`objectql-face-order-limit.test.ts`, which resolves
`@objectstack/driver-sql` through `dist/`): the fix was disabled in
`dist/` (`const calendarDay = false`, rebuilt, `ablation-dist-preflight`
marker present in 2 built files). Result: **4 failed / 22 passed of
26**, exactly the card's four. After restore + rebuild + `--absent`
preflight: the file plus `objectql-echo-date-bucket.test.ts` gave 42 of
42 green against live PostgreSQL at `Asia/Shanghai`. **No assertion
change is needed** in `objectql-face-order-limit.test.ts`, so this PR
does not touch it.
## Dispatch hypotheses
- **H1, holds.** Both call sites pass the object name as the coercion
key (`aggregate`: `coercionKey(builder)`; `dateBucketSql`: `objectName`,
which the analytics echo fills from `extractObjectName(cube)`).
`temporalFieldKind(table, field)` answers `'date'` from `dateFields`,
which only `type: 'date'` populates. No new parameter.
- **H2, measured** (above). PostgreSQL: the card's mechanism, confirmed.
MySQL: the `DATE` shifts under any east-of-UTC session; through the
driver only with a host-set session zone.
- **H3.** Only `type: 'date'` feeds `dateFields`. SQLite stores a `date`
as TEXT, and `strftime` reads it with no zone, so the SQLite arm has no
instance of this class; its sqlite cells are the control. The SQLite
`week` arm is untouched (#21595).
- **H4,** answered above.
## Driver conformance ledger
`pnpm check:driver-conformance`, before (`f6b752083`) and after
(`c20518cfd7`): **50 covered, 0 DEBT, 0 exempt**, both times. The
dialect axis was 8 suites (7 matrix, 1 named cell), 0 DIALECT ledger,
both times. The new file imports no shared `spec/data` case-set, so it
adds no cell. A spec-level shared case-set would have made every one of
the five drivers consume it or carry DEBT. That is outside this card's
file surface and against the no-new-DEBT commitment, so the matrix is
driver-local.
## Local verification
- `pnpm --filter @objectstack/driver-sql exec vitest run
--maxWorkers=2`, with all three dialects live as in CI, at `eab3f3bde`
(after merging `origin/main` `b610eabf7`): **228 files, 5521 passed, 1
skipped** (the skip is pre-existing, in
`schema-drift.base-type-mismatch.test.ts`). The run printed "all 3
dialects were exercised".
- At the final head `c20518cfd7`, whose last commit only removes the `as
any` casts in the new test: `pnpm --filter @objectstack/driver-sql
typecheck` was green (`--listFiles` lists all 228 test files). The new
file, `sql-driver-temporal-dialect.test.ts` and
`live-dialect-matrix.isolation.test.ts` gave **81 of 81** green with all
three dialects live.
- Gates at `c20518cfd7`: `node scripts/pm/dispatch-gates.mjs --commands`
derived 65, and all 65 were run with exit 0. `--ran` printed: "65
derived famil(ies) accounted for, 65 run, 0 NOT-MEASURED". One gate was
red on the way: `check:query-options-erasure`, because the new test's
`as any` raised the test surface 236 → 237. It was answered by typing
the query and the config, not by raising the number.
- ESLint, a narrowed run: the three changed `.ts` files, all in the lint
population (`--print-config` resolves a config for each), 3 files / 0
errors / 0 warnings in the `--format json` output at `c20518cfd7`.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move any untouched file's
verdict. The full `pnpm lint` is CI's.
## Acceptance notes
- **MySQL `datetime` under a host-set session zone (outside this card's
ruling, not changed).** Measured through the driver on MySQL 8.0.46 with
a host `pool.afterCreate` setting `time_zone = '+08:00'`:
`2026-06-01T03:00:00.000Z` stores `2026-06-01 03:00:00.000` in
`DATETIME(3)`, and `find()` reads it back correctly, but `aggregate()`
by `day` answers `2026-05-31`. `convert_tz(…, @@session.time_zone,
'+00:00')` is right for a legacy `TIMESTAMP` column and wrong for a
`DATETIME(3)` that already holds the UTC wall clock. Under the driver's
default composition it is the identity. No in-repo host sets the session
zone, so this is noted here, not filed.
- An external (federated) object that declares `type: 'date'` over a
remote `timestamptz` column would now bucket by the session-local day
(`::date` on a `timestamptz`). That is an inference only, not measured,
and no producer is named.
- `service-analytics` comments (`strategies/objectql-strategy.ts`,
`strategies/types.ts`) and the `objectql-echo-date-bucket.test.ts`
docblock quote the PostgreSQL `… AT TIME ZONE 'UTC' …` text. That is
still the `datetime` expression; they are not edited, because a
`service-analytics` source edit is outside this PR's surface.
- Raise rule: no hosted or shipped deployment was measured running
PostgreSQL at a non-UTC `TimeZone`. The non-UTC readings here are a
private server in this container.
- Merged `origin/main` `b610eabf7` (two commits, `packages/spec` and
docs only, disjoint from this diff) before the full-suite run.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 83b3d32 commit 440cd32
4 files changed
Lines changed: 350 additions & 11 deletions
File tree
- .changeset
- packages/drivers/driver-sql/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
Lines changed: 272 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
Lines changed: 29 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
218 | 247 | | |
219 | 248 | | |
220 | 249 | | |
| |||
0 commit comments