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/Shanghai | apply_date = 2026-08-24 | "apply_date": "2026-08-23" |
TZ=UTC | apply_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.
Summary
On PostgreSQL, every
Field.datevalue read back throughSqlDriveris 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-postgresmaterialises adatecolumn (OID 1082) as a JSDateat local midnight.SqlDriver#toDateOnly()then formats thatDateusing UTC components:Local midnight in UTC+8 is
T-8hin UTC, i.e. the previous calendar day, so the UTC components name the wrong day.toDateOnlyis the shared definition used byformatOutput(read),formatInput(write) andcoerceFilterValue(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
End-to-end on a real app, same rows, same browser, only the app process
TZchanged:psql)GET /api/v1/data/:object/:idTZ=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
afterUpdatehook that copiesrecord.required_dateinto 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'srequired_dateis2026-08-26and the child's copiedplan_end_dateis2026-08-25.Suggested fix
Read the calendar day from a
Datethe same way it was produced — with local components (getFullYear/getMonth/getDate) — or, more robustly, register apgtype parser for OID 1082 (and 1182,date[]) that returns the rawYYYY-MM-DDstring so adatenever becomes aDatein 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,
toDateOnlyshould be unambiguous about which clock aDateargument 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=UTCand keep the business timezone inOS_LOCALIZATION_TIMEZONE. Verified: the same rows then read back correctly, and autonumber date segments (which followOS_LOCALIZATION_TIMEZONE, not the process clock) are unaffected. It does change any app code that formats timestamps from process-localDatecomponents, so it is a mitigation rather than a fix.