Skip to content

driver-sql: every Field.date read from PostgreSQL is one day early when the process TZ is east of UTC — toDateOnly() reads UTC components off a local-midnight Date #11389

Description

@baozhoutao

Summary

On PostgreSQL, every Field.date value read back through SqlDriver is shifted one calendar day earlier whenever the Node process runs in a timezone east of UTC (e.g. TZ=Asia/Shanghai). The value stored in the database is correct; the read path corrupts it.

We hit this in production (an MES app whose app container runs TZ=Asia/Shanghai, per the usual "container clock = business clock" deployment): users create a record today and every date field on it renders as yesterday, on both the Console and a custom H5 client. It is not a rendering bug — the REST payload already carries the wrong day.

Version: @objectstack/driver-sql@17.2.0 (current latest), pg@8.16.3, PostgreSQL 16.

Root cause

node-postgres materialises a date column (OID 1082) as a JS Date at local midnight. SqlDriver#toDateOnly() then formats that Date using UTC components:

toDateOnly(value){if(valueinstanceofDate){consty=value.getUTCFullYear();constm=String(value.getUTCMonth()+1).padStart(2,"0");constd=String(value.getUTCDate()).padStart(2,"0");return`${y}-${m}-${d}`;}
...
}

Local midnight in UTC+8 is T-8h in UTC, i.e. the previous calendar day, so the UTC components name the wrong day. toDateOnly is the shared definition used by formatOutput (read), formatInput (write) and coerceFilterValue (filter), so the same skew reaches list payloads, single-record reads, and date filter comparands.

On SQLite the value round-trips as TEXT and never becomes a Date, which is why the bug is Postgres-only, and why it is invisible in dev.

Minimal reproduction

// TZ=Asia/Shanghai node repro.jsconst{ Client }=require('pg');(async()=>{constc=newClient({connectionString: process.env.PG_URL});awaitc.connect();awaitc.query('drop table if exists t_date_repro');awaitc.query('create table t_date_repro (id int, d date)');awaitc.query("insert into t_date_repro values (1, '2026-08-24')");constv=(awaitc.query('select d from t_date_repro')).rows[0].d;// exactly what SqlDriver#toDateOnly() does for a Date instance:constout=`${v.getUTCFullYear()}-${String(v.getUTCMonth()+1).padStart(2,'0')}-${String(v.getUTCDate()).padStart(2,'0')}`;console.log('pg returns :',v.toISOString());console.log('toDateOnly() => :',out);awaitc.end();})();
--- TZ=Asia/Shanghai ---
pg returns : 2026-08-23T16:00:00.000Z
toDateOnly() => : 2026-08-23 <-- stored value was 2026-08-24
--- TZ=UTC ---
pg returns : 2026-08-24T00:00:00.000Z
toDateOnly() => : 2026-08-24 <-- correct

End-to-end on a real app, same rows, same browser, only the app process TZ changed:

DB (psql)REST GET /api/v1/data/:object/:id
TZ=Asia/Shanghaiapply_date = 2026-08-24"apply_date": "2026-08-23"
TZ=UTCapply_date = 2026-08-24"apply_date": "2026-08-24"

Why this is worse than a display bug

The skewed read feeds writes. An afterUpdate hook that copies record.required_date into a child record persists the shifted value, so the wrong day is written back into the database and survives any later fix to the read path. We have production rows where the parent's required_date is 2026-08-26 and the child's copied plan_end_date is 2026-08-25.

Suggested fix

Read the calendar day from a Date the same way it was produced — with local components (getFullYear / getMonth / getDate) — or, more robustly, register a pg type parser for OID 1082 (and 1182, date[]) that returns the raw YYYY-MM-DD string so a date never becomes a Date in the first place. The latter also removes the DST/edge-case surface entirely and matches what the SQLite path already does.

Whichever direction is chosen, toDateOnly should be unambiguous about which clock a Date argument is expected to be on, since it is shared by the read, write and filter paths.

Workaround for app teams

Run the app process with TZ=UTC and keep the business timezone in OS_LOCALIZATION_TIMEZONE. Verified: the same rows then read back correctly, and autonumber date segments (which follow OS_LOCALIZATION_TIMEZONE, not the process clock) are unaffected. It does change any app code that formats timestamps from process-local Date components, so it is a mitigation rather than a fix.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions