diff --git a/.changeset/pg-date-calendar-day-not-local-midnight.md b/.changeset/pg-date-calendar-day-not-local-midnight.md new file mode 100644 index 0000000000..48817588ab --- /dev/null +++ b/.changeset/pg-date-calendar-day-not-local-midnight.md @@ -0,0 +1,61 @@ +--- +'@objectstack/driver-sql': minor +--- + +Stop reading every PostgreSQL `Field.date` one day early on a process east of UTC + +On PostgreSQL a `Field.date` came back **one calendar day early** whenever the +Node process ran east of UTC — an app container on `TZ=Asia/Shanghai` served +`"apply_date": "2026-08-23"` for a row `psql` reads as `2026-08-24`. The stored +value was always right; the read corrupted it, so the wrong day was already in +the REST payload before anything rendered it. Worse than a display bug: an +`afterUpdate` hook copying a date into a child record persisted the shifted +value, writing the wrong day back into the database. + +`node-postgres` materialises OID 1082 (`date`) as a JS `Date` at **local** +midnight, and `SqlDriver#toDateOnly` reads a `Date` with **UTC** components. +East of UTC, local midnight is the previous day in UTC. Measured on PostgreSQL +16, one stored row `2026-08-24`, only the process `TZ` changed: + +| process `TZ` | `pg` materialised | driver returned | +|---|---|---| +| `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | +| `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | +| `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | + +Fixed at the parser rather than the reader: the driver now registers a +connection-scoped type parser so `date` (OID 1082) and `date[]` (1182) arrive +as their `YYYY-MM-DD` wire text and never become a `Date` at all — the same +shape SQLite has always had, and the same shape MySQL already had via the +existing UTC connection pin. `timestamptz` is untouched: an instant is what a +`Date` is for, and `Field.datetime` depends on it. The parser is registered on +the connections this driver opens, never through the process-wide +`pg.types.setTypeParser`, so a host application's own `pg` clients keep stock +behaviour. + +Reading local components in `toDateOnly` instead was measured and rejected: +that helper is shared by the read, write and filter paths, and a caller's +`new Date('2026-08-24')` is UTC midnight — local components would report it as +`2026-08-23` west of UTC, i.e. the identical one-day error moved onto the write +and filter paths. `toDateOnly` now documents the UTC clock as its contract. + +**If you worked around this, you can undo the workaround.** Running the app +process with `TZ=UTC` is no longer a prerequisite for correct dates, and any +app-side "+1 day" compensation on a PostgreSQL date read must be removed — with +this release the driver returns the stored day, so a compensating shift now +overshoots. Rows that were *written* through the old skew (a hook that copied a +date it had just read) still hold the wrong day and need a data fix; nothing +here rewrites stored data. + +One behaviour change beyond the corrected day: on PostgreSQL a raw read +(`driver.execute(...)`, or knex used directly on this driver's connection) now +yields a `string` for a `date` column where it previously yielded a `Date`. +Values leaving `find()` / `findOne()` / `aggregate()` / `distinct()` were +already normalised to `YYYY-MM-DD` strings and keep that type — only the day +they name changes. + +Pinned by a process-zone matrix (`UTC`, `Asia/Shanghai`, `America/New_York`, +`Asia/Kolkata`) that asserts it contains an east-of-UTC cell before it believes +itself: the existing live-Postgres CI job runs at `TZ=America/New_York`, which +is west of UTC, where the pre-fix read names the right day — which is why this +was green in CI for as long as it was broken in production. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 21f64f7a53..0e598c7b4e 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -373,6 +373,14 @@ depends on a timezone, it is a `datetime`, not a `date`. `YYYY-MM-DD` on every dialect - MongoDB: no DDL (schemaless); the driver stores the value it is given +**The day you read back does not depend on the app process's timezone.** The SQL +driver pins each dialect so a stored `DATE` arrives as the calendar-day string +itself, never as an instant it would then have to re-derive a day from: +PostgreSQL connections parse `date` (and `date[]`) as text, and MySQL +connections are pinned to UTC in both directions. So an app container running +`TZ=Asia/Shanghai` and one running `TZ=UTC` read the same row as the same day — +running the process at UTC is not a prerequisite for correct dates. + **Use cases:** - Birthdays, anniversaries - Contract dates diff --git a/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts new file mode 100644 index 0000000000..8a96b1a41e --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11389-date-tz-skew.test.ts @@ -0,0 +1,441 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #11389 — a `Field.date` read back from PostgreSQL was one calendar day early + * whenever the Node process ran EAST of UTC. + * + * `node-postgres` materialises OID 1082 (`date`) with `new Date(y, m - 1, d)`, + * i.e. **local** midnight; `SqlDriver#toDateOnly` reads a `Date` with **UTC** + * components. East of UTC local midnight is the previous day in UTC, so the two + * disagreed by a day and the REST payload carried the wrong date — production + * reported, on an app container running `TZ=Asia/Shanghai`. + * + * ## Why the existing live matrix never caught it + * + * `Temporal Conformance (live PG + MySQL)` runs the whole driver-sql suite at + * `TZ=America/New_York`. That is WEST of UTC, where local midnight is later the + * same day in UTC and the UTC components name the right day. **A timezone + * matrix that never runs east of UTC cannot fail this**, which is why every + * sweep below is over a zone list that is asserted to contain an east-of-UTC + * cell before any of it is believed. + * + * ## What each part measures + * + * - **The mechanism, serverless.** The real `pool.afterCreate` is driven out + * of the real knex config with a recording connection, so which OIDs get a + * parser — and what those parsers return — is pinned everywhere the suite + * runs, not only where a live Postgres is attached. + * - **The two-clock pin, serverless.** `toDateOnly` is shared by the read, + * write and filter paths, and the `Date`s they hand it are NOT on one clock. + * Measured: a caller's `new Date('2026-08-24')` is UTC midnight, so reading + * local components off it yields `2026-08-23` under `TZ=America/New_York` — + * the identical one-day error in the mirror direction, moved onto the write + * and filter paths. These cases go red if anyone ever "fixes" a residual + * skew by swapping the getters in `toDateOnly` for their local twins. + * - **The live matrix.** One connection, one set of rows, the process zone + * swept underneath them — the card's own end-to-end table, executed. + */ + +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { + MYSQL_CELL, + PG_CELL, + declareDialectCell, + type DialectCell, +} from './live-dialect-matrix.testkit.js'; + +/** The day under test, and the year boundary where a one-day skew changes the YEAR. */ +const DAY = '2026-08-24'; +const NEW_YEAR = '2026-01-01'; + +/** + * The process-zone axis. + * + * `Asia/Shanghai` is the load-bearing cell: it is the only one where the + * pre-fix read is wrong, and the reason this list is not just "UTC and the zone + * CI already uses". `Asia/Kolkata` adds a half-hour offset, the shape that + * breaks arithmetic written for whole-hour zones. + */ +const ZONE_MATRIX = ['UTC', 'Asia/Shanghai', 'America/New_York', 'Asia/Kolkata'] as const; + +/** Minutes EAST of UTC for `when`, as the zone currently installed sees it. */ +const offsetEastOfUtc = (when: Date): number => 0 - when.getTimezoneOffset(); + +/** Run `body` with the process timezone set to `tz`, restoring it afterwards. */ +async function underProcessZone(tz: string, body: () => Promise | T): Promise { + const previous = process.env.TZ; + process.env.TZ = tz; + try { + return await body(); + } finally { + // Restore rather than assume: vitest reuses a worker across files, so a + // leaked TZ would silently re-zone whatever runs next in this process. + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } +} + +describe('#11389 — the process-zone axis is non-vacuous', () => { + it('sweeps a zone EAST of UTC, a zone WEST of UTC, and UTC itself', async () => { + const offsets = new Map(); + for (const tz of ZONE_MATRIX) { + await underProcessZone(tz, () => { + offsets.set(tz, offsetEastOfUtc(new Date(`${DAY}T00:00:00Z`))); + }); + } + const seen = JSON.stringify(Object.fromEntries(offsets)); + + expect( + [...offsets.values()].some((o) => o > 0), + `no cell of ZONE_MATRIX is east of UTC (${seen}). West of UTC the pre-fix UTC-component ` + + 'read names the RIGHT day, so a matrix without an east cell passes on broken source — ' + + 'which is exactly why the America/New_York-pinned CI job never caught #11389.', + ).toBe(true); + + expect( + [...offsets.values()].some((o) => o < 0), + `no cell of ZONE_MATRIX is west of UTC (${seen}) — the mirror direction (a UTC-midnight ` + + 'comparand read with local components) only misbehaves west of UTC.', + ).toBe(true); + + expect([...offsets.values()].some((o) => o === 0), seen).toBe(true); + }); + + it('restores the ambient zone after a sweep', async () => { + const before = Intl.DateTimeFormat().resolvedOptions().timeZone; + await underProcessZone('Pacific/Kiritimati', () => { + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('Pacific/Kiritimati'); + }); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(before); + }); +}); + +// ── The mechanism, without a server ───────────────────────────────────────── + +/** + * A stand-in for the `pg.Client` knex hands `pool.afterCreate`, recording what + * the hook registers instead of parsing anything. + * + * `getTypeParser` returns a per-OID sentinel so the assertion that the `date[]` + * parser IS the connection's own `text[]` parser can be made by identity — + * without this file importing `pg`, which is an optional peer dependency of the + * package under test and must not become a test-time requirement. + */ +function recordingPgConnection() { + const registered = new Map unknown>(); + const sentinels = new Map unknown>(); + return { + registered, + /** The sentinel this connection would hand back for `oid`. */ + sentinelFor(oid: number) { + if (!sentinels.has(oid)) sentinels.set(oid, () => `built-in parser for ${oid}`); + return sentinels.get(oid)!; + }, + connection: { + getTypeParser(oid: number, _format?: string) { + if (!sentinels.has(oid)) sentinels.set(oid, () => `built-in parser for ${oid}`); + return sentinels.get(oid)!; + }, + setTypeParser(oid: number, parse: (text: string) => unknown) { + registered.set(oid, parse); + }, + }, + }; +} + +/** Drive the hook the driver really installed, and report what it registered. */ +async function runAfterCreate(driver: SqlDriver, connection: unknown): Promise { + const hook = (driver as any).knex.client.config.pool?.afterCreate as + | ((conn: unknown, done: (err?: unknown, conn?: unknown) => void) => void) + | undefined; + expect(typeof hook, 'the driver installed no pool.afterCreate for this dialect').toBe('function'); + await new Promise((resolve, reject) => { + hook!(connection, (err?: unknown) => (err ? reject(err) : resolve())); + }); +} + +describe('#11389 — a Postgres `date` never becomes a JS Date', () => { + const OID_DATE = 1082; + const OID_DATE_ARRAY = 1182; + const OID_TEXT_ARRAY = 1009; + + const drivers: SqlDriver[] = []; + const make = (cfg: any): SqlDriver => { + const d = new SqlDriver(cfg); + drivers.push(d); + return d; + }; + + afterEach(async () => { + await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {}))); + }); + + it('registers a text parser for `date` and `date[]`, and for nothing else', async () => { + const rec = recordingPgConnection(); + await runAfterCreate(make({ client: 'pg', connection: 'postgres://u:p@host:5432/d' }), rec.connection); + + expect([...rec.registered.keys()].sort((a, b) => a - b)).toEqual([OID_DATE, OID_DATE_ARRAY]); + }); + + it('hands a `date` back as its wire text, byte for byte', async () => { + const rec = recordingPgConnection(); + await runAfterCreate(make({ client: 'pg', connection: 'postgres://u:p@host:5432/d' }), rec.connection); + + const parseDate = rec.registered.get(OID_DATE)!; + expect(parseDate(DAY)).toBe(DAY); + expect(parseDate(NEW_YEAR)).toBe(NEW_YEAR); + // Whatever the wire says, including shapes a calendar day never takes — the + // parser is deliberately not a validator. + expect(parseDate('infinity')).toBe('infinity'); + }); + + it("reuses the connection's own `text[]` parser for `date[]`", async () => { + // Identity, not behaviour: pg's `text[]` parser IS its array-literal + // splitter with an identity element transform, so borrowing it is what + // keeps a `date[]` element a string — with no hand-rolled array parsing in + // this driver, and no import of `pg`. + const rec = recordingPgConnection(); + await runAfterCreate(make({ client: 'pg', connection: 'postgres://u:p@host:5432/d' }), rec.connection); + + expect(rec.registered.get(OID_DATE_ARRAY)).toBe(rec.sentinelFor(OID_TEXT_ARRAY)); + }); + + it('applies to every knex client that speaks the pg wire protocol', async () => { + for (const client of ['pg', 'postgres', 'postgresql', 'cockroachdb', 'redshift']) { + const rec = recordingPgConnection(); + await runAfterCreate(make({ client, connection: 'postgres://u:p@host:5432/d' }), rec.connection); + expect([...rec.registered.keys()].sort((a, b) => a - b), `client=${client}`) + .toEqual([OID_DATE, OID_DATE_ARRAY]); + } + }); + + it('is not gated on the connect-timeout table — the two lists are not the same list', async () => { + // `redshift` speaks the pg wire protocol but has no connect-timeout knob, + // so it is absent from DIALECT_CONNECT_TIMEOUT. Measured while fixing + // #11389: with the session pins reached only through that table's early + // return, redshift silently opted out of a fix it needs. This asserts the + // two concerns really are independent — no timeout injected, pin applied. + const rec = recordingPgConnection(); + const driver = make({ client: 'redshift', connection: 'postgres://u:p@host:5439/d' }); + // knex parses a URL connection into its own object either way, so the + // readable signal is that no timeout key was injected into it. + expect((driver as any).knex.client.config.connection.connectionTimeoutMillis).toBeUndefined(); + await runAfterCreate(driver, rec.connection); + expect([...rec.registered.keys()].sort((a, b) => a - b)).toEqual([OID_DATE, OID_DATE_ARRAY]); + }); + + it('chains a host-supplied afterCreate rather than replacing it', async () => { + const seen: string[] = []; + const rec = recordingPgConnection(); + const driver = make({ + client: 'pg', + connection: 'postgres://u:p@host:5432/d', + pool: { min: 0, max: 5, afterCreate: (_c: unknown, done: (e?: unknown) => void) => { seen.push('host'); done(); } }, + }); + await runAfterCreate(driver, rec.connection); + + expect(seen).toEqual(['host']); + expect(rec.registered.size).toBe(2); + // …and the host's own pool sizing survives the wrapping. + expect((driver as any).knex.client.config.pool).toMatchObject({ min: 0, max: 5 }); + }); + + it("surfaces a host afterCreate's error instead of swallowing it", async () => { + const rec = recordingPgConnection(); + const driver = make({ + client: 'pg', + connection: 'postgres://u:p@host:5432/d', + pool: { afterCreate: (_c: unknown, done: (e?: unknown) => void) => done(new Error('host said no')) }, + }); + await expect(runAfterCreate(driver, rec.connection)).rejects.toThrow('host said no'); + }); + + it('degrades to a no-op on a connection that is not a pg.Client', async () => { + // A stub, or a future knex shape: leaving the connection alone is right, + // failing the pool acquire is not. + const notAClient = { query: () => {} }; + await expect( + runAfterCreate(make({ client: 'pg', connection: 'postgres://u:p@host:5432/d' }), notAClient), + ).resolves.toBeUndefined(); + }); + + it('leaves the other dialects alone', () => { + const sqlite = make({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + expect((sqlite as any).knex.client.config.pool?.afterCreate).toBeUndefined(); + + // MySQL keeps exactly the UTC-session hook of #3942 — the date fix must not + // have displaced it, and mysql2 needs no parser override (its DATE arrives + // at UTC midnight because `withUtcSession` pins `connection.timezone: 'Z'`). + const mysql = make({ client: 'mysql2', connection: 'mysql://u:p@host:3306/d' }); + expect(typeof (mysql as any).knex.client.config.pool?.afterCreate).toBe('function'); + expect((mysql as any).knex.client.config.connection.timezone).toBe('Z'); + }); +}); + +// ── The two-clock pin: what `toDateOnly` must keep doing ──────────────────── + +describe('#11389 — the write and filter paths keep reading a Date on the UTC clock', () => { + let driver: SqlDriver | undefined; + + afterEach(async () => { + await driver?.disconnect().catch(() => {}); + driver = undefined; + }); + + /** + * A caller's `Date` for a `Field.date` is read on the UTC clock — the same + * clock every other temporal canon in this driver folds through. Swapping + * `toDateOnly` to local components would make each of these name the day + * before or after, depending on which side of UTC the process sits, which is + * why fixing #11389 there was rejected. + */ + const CALLER_DATES: readonly [label: string, value: string, expected: string][] = [ + ['ISO date-only (UTC midnight)', `${DAY}T00:00:00.000Z`, DAY], + // Late-UTC-evening: local components read this as the NEXT day east of UTC. + ['late UTC evening', `${DAY}T23:30:00.000Z`, DAY], + // Early-UTC-morning: local components read this as the PREVIOUS day west of UTC. + ['early UTC morning', `${DAY}T00:30:00.000Z`, DAY], + ['year boundary', `${NEW_YEAR}T00:00:00.000Z`, NEW_YEAR], + ]; + + for (const tz of ZONE_MATRIX) { + for (const [label, iso, expected] of CALLER_DATES) { + it(`stores and reads back ${expected} for a ${label} Date under TZ=${tz}`, async () => { + await underProcessZone(tz, async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([ + { name: 'deal', fields: { close_date: { type: 'date' } } }, + ] as any); + + await driver.create('deal', { id: 'd1', close_date: new Date(iso) }, { bypassTenantAudit: true }); + + const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true }); + expect(row.close_date).toBe(expected); + + // The filter path takes the same helper, so it has to agree — a + // comparand read on a different clock than the stored value is the + // silent-empty-result shape ADR-0053 Phase 1 already paid for once. + const byDate = await driver.find( + 'deal', + { where: { close_date: new Date(iso) } }, + { bypassTenantAudit: true }, + ); + expect(byDate.map((r: any) => r.id)).toEqual(['d1']); + }); + }); + } + } +}); + +// ── The live matrix: one connection, one row set, the zone swept underneath ── + +/** + * The card's end-to-end table, executed: the SAME rows read back under each + * process zone. Writes use plain `YYYY-MM-DD` strings on purpose — an + * unambiguous write isolates the READ path, which is where #11389 lived. + */ +function declareZoneSweep(cell: DialectCell): void { + describe(`#11389 — Field.date is process-zone invariant on ${cell.label}`, () => { + const TABLE = 'os11389_date_zone'; + let driver: SqlDriver | undefined; + + const connect = async (): Promise => { + if (driver) return driver; + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { name: TABLE, fields: { label: { type: 'string' }, close_date: { type: 'date' } } }, + ] as any); + await driver.create(TABLE, { id: 'r1', label: 'day', close_date: DAY }, { bypassTenantAudit: true }); + await driver.create(TABLE, { id: 'r2', label: 'ny', close_date: NEW_YEAR }, { bypassTenantAudit: true }); + return driver; + }; + + // Torn down once, after the last case — the sweep deliberately shares ONE + // connection so the only variable across cells is the process zone. + afterAll(async () => { + await driver?.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver?.disconnect().catch(() => {}); + driver = undefined; + }); + + it('stored what the assertions read back — the control for the whole sweep', async () => { + const d = await connect(); + const raw: any = await d.execute(`select id, close_date from ${TABLE} order by id`); + const rows = raw?.rows ?? (Array.isArray(raw?.[0]) ? raw[0] : raw); + expect(rows.length, 'fixture is vacuous unless both rows are really there').toBe(2); + }); + + for (const tz of ZONE_MATRIX) { + it(`reads ${DAY} and ${NEW_YEAR} back unchanged under TZ=${tz}`, async () => { + const d = await connect(); + await underProcessZone(tz, async () => { + const day = await d.findOne(TABLE, { where: { id: 'r1' } }, { bypassTenantAudit: true }); + const ny = await d.findOne(TABLE, { where: { id: 'r2' } }, { bypassTenantAudit: true }); + + expect(day.close_date, `${cell.label} read the wrong calendar day under TZ=${tz}`).toBe(DAY); + // A one-day skew here changes the YEAR, which is the most legible + // form of the production report. + expect(ny.close_date, `${cell.label} read the wrong calendar day under TZ=${tz}`).toBe(NEW_YEAR); + + // `distinct()` returns raw builder output through `presentReadValue` + // rather than `formatOutput` — the second of `toDateOnly`'s two read + // consumers, and it must agree. + expect((await d.distinct(TABLE, 'close_date')).slice().sort()).toEqual([NEW_YEAR, DAY]); + + // The filter path against a stored calendar day. + const found = await d.find(TABLE, { where: { close_date: DAY } }, { bypassTenantAudit: true }); + expect(found.map((r: any) => r.id)).toEqual(['r1']); + }); + }); + } + }); +} + +declareDialectCell(PG_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep); +// MySQL is in the matrix because it is the dialect that PROVES the asymmetry: +// mysql2 materialises a DATE at local midnight too, exactly like pg, and is +// nevertheless correct today because `withUtcSession` already pins +// `connection.timezone: 'Z'` (#3942). Losing that pin would reproduce #11389 +// one dialect over, and this cell is what would say so. +declareDialectCell(MYSQL_CELL, 'date calendar-day zone invariance (#11389)', declareZoneSweep); + +// ── The raw wire form, on a live server ───────────────────────────────────── + +declareDialectCell(PG_CELL, 'date wire form (#11389)', (cell) => { + describe(`#11389 — what a ${cell.label} date column materialises as`, () => { + let driver: SqlDriver | undefined; + + afterEach(async () => { + await driver?.disconnect().catch(() => {}); + driver = undefined; + }); + + it('hands back strings for `date` and `date[]`, and keeps `timestamptz` an instant', async () => { + driver = new SqlDriver(cell.config()); + await underProcessZone('Asia/Shanghai', async () => { + const raw: any = await driver!.execute( + `select date '${DAY}' as d, + array[date '${DAY}', NULL, date '${NEW_YEAR}'] as ds, + timestamptz '${DAY}T00:00:00Z' as ts`, + ); + const row = (raw?.rows ?? raw)[0]; + + expect(typeof row.d, 'a pg `date` must not arrive as a JS Date').toBe('string'); + expect(row.d).toBe(DAY); + // SQL NULL survives as null; the year-boundary element is where a + // pre-fix skew changed the year. + expect(row.ds).toEqual([DAY, null, NEW_YEAR]); + // Untouched on purpose: an instant is exactly what a Date is for, and + // `Field.datetime` depends on it. + expect(row.ts instanceof Date).toBe(true); + expect((row.ts as Date).toISOString()).toBe(`${DAY}T00:00:00.000Z`); + }); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index fa767ee7c8..c35450ea64 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -4475,24 +4475,31 @@ export class SqlDriver implements IDataDriver { } : { ...knexConfig }; // host chose its own bound — respect it + // `!dialect` — sqlite, or a client with no connect-timeout knob — means + // there is no TIMEOUT to inject. It deliberately does not skip the session + // pins below: those answer a different question (what does a value MEAN on + // this connection), and the two lists are not the same list. Measured while + // fixing #11389: `redshift` speaks the pg wire protocol, and therefore + // needs the calendar-day pin, but carries no entry here — so a `return` + // placed at this point silently opted it out of a fix it needs. const dialect = SqlDriver.DIALECT_CONNECT_TIMEOUT[String(knexConfig.client ?? '')]; - if (!dialect) return bounded; // sqlite / unknown client — nothing to inject - - const conn = knexConfig.connection; - if (typeof conn === 'string') { - // The URL must move into the dialect's own URL slot so the timeout can - // ride alongside it. Verified for both dialects: the connection attempt - // still goes to the URL's host/port, `?sslmode=` is still honoured. - bounded.connection = { - [dialect.urlKey]: conn, - [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS, - }; - } else if (conn && typeof conn === 'object' && (conn as any)[dialect.key] === undefined) { - bounded.connection = { ...(conn as object), [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS }; + if (dialect) { + const conn = knexConfig.connection; + if (typeof conn === 'string') { + // The URL must move into the dialect's own URL slot so the timeout can + // ride alongside it. Verified for both dialects: the connection attempt + // still goes to the URL's host/port, `?sslmode=` is still honoured. + bounded.connection = { + [dialect.urlKey]: conn, + [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS, + }; + } else if (conn && typeof conn === 'object' && (conn as any)[dialect.key] === undefined) { + bounded.connection = { ...(conn as object), [dialect.key]: SqlDriver.DEFAULT_CONNECT_TIMEOUT_MS }; + } + // A function-valued `connection` (knex's per-acquire provider) is left + // alone: the host is building each connection itself and owns its timeouts. } - // A function-valued `connection` (knex's per-acquire provider) is left - // alone: the host is building each connection itself and owns its timeouts. - return SqlDriver.withUtcSession(bounded); + return SqlDriver.withPostgresCalendarDayAsText(SqlDriver.withUtcSession(bounded)); } /** @@ -4547,6 +4554,126 @@ export class SqlDriver implements IDataDriver { return out; } + /** + * knex client names that route to the `pg` npm driver, and therefore reach + * {@link withPostgresCalendarDayAsText}. Wider than {@link isPostgres} on + * purpose: that getter answers "which dialect's SQL do I emit", while this + * answers "which npm package parses the wire format" — `postgres` (knex's + * own alias), `cockroachdb` and `redshift` all speak the pg wire protocol + * with the same type OIDs. The hook is additionally guarded on the + * connection really exposing `setTypeParser`, so a client name that turns + * out not to be a `pg.Client` degrades to a no-op instead of throwing. + */ + private static readonly POSTGRES_WIRE_CLIENTS: ReadonlySet = new Set([ + 'pg', 'postgres', 'postgresql', 'cockroachdb', 'redshift', + ]); + + /** Postgres OID of `date` — a bare calendar day, no time and no zone. */ + private static readonly PG_OID_DATE = 1082; + /** Postgres OID of `date[]`. */ + private static readonly PG_OID_DATE_ARRAY = 1182; + /** + * Postgres OID of `text[]`. Borrowed, not used for its own type: pg's + * built-in `text[]` parser IS its array-literal splitter with an identity + * element transform, so applying it to a `date[]` literal yields the raw + * `YYYY-MM-DD` element strings — including `null` for a SQL NULL element, + * `[]` for an empty array, and nested arrays for the multi-dimensional case. + * Reusing it is what keeps this hook free of a hand-rolled array parser and + * free of any dependency on the `pg` module being importable from here. + */ + private static readonly PG_OID_TEXT_ARRAY = 1009; + + /** + * Keep a Postgres `date` a calendar-day STRING, never a JS `Date` (#11389). + * + * This is the Postgres counterpart of {@link withUtcSession}, and it exists + * for the same reason: the value a driver materialises must not depend on + * which machine's clock the Node process happens to be running. + * + * ## The measurement + * + * `node-postgres` parses OID 1082 with `new Date(y, m - 1, d)` — **local** + * midnight. `SqlDriver#toDateOnly` reads a `Date` with UTC components (its + * documented contract, see there), so on a process east of UTC the two + * disagree by a calendar day. Measured on PostgreSQL 16, one stored row + * `d = '2026-08-24'`, only the process `TZ` changed: + * + * | process TZ | pg materialises | `toDateOnly` returned | + * |---|---|---| + * | `UTC` | `2026-08-24T00:00:00.000Z` | `2026-08-24` | + * | `America/New_York` | `2026-08-24T04:00:00.000Z` | `2026-08-24` | + * | `Asia/Shanghai` | `2026-08-23T16:00:00.000Z` | **`2026-08-23`** | + * + * West of UTC the UTC components happen to name the right day, which is why + * the `Temporal Conformance (live PG + MySQL)` job — pinned at + * `TZ=America/New_York` — was green throughout. Only an east-of-UTC process + * zone can fail it, so the pin that guards this sweeps the process zone. + * + * ## Why the parser and not the reader + * + * The alternative — read local components in `toDateOnly` — was measured and + * rejected: that helper is shared by the READ path (`formatOutput`, + * `presentReadValue`), the WRITE path (`formatInput`) and the FILTER path + * (`coerceFilterValue`), and the `Date`s they receive are on different + * clocks. A caller's `new Date('2026-08-24')` is UTC midnight, so under + * `TZ=America/New_York` local components read it as `2026-08-23` — the same + * one-day error in the mirror direction, moved onto the write and filter + * paths. Fixing it at the parser leaves exactly one clock in play, because + * the driver then never produces a `Date` for a `date` column at all — which + * is already how SQLite behaves (TEXT round-trip) and, via + * {@link withUtcSession}'s `timezone: 'Z'`, how mysql2 behaves. + * + * `pool.afterCreate` is the hook rather than a `pg.types.setTypeParser` + * call because `setTypeParser` mutates the pg-types registry **process + * wide**, which would reach every other pg client in the host application. + * A parser registered on the connection is scoped to the pools this driver + * opened; a host's own `pg` clients keep the stock behaviour. `pg` is an + * optional peer dependency and is never imported here — `setTypeParser` / + * `getTypeParser` are read off the `pg.Client` knex hands the hook. + * + * `timestamptz` / `timestamp` are deliberately untouched: those are + * instants, a `Date` is the right materialisation for them, and + * `Field.datetime` depends on it. + * + * A host's existing `pool.afterCreate` is chained rather than replaced, + * exactly as in {@link withUtcSession}. + */ + private static withPostgresCalendarDayAsText( + knexConfig: Record, + ): Record { + if (!SqlDriver.POSTGRES_WIRE_CLIENTS.has(String(knexConfig.client ?? ''))) return knexConfig; + + const out: Record = { ...knexConfig }; + const pool = (out.pool ?? {}) as Record; + const hostAfterCreate = pool.afterCreate as + | ((conn: unknown, done: (err?: unknown) => void) => void) + | undefined; + out.pool = { + ...pool, + afterCreate(connection: any, done: (err?: unknown, conn?: unknown) => void) { + const chain = (): void => { + if (!hostAfterCreate) return done(undefined, connection); + hostAfterCreate(connection, (hostErr?: unknown) => done(hostErr, connection)); + }; + // Not a `pg.Client` after all (a stub, a future knex shape): leave the + // connection exactly as it was rather than failing the acquire. + if ( + typeof connection?.setTypeParser !== 'function' || + typeof connection?.getTypeParser !== 'function' + ) { + return chain(); + } + const parseTextArray = connection.getTypeParser(SqlDriver.PG_OID_TEXT_ARRAY, 'text'); + // The wire form of `date` IS `YYYY-MM-DD`; handing it back verbatim is + // both the fix and the whole parser. + connection.setTypeParser(SqlDriver.PG_OID_DATE, (text: string) => text); + connection.setTypeParser(SqlDriver.PG_OID_DATE_ARRAY, parseTextArray); + chain(); + }, + }; + return out; + } + /** * Per-request SQL query timing (perf-tuning mode). Correlates knex's * `query` → `query-response` / `query-error` events by `__knexQueryUid` and @@ -10666,12 +10793,43 @@ export class SqlDriver implements IDataDriver { /** * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD` - * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC - * calendar day; a string keeps its leading date and drops any time - * component. Anything else (and `null`/`undefined`) passes through - * unchanged. This is the single source of truth for date-only truncation, - * shared by the filter (`coerceFilterValue`), write (`formatInput`) and - * read (`formatOutput`) paths so all three agree on what a date *is*. + * calendar-day string (ADR-0053 Phase 1). A string keeps its leading date + * and drops any time component. Anything else (and `null`/`undefined`) + * passes through unchanged. This is the single source of truth for date-only + * truncation, shared by the filter (`coerceFilterValue`), write + * (`formatInput`) and read (`formatOutput`, `presentReadValue`) paths so all + * of them agree on what a date *is*. + * + * ## Which clock a `Date` argument is on — the contract, not an accident + * + * **A `Date` reaching this helper is read on the UTC clock** + * (`getUTCFullYear` / `getUTCMonth` / `getUTCDate`), and callers must hand it + * one whose UTC components name the intended calendar day. That is the same + * clock every other temporal canon in this driver folds through + * (`storageDatetimeValue`, `canonicalTimeOfDay`, `nowColumnDefault`'s + * `timezone('utc', now())::date`), so a `Field.date` means one day on every + * host regardless of the process `TZ`. + * + * It is written down because the three call paths do NOT hand it `Date`s + * from one source, and #11389 was the reading of that. Measured (PostgreSQL + * 16, one process, only `TZ` changed): + * + * | argument, for calendar day 2026-08-24 | `TZ=Asia/Shanghai` | `TZ=America/New_York` | + * |---|---|---| + * | caller's `new Date('2026-08-24')` — write/filter | `…T00:00Z` | `…T00:00Z` | + * | caller's `new Date(2026, 7, 24)` — write/filter | `…T16:00Z` (prev. day UTC) | `…T04:00Z` | + * | what `pg` used to hand the READ path for a `date` column | `…T16:00Z` (prev. day UTC) | `…T04:00Z` | + * + * So no single clock is right for every `Date` that could arrive: reading + * local components would fix the third row and break the first (a + * `new Date('2026-08-24')` comparand becomes `2026-08-23` west of UTC — the + * identical one-day error, moved onto the write and filter paths). The read + * path was fixed at its source instead — see + * {@link withPostgresCalendarDayAsText} — so on every dialect a `date` + * column now arrives here as TEXT and no driver-materialised `Date` reaches + * this helper at all. ⛔ Do not "repair" a residual date skew by switching + * the getters below to their local twins; that reintroduces #11389 in the + * mirror direction, and `sql-driver-11389-date-tz-skew.test.ts` pins it. */ protected toDateOnly(value: any): any { if (value == null) return value;