Uh oh!
There was an error while loading. Please reload this page.
fix(driver-sql): keep a PostgreSQL date a calendar-day string, not a local-midnight Date - #11561
Conversation
…local-midnight Date Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
… process timezone Plus the changeset for the driver-sql calendar-day fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a5c5d83644d5c52062395a5c6136bcbdbdf57f9b && git checkout a5c5d83644d5c52062395a5c6136bcbdbdf57f9b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ba8420b58d36077fe79ca8ce0f201bb31f8be2bc 70c6c931ea0f53c2ac5cbe675f8c53e1c786ea09 && git checkout -B drift-repro ba8420b58d36077fe79ca8ce0f201bb31f8be2bc && git merge --no-ff 70c6c931ea0f53c2ac5cbe675f8c53e1c786ea09
node scripts/docs-audit/affected-docs.mjs --json ba8420b58d36077fe79ca8ce0f201bb31f8be2bc
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11389
On PostgreSQL every
Field.dateread back throughSqlDriverwas one calendar day early whenever the Node process ran east of UTC. The stored value was always right; the read corrupted it, so the wrong day was already in the REST payload. Production-reported on an app container runningTZ=Asia/Shanghai.What I measured before choosing a route
The card offers two routes and prefers (b). Both were measured rather than assumed, on a live PostgreSQL 16 and MySQL 8.0 configured to CI parity (PG
timezone='Asia/Shanghai', MySQL@@global.time_zone='+08:00'), with only the processTZchanged between runs.1. The read path really is on the local clock.
pgmaterialises OID 1082 withnew Date(y, m - 1, d):TZpgmaterialised for stored2026-08-24toDateOnlyreturnedUTC2026-08-24T00:00:00.000Z2026-08-24America/New_York2026-08-24T04:00:00.000Z2026-08-24Asia/Shanghai2026-08-23T16:00:00.000Z2026-08-232. The two-clock trap is real, and route (a) would break the write path.
toDateOnlyis shared by the read (formatOutput,presentReadValue), write (formatInput) and filter (coerceFilterValue) paths, and they do not hand itDates from one source:TZ=Asia/ShanghaiTZ=America/New_Yorknew Date('2026-08-24')— write/filter…T00:00Z, UTC comp 24, local comp 24…T00:00Z, UTC comp 24, local comp 23new Date(2026, 7, 24)— write/filter…T16:00Z, UTC comp 23, local comp 24…T04:00Z, UTC comp 24, local comp 24pghanded the READ path…T16:00Z, UTC comp 23, local comp 24…T04:00Z, UTC comp 24, local comp 24So no single clock is right for every
Datethat can arrive at that helper. Reading local components fixes row 3 and breaks row 1: anew Date('2026-08-24')comparand becomes2026-08-23west of UTC — the identical one-day error, moved onto the write and filter paths. Route (a) is measurably wrong, and the card's preference for (b) is confirmed rather than taken on trust.3. A correction to the card: MySQL is NOT protected by "SQLite round-trips as TEXT". The card says the bug is Postgres-only because SQLite keeps dates as TEXT. That is true of SQLite but says nothing about MySQL, and mysql2 materialises a
DATEat local midnight too — measured identically topg. MySQL is correct today for a different reason the card never names:withUtcSession(#3942) already pinsconnection.timezone: 'Z'on every mysql2 connection, and with that pin the same column arrives at UTC midnight:That asymmetry is the actual root-cause shape: MySQL had a connection-level pin and PostgreSQL had none. The fix gives PostgreSQL its counterpart, and the MySQL cell is in the new matrix so that losing the mysql2 pin would reproduce this issue one dialect over.
The fix — route (b)
SqlDriver.withPostgresCalendarDayAsText, chained beside the existingwithUtcSessioninwithConnectBound, installs apool.afterCreatehook that registers connection-scoped parsers:date) returns the wire text unchanged — the wire form already isYYYY-MM-DD.date[]) reuses the connection's owntext[]parser (OID 1009), which is pg's array-literal splitter with an identity element transform. No hand-rolled array parsing:NULLelements survive asnull,{}yields[], and multi-dimensional arrays parse correctly.timestamptzis untouched — an instant is exactly what aDateis for, andField.datetimedepends on it.Three properties worth naming:
pg.types.setTypeParsermutates the pg-types registry for every pg client in the process, including a host application's own. Registering on the connection scopes it to the pools this driver opened; verified with a second, plainpg.Clientin the same process still receiving aDate.pg.pgis an optional peer dependency, anddriver-sqlbuilds through the shared roottsup.config.ts, which does not setshims: true— socreateRequire(import.meta.url)would emitimport.metaverbatim into the CJS bundle and breakrequire('@objectstack/driver-sql')at load (the measured failurepackages/metadata-protocol/tsup.config.tsdocuments).setTypeParser/getTypeParserare read off thepg.Clientknex hands the hook instead, and the hook no-ops on a connection that does not expose them.withConnectBoundused toreturnearly when a client had no entry inDIALECT_CONNECT_TIMEOUT, which also skipped the session pins below it. Those answer a different question (what a value means on this connection, not how long a connect may take) and the lists differ:redshiftspeaks the pg wire protocol but has no timeout entry, so it silently opted out of a fix it needs. The earlyreturnis now a guarded block; sqlite's config is byte-identical either way (both transforms return their input unchanged for non-matching clients).toDateOnlykeeps its UTC-component reading and now documents that as its contract, with the measurement above inline and an explicit warning against "repairing" a date skew by switching to the local getters.Reverse verification — the pin was seen red
Ran the new suite against the pre-fix
sql-driver.ts(restored withgit restore --source=origin/main; mutation confirmed on disk bygrep -c withPostgresCalendarDayAsTextreturning0whilegetUTCFullYearstill returned1, and a lone unstagedMingit status --porcelain). No rebuild leg applies:driver-sql's vitest resolves the subject through a relative source import, not throughdist/. 10 of 38 failed, in the predicted direction:The
UTCandAmerica/New_Yorkcells passed on pre-fix source, and so did every MySQL cell. That is the point of the matrix: the existingTemporal Conformance (live PG + MySQL)job pinsTZ=America/New_York, which is west of UTC, where the pre-fix read names the right day. A TZ matrix that never runs east of UTC cannot fail this, so the suite asserts its own zone list contains an east-of-UTC cell before believing any of it. The fix was then restored withgit checkout HEAD --and re-proved byte-identical (git status --porcelainempty,git diff HEADclean).Tests
packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts— 38 cases in three layers:pool.afterCreateis driven out of the real knex config with a recording connection: which OIDs get a parser and nothing else, thatdatecomes back byte-for-byte, thatdate[]is bound to the connection's owntext[]parser by identity, that every pg-wire client name is covered, that a hostafterCreateis chained and its error surfaced, that a non-pg.Clientdegrades to a no-op, and that sqlite and mysql2 are untouched.Dateshapes across four process zones, on SQLite, covering both the write and filter paths. These pass on pre-fix source by design — they guard the rejected fix, not the bug.find,distinct(thepresentReadValueconsumer) and a filter, plus a raw wire-form read assertingdate[]and an untouchedtimestamptz.Not fixed here, by nature
Rows already written through the old skew — an
afterUpdatehook copying a date it had just read — still hold the wrong day. Nothing in a driver can identify them: a date written correctly and a date written through a skewed read are byte-identical on disk. That is a per-deployment data fix, and the changeset says so.pgnative(knex'spg-nativeclient) is deliberately outside the covered client set and was not measured — no path in this repo exercises it, and it parses on the C side where the JS parser registry may not apply.Verification
Against live PostgreSQL 16 (
timezone='Asia/Shanghai') and MySQL 8.0 (@@global.time_zone='+08:00'), process atTZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1— CI parity. Both servers were found down in the container (pg_lsclustersreported16 main 5432 downplus a stale pid file;service mysql statusreported stopped) and were started and configured for this run.All of the following ran on
70c6c931ea, the head of this branch:pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2—Test Files 126 passed (126)/Tests 2549 passed (2549)pnpm --filter @objectstack/driver-sql typecheck— cleaneslint . --no-inline-configover the whole repo, not a narrowed subset — exit 0, and eslint's own--format jsonaccounting reports 5031 files, 0 errors, 0 warnings; both files this PR changes appear in that population withe=0 w=0node scripts/pm/dispatch-gates.mjs(which reads the change set from the merge base itself), all green:check:changeset-gate-self-tests,check:objectui-changeset,check:doc-anchors,check:doc-authoring,check:doc-formula-expressions,check:doc-security-posture,check:docs-audit-scope,check:docs-redirects,check:role-word,check:published-readme-links,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs,check:driver-conformance,check:empty-state,check:liveness,check:strictness-ledger,check:variant-docs,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-ci-filter-parity,check-doc-frontmatter,check-plugin-teardown-shape,check-section-landing-index,docs-audit/check-affected-docscheck:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:nul-bytes— all greenFiled, not fixed here
client: 'postgres'silently loses every dialect-specific behaviour #11550 —isSqlite/isPostgres/isMysqlrecognise fewer knex client spellings than knex accepts, soclient: 'postgres'(knex's own canonical name) silently loses every dialect-specific behaviour, including the UTCdatecolumn default that Field.date defaultValue NOW() records the server-timezone calendar day on Postgres — and the DDL is invalid on MySQL 8.0 #4022 added. Found while establishing which client names route to thepgdriver; out of scope because those getters are read from regions ofsql-driver.tsheld by concurrent claims.Generated by Claude Code