Skip to content

Commit 42e3b01

Browse files
os-zhuangclaude
andauthored
fix(driver-sql): Field.date NOW() default reads the UTC calendar day on PG/MySQL (#4022) (#4026)
Two follow-ups from the #3994 line: 1. Field.date + defaultValue NOW() emitted a bare CURRENT_TIMESTAMP default, which resolves the calendar day in the SERVER's timezone on Postgres — measured: a UTC-12 server recorded yesterday's date; an Asia/Shanghai server records tomorrow for every default after 16:00 UTC. MySQL 8.0 additionally rejects the bare default on a DATE column outright (MariaDB is merely permissive, and the driver's UTC-pinned session masked the semantic half there). nowColumnDefault now emits a UTC expression default on both dialects — timezone('utc', now())::date on Postgres, (cast(utc_timestamp() as date)) on MySQL — the #3994 D-C3 construction one type over. Defaults only govern newly created columns (D-B3 policy). Live tests pin the expression and a defaulted-insert round-trip. 2. marketplace-install-local-seed-lookup.test.ts: 30s → 120s timeout. The test cold-imports the real @objectstack/runtime on purpose; under a fully parallel turbo run that import stalls on core contention and the 30s cap produced recurring false reds (observed 3x, passes standalone). A genuine hang still fails, just later. Closes #4022 Claude-Session: https://claude.ai/code/session_01TPxNwPnjcn599ujXpU3ibJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c35cfdb commit 42e3b01

5 files changed

Lines changed: 131 additions & 2 deletions

File tree

.changeset/date-now-default-utc.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): `Field.date` + `defaultValue: 'NOW()'` records the UTC calendar day on Postgres/MySQL (#4022)
6+
7+
The bare `CURRENT_TIMESTAMP` default resolved the calendar day in the SERVER's
8+
timezone on Postgres — measured: a UTC-12 server recorded yesterday; an
9+
Asia/Shanghai server records tomorrow for every default after 16:00 UTC — and
10+
MySQL 8.0 rejects it on a DATE column outright (MariaDB is merely permissive,
11+
and the driver's UTC-pinned session masked the semantic half there).
12+
`nowColumnDefault` now emits a UTC expression default on both dialects, the
13+
#3994 D-C3 construction one type over. Defaults only govern newly created
14+
columns; existing columns keep their legacy default, per the standing D-B3
15+
policy.

docs/adr/0053-date-and-datetime-semantics.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,15 @@ canonical `.000` trim on SQLite, `timezone('utc', now())::time(3)` on Postgres,
643643
`(cast(utc_timestamp(3) as time(3)))` on MySQL (8.0.13+/MariaDB 10.2+ for the
644644
expression-default syntax) — all reading the UTC clock.
645645

646+
The same construction closes the `Field.date` half of the gap (#4022):
647+
`nowColumnDefault('date')` emits `timezone('utc', now())::date` on Postgres and
648+
`(cast(utc_timestamp() as date))` on MySQL. Measured: the bare
649+
`CURRENT_TIMESTAMP` default recorded YESTERDAY's calendar day on a UTC-12
650+
Postgres server, and MySQL 8.0 rejects it on a DATE column outright (the
651+
driver's UTC-pinned session masked the semantic half on MariaDB). Legacy
652+
columns keep their old default — defaults only govern newly created columns,
653+
the standing policy since D-B3.
654+
646655
The D-B3 caveat applies unchanged: the backfill converges *shapes*; a wall
647656
clock the old path never recorded correctly (a pg `Date` bind serialised in the
648657
host's zone) is not recoverable. Regression cover:

packages/cloud-connection/src/marketplace-install-local-seed-lookup.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,11 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMo
192192
describe('marketplace install — seed lookup resolution', () => {
193193
// Generous timeout: the install handler dynamically imports the real
194194
// @objectstack/runtime (unmocked on purpose), and that cold import alone
195-
// can eat several seconds on a fresh CI runner.
196-
it('writes target record ids (never raw externalId strings) for package objects unknown to the metadata service', { timeout: 30_000 }, async () => {
195+
// can eat several seconds on a fresh CI runner — and multiples of that
196+
// under a fully parallel turbo run, where this test competes with every
197+
// other package's suite for cores. 30s was observed flaking exactly that
198+
// way (an import stall, not a hang); a genuine hang still fails, just later.
199+
it('writes target record ids (never raw externalId strings) for package objects unknown to the metadata service', { timeout: 120_000 }, async () => {
197200
const { engine, store, registry } = makeEngine();
198201
const rawApp = makeRawApp();
199202
const { ctx, fire } = makeCtx(rawApp, {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #4022 — `Field.date` + `defaultValue: 'NOW()'` on live Postgres and MySQL.
5+
*
6+
* The bare `CURRENT_TIMESTAMP` default `knex.fn.now()` emits resolves the
7+
* calendar day in the SERVER's timezone on Postgres — measured: with the server
8+
* at UTC-12, a defaulted insert on UTC's 2026-07-30 stored `2026-07-29`. On an
9+
* Asia/Shanghai production server every default after 16:00 UTC records
10+
* TOMORROW. MySQL 8.0 rejects the bare default on a DATE column outright
11+
* (MariaDB is merely permissive, and the driver's UTC-pinned session masked
12+
* the semantic half there).
13+
*
14+
* The fix is an expression default reading the UTC clock (`nowColumnDefault`),
15+
* the #3994 D-C3 construction one type over. The wrong-day scenario cannot be
16+
* asserted deterministically in CI (it depends on the time of day relative to
17+
* the server's offset), so these tests pin the two things that CAN be:
18+
* the DDL builds — which on MySQL 8.0 is itself the compatibility fix — with a
19+
* default expression that spells `utc`, and a defaulted insert round-trips a
20+
* valid calendar day.
21+
*/
22+
23+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
24+
import { SqlDriver } from '../src/index.js';
25+
26+
const PG_URL = process.env.OS_TEST_POSTGRES_URL;
27+
const MY_URL = process.env.OS_TEST_MYSQL_URL;
28+
const TABLE = 'os4022_probe';
29+
30+
const SHAPE = {
31+
name: TABLE,
32+
fields: {
33+
label: { type: 'string' },
34+
due_on: { type: 'date', defaultValue: 'NOW()' },
35+
},
36+
} as any;
37+
38+
function suite(dialect: 'pg' | 'mysql', url: string | undefined) {
39+
describe.skipIf(!url)(`Field.date NOW() default on live ${dialect} (#4022)`, () => {
40+
let driver: SqlDriver;
41+
42+
beforeEach(async () => {
43+
driver = new SqlDriver(
44+
dialect === 'pg'
45+
? { client: 'pg', connection: url }
46+
: { client: 'mysql2', connection: url },
47+
);
48+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
49+
await driver.initObjects([SHAPE]);
50+
});
51+
52+
afterEach(async () => {
53+
await driver.execute(`drop table if exists ${TABLE}`).catch(() => {});
54+
await driver.disconnect();
55+
});
56+
57+
it('builds with a UTC expression default, not a bare CURRENT_TIMESTAMP', async () => {
58+
// On MySQL 8.0 reaching this line at all is the compatibility half of the
59+
// fix — the bare default fails CREATE TABLE there.
60+
const res: any = await driver.execute(
61+
dialect === 'pg'
62+
? `select column_default as d from information_schema.columns
63+
where table_name = '${TABLE}' and column_name = 'due_on'`
64+
: `select column_default as d from information_schema.columns
65+
where table_schema = database() and table_name = ? and column_name = 'due_on'`,
66+
dialect === 'pg' ? [] : [TABLE],
67+
);
68+
const rows = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : (res?.rows ?? res);
69+
const def = String((rows[0] as any).d ?? (rows[0] as any).D ?? '').toLowerCase();
70+
expect(def).toContain('utc');
71+
expect(def).not.toBe('current_timestamp');
72+
});
73+
74+
it('a defaulted insert round-trips a valid canonical calendar day', async () => {
75+
await driver.create(TABLE, { id: 'x', label: 'x' }, { bypassTenantAudit: true });
76+
const row: any = await driver.findOne(TABLE, 'x', { bypassTenantAudit: true });
77+
expect(String(row.due_on)).toMatch(/^\d{4}-\d{2}-\d{2}$/);
78+
// The UTC calendar day only ever differs from this probe's own UTC clock
79+
// across a midnight boundary — accept today or the day either side rather
80+
// than flake at 00:00, while still catching a ±12h zone leak on any run
81+
// that is not within a day-boundary race.
82+
const utcToday = Date.parse(new Date().toISOString().slice(0, 10));
83+
const stored = Date.parse(String(row.due_on));
84+
expect(Math.abs(stored - utcToday)).toBeLessThanOrEqual(24 * 3600 * 1000);
85+
});
86+
});
87+
}
88+
89+
suite('pg', PG_URL);
90+
suite('mysql', MY_URL);

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5109,6 +5109,18 @@ export class SqlDriver implements IDataDriver {
51095109
if (this.isMysql) return this.knex.raw('(cast(utc_timestamp(3) as time(3)))');
51105110
if (this.isPostgres) return this.knex.raw("(timezone('utc', now())::time(3))");
51115111
}
5112+
// `date` has the same two problems one type over (#4022): a bare
5113+
// CURRENT_TIMESTAMP default resolves the calendar day in the SERVER's
5114+
// timezone on Postgres (measured: a UTC-12 server records YESTERDAY),
5115+
// and MySQL 8.0 rejects it on a DATE column outright (MariaDB is merely
5116+
// permissive; the driver's UTC-pinned session masked the semantic half
5117+
// there). Same fix as `time`: an expression default reading the UTC
5118+
// clock, which is also what `formatInput`'s NOW() safety-net and the
5119+
// SQLite branch below store.
5120+
if (type === 'date') {
5121+
if (this.isMysql) return this.knex.raw('(cast(utc_timestamp() as date))');
5122+
if (this.isPostgres) return this.knex.raw("(timezone('utc', now())::date)");
5123+
}
51125124
return this.knex.fn.now();
51135125
}
51145126
switch (type) {

0 commit comments

Comments
 (0)