Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/32851-date-on-postgres-instant-column.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/driver-sql': patch
---

fix(driver-sql): a `date` field still on a Postgres `timestamptz` column reads, writes, filters and buckets the same day

Clause-②: no
Metadata-protocol: none

Publishing a field changed from `datetime` to `date` keeps its `timestamptz` column on Postgres. The driver then wrote a bare day into that column, and Postgres stored it as midnight in the server's time zone; the read folds the stored instant by its UTC day, so with the server ahead of UTC (`Asia/Shanghai`) a date written as `2026-11-12` read back `2026-11-11`. A filter by the day and the date bucket also read the column in the server's zone, so the four doors gave three answers. SQLite and MySQL were not affected.

Every door now uses UTC, the zone the read already used: the write binds the day's UTC midnight, and a filter and a date bucket read the column as its UTC calendar day. The schema sync records which `date` fields sit on a `timestamptz` column from the column catalog it already reads. Nothing a `datetime` field reads changes, the column type is not changed, and no stored value is rewritten. A day written before this fix on such a server keeps the earlier day it was stored as.
98 changes: 98 additions & 0 deletions packages/drivers/driver-sql/src/date-on-instant-column.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#32851] A `Field.date` whose Postgres column is still an INSTANT column.
*
* `createColumn` gives a `date` field a `date` column, and the additive sync
* never revisits an existing column's type. So a field retyped `datetime` →
* `date` and published keeps the `timestamptz` column its `datetime`
* declaration built, and the driver's `date` path, written for a `date`
* column, met a column that holds instants:
*
* - the WRITE bound the bare day `'2026-11-12'`, which Postgres reads into
* `timestamptz` as midnight in the SESSION's zone, so on a server at
* `Asia/Shanghai` it stored `2026-11-11T16:00:00Z`;
* - the READ folds the `Date` node-pg hands back by its UTC components
* (ADR-0053; `temporalStorageForm`), so that row read `2026-11-11`;
* - a FILTER bound the same bare day, cast in the session zone again, so
* `due = '2026-11-12'` matched the row that READ `2026-11-11` and missed a
* row written as a `datetime` that read `2026-11-12`;
* - the DATE BUCKET cast the column with `::date`, also in the session
* zone, and put both of those rows on `2026-11-12`.
*
* Four doors, three answers, no error. Measured on live Postgres 16 with the
* server at `Asia/Shanghai` and the process at `America/New_York`.
*
* The fix pins every door to ONE zone, UTC, the zone the read already uses,
* so nothing a `datetime` field reads moves and no stored value is rewritten:
*
* - the write binds the day as the instant of its UTC midnight
* ({@link utcMidnightOfDay});
* - a filter and a date bucket read the column as its UTC calendar day
* ({@link utcCalendarDaySql}), so they compare and group the day the read
* presents, for every stored value, including one written while the field
* was a `datetime`.
*
* The column is NOT narrowed to `date` here: that rewrites every stored value
* (it drops the time of day a `datetime` wrote), which is a data change, not a
* read rule.
*
* MySQL needs none of this: its session is pinned to UTC (`withUtcSession`)
* and `DATETIME` is zone-naive, so a bare day IS UTC midnight on every door.
* SQLite stores text. Only Postgres records the registry this module reads.
*/

/**
* The registry: per table, the `Field.date` fields whose physical Postgres
* column is a `timestamp` type. Recorded by the schema sync from the column
* catalog it already reads, and EMPTY until a sync has looked — a driver that
* never synced the table (deferred DDL, a guest datasource) keeps the plain
* `date` path it had before.
*/
export type DateFieldsOnInstantColumn = Record<string, Set<string>>;

/**
* Whether `field` of `table` is a declared `Field.date` that the sync found on
* an instant column. Both registries are asked: a field retyped away from
* `date` since the last sync leaves the date registry first.
*/
export function isDateOnInstantColumn(
host: { readonly dateFields: Record<string, Set<string>>; readonly dateFieldsOnInstantColumn: DateFieldsOnInstantColumn },
table: string | null | undefined,
field: string,
): boolean {
if (!table) return false;
return host.dateFieldsOnInstantColumn[table]?.has(field) === true && host.dateFields[table]?.has(field) === true;
}

/**
* Whether a Postgres catalog type (as knex `columnInfo()` reports it:
* `timestamp with time zone`, `timestamp without time zone`) holds instants.
* Only `timestamptz` is recorded: it is the column this driver builds for a
* `datetime` field, and the one whose bare-day cast reads the session zone.
*/
export function isPostgresInstantColumnType(type: unknown): boolean {
return String(type ?? '').trim().toLowerCase() === 'timestamp with time zone';
}

/** The fixed-width calendar day `temporalStorageForm(…, 'date')` answers. */
const CALENDAR_DAY = /^\d{4}-\d{2}-\d{2}$/;

/**
* The write binding of a calendar day into an instant column: the instant of
* its UTC midnight, zone explicit, so the server's zone takes no part. Any
* other shape (a value the day fold could not read) is handed back unchanged
* for the server to refuse as it did before.
*/
export function utcMidnightOfDay(day: unknown): unknown {
return typeof day === 'string' && CALENDAR_DAY.test(day) ? `${day}T00:00:00.000Z` : day;
}

/**
* The SQL that reads an instant column as its UTC calendar day — the day the
* read door presents. `columnSql` is an identifier placeholder (`??`) or an
* already-quoted column reference.
*/
export function utcCalendarDaySql(columnSql: string): string {
return `((${columnSql}) AT TIME ZONE 'UTC')::date`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#32851] A field published `datetime` → `date` keeps its `timestamptz`
* column on Postgres, and the `date` path read that column in two zones.
*
* Measured on live Postgres 16 before the fix, server at `Asia/Shanghai`,
* process at `America/New_York` (the `Temporal Conformance (live PG + MySQL)`
* job's pair):
*
* - `create({ due: '2026-11-12' })` answered, and read back, `2026-11-11`:
* the bare day was stored as midnight in the SERVER's zone
* (`2026-11-11T16:00:00Z`) and read back by its UTC components;
* - `due = '2026-11-12'` matched that row (the comparand was cast in the
* session zone too) and missed the row written as the instant
* `2026-11-12T20:00:00Z` while the field was a `datetime`, which READ
* `2026-11-12`;
* - the day bucket put the second row on `2026-11-13` (`::date` in the
* session zone).
*
* The fix reads every door in UTC, the zone the read already used: the write
* binds the day's UTC midnight, and the filter and the bucket read the column
* as its UTC calendar day. See `date-on-instant-column.ts`.
*
* Every cell writes and reads the same rows: SQLite and MySQL never had the
* defect (SQLite stores text; MySQL's session is UTC and `DATETIME` is
* zone-naive) and must still answer the same day. The row written while the
* field was a `datetime` is asserted through the filter and the bucket on
* Postgres only: on SQLite and MySQL a bare-day comparand does not match a
* stored instant with a time of day, the same on the pre-fix tree, and that is
* not what this card changes.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SqlDriver } from './sql-driver.js';
import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js';

const MATRIX = 'date on an instant column (#32851)';
const TABLE = 'pub32851_due';

/** The live table as it was created: `due` a `datetime`. */
const V1 = { name: TABLE, fields: { label: { type: 'text' }, due: { type: 'datetime' }, seen: { type: 'datetime' } } };
/** The published change: `due` becomes a `date`; `seen` stays a `datetime`. */
const V2 = { name: TABLE, fields: { label: { type: 'text' }, due: { type: 'date' }, seen: { type: 'datetime' } } };

/** 20:00Z is 04:00 the NEXT day at `Asia/Shanghai`: a session-zone day discriminates. */
const DATETIME_ERA = '2026-11-12T20:00:00.000Z';
const DAY = '2026-11-12';
const SEEN = '2026-11-12T23:30:00.000Z';

const rowsOf = (res: unknown): Record<string, unknown>[] =>
(Array.isArray(res) ? res : ((res as { rows?: unknown[] })?.rows ?? [])) as Record<string, unknown>[];

function measure(cell: DialectCell): void {
describe(`a \`datetime\` field published as \`date\` — ${cell.label} (#32851)`, () => {
const pg = cell.id === 'pg';
let driver: SqlDriver;
let columnType = '';
let serverOffsetSeconds = 0;
const ids: Record<'era' | 'created' | 'updated', string> = { era: '', created: '', updated: '' };
let createdAnswer: Record<string, unknown> = {};

const labelsOf = (rows: readonly Record<string, unknown>[]) => rows.map((r) => String(r.label)).sort();

// Live: a connect, the two syncs and the publish's column half (#14213).
beforeAll(async () => {
driver = new SqlDriver(cell.config());
await driver.connect();
await driver.getKnex().schema.dropTableIfExists(TABLE);

await driver.syncSchema(TABLE, V1);
ids.era = String((await driver.create(TABLE, { label: 'era', due: DATETIME_ERA })).id);
ids.updated = String((await driver.create(TABLE, { label: 'updated', due: DATETIME_ERA })).id);

// The publish: its storage sync, then the column half it asks for.
await driver.syncSchema(TABLE, V2);
await driver.applyPublishedColumnChanges(V2);

columnType = String(((await driver.getKnex()(TABLE).columnInfo()) as any).due.type).toLowerCase();
if (pg) {
const res = await driver.execute(`select extract(timezone from now())::int as off`);
serverOffsetSeconds = Number(rowsOf(res)[0]?.off);
}

createdAnswer = (await driver.create(TABLE, { label: 'created', due: DAY, seen: SEEN })) as Record<string, unknown>;
ids.created = String(createdAnswer.id);
await driver.update(TABLE, ids.updated, { due: DAY });
}, 60_000);

afterAll(async () => {
await driver?.getKnex().schema.dropTableIfExists(TABLE).catch(() => {});
await driver?.disconnect().catch(() => {});
}, 60_000);

it.runIf(pg)('the fixture is real: the column is still `timestamptz` and the server zone is ahead of UTC', () => {
expect(columnType).toBe('timestamp with time zone');
// The defect needs a session zone east of UTC; CI points the server at
// `Asia/Shanghai`. A server at UTC would pass every assertion below on
// the pre-fix tree.
expect(serverOffsetSeconds, 'point the Postgres server at a zone ahead of UTC (e.g. Asia/Shanghai)').toBeGreaterThan(0);
});

it('a day written after the publish is answered and read back as that day', async () => {
expect(createdAnswer.due).toBe(DAY);
const created: any = await driver.findOne(TABLE, { where: { id: ids.created } });
expect(created.due).toBe(DAY);
const updated: any = await driver.findOne(TABLE, { where: { id: ids.updated } });
expect(updated.due).toBe(DAY);
});

it('a filter by that day finds every row written as that day', async () => {
const eq = await driver.find(TABLE, { where: { due: DAY } });
expect(labelsOf(eq as any[])).toEqual(expect.arrayContaining(['created', 'updated']));
const range = await driver.find(TABLE, { where: { due: { $gte: DAY, $lte: DAY } } });
expect(labelsOf(range as any[])).toEqual(expect.arrayContaining(['created', 'updated']));
const before = await driver.find(TABLE, { where: { due: { $lt: DAY } } });
expect(labelsOf(before as any[])).not.toContain('created');
});

it('a `datetime` field beside it still reads and filters as its instant', async () => {
const row: any = await driver.findOne(TABLE, { where: { id: ids.created } });
expect(row.seen).toBe(SEEN);
const hit = await driver.find(TABLE, { where: { seen: SEEN } });
expect(labelsOf(hit as any[])).toEqual(['created']);
});

it.runIf(pg)('the row written as a `datetime` reads, filters and buckets as one day', async () => {
const era: any = await driver.findOne(TABLE, { where: { id: ids.era } });
expect(era.due).toBe(DAY);

const eq = await driver.find(TABLE, { where: { due: DAY } });
expect(labelsOf(eq as any[])).toEqual(['created', 'era', 'updated']);

const grouped = await driver.aggregate(TABLE, {
groupBy: [{ field: 'due', dateGranularity: 'day' }],
aggregations: [{ function: 'count', alias: 'n' }],
});
expect((grouped as any[]).map((g) => [g.due, Number(g.n)])).toEqual([[DAY, 3]]);
});

it.runIf(pg)('the outside-the-builder doors (analytics native SQL) answer the same day', async () => {
const column = driver.temporalFilterColumnSql(TABLE, 'due', '"due"');
const comparand = driver.temporalFilterValue(TABLE, 'due', DAY);
const hit = rowsOf(await driver.execute(`select label from "${TABLE}" where ${column} = ? order by label`, [comparand]));
expect(hit.map((r) => r.label)).toEqual(['created', 'era', 'updated']);

const expr = driver.dateBucketSql(TABLE, 'due', 'day');
const echoed = rowsOf(await driver.execute(`select ${expr} as b, count(*) as n from "${TABLE}" group by ${expr}`));
expect(echoed.map((r) => [r.b, Number(r.n)])).toEqual([[DAY, 3]]);
});
});
}

for (const cell of DIALECT_CELLS) {
declareDialectCell(cell, MATRIX, measure);
}
11 changes: 9 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver-date-bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
*/

import type { SqlDriver } from './sql-driver.js';
import { isDateOnInstantColumn, utcCalendarDaySql } from './date-on-instant-column.js';

/**
* The `SqlDriver` members the function below reads — every one of them, and
Expand All @@ -50,6 +51,8 @@ import type { SqlDriver } from './sql-driver.js';
* the module header for which members are getters.
*/
export interface DateBucketHost {
readonly dateFields: SqlDriver['dateFields'];
readonly dateFieldsOnInstantColumn: SqlDriver['dateFieldsOnInstantColumn'];
readonly dateGranularityCapabilities: SqlDriver['dateGranularityCapabilities'];
readonly isMysql: SqlDriver['isMysql'];
readonly isOracle: SqlDriver['isOracle'];
Expand All @@ -60,7 +63,7 @@ export interface DateBucketHost {
}

/** The body of `SqlDriver#buildDateBucketExpr`; the method's docblock there is its contract. */
export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateGranularityCapabilities' | 'isMysql' | 'isOracle' | 'isPostgres' | 'isSqlite' | 'sqliteTemporalArg' | 'temporalFieldKind'>, field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?: string | null, column: string = field): { sql: string; bindings: any[] } | null {
export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateFields' | 'dateFieldsOnInstantColumn' | 'dateGranularityCapabilities' | 'isMysql' | 'isOracle' | 'isPostgres' | 'isSqlite' | 'sqliteTemporalArg' | 'temporalFieldKind'>, field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year', table?: string | null, column: string = field): { sql: string; bindings: any[] } | null {
if (!this.dateGranularityCapabilities[granularity]) return null;

// `field` is the FIELD — the registries (`temporalFieldKind`, the SQLite
Expand Down Expand Up @@ -89,7 +92,11 @@ export function buildDateBucketExpr(this: Pick<DateBucketHost, 'dateGranularityC
}

if (this.isPostgres) {
const arg = calendarDay ? `(??)::date::timestamp` : `(??)::timestamptz AT TIME ZONE 'UTC'`;
// [#32851] A `Field.date` still on a `timestamptz` column (a `datetime` →
// `date` publish) is bucketed as its UTC day, the day the read presents;
// a bare `::date` would read it in the session's zone.
const dayArg = isDateOnInstantColumn(this, table, field) ? `${utcCalendarDaySql('??')}::timestamp` : `(??)::date::timestamp`;
const arg = calendarDay ? dayArg : `(??)::timestamptz AT TIME ZONE 'UTC'`;
switch (granularity) {
case 'year': return { sql: `to_char(${arg}, 'YYYY')`, bindings: [field] };
case 'month': return { sql: `to_char(${arg}, 'YYYY-MM')`, bindings: [field] };
Expand Down
14 changes: 12 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver-filter-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import type { OracleUuidBindingRole } from './sql-driver-oracle-dialect.js';
import { jsonMembershipUnsupportedOnOracleError } from './sql-driver-predicate-emitters.js';
import type { ReadPresentationKind } from './sql-driver-top-level.js';
import type { SqlDriver } from './sql-driver.js';
import { isDateOnInstantColumn, utcCalendarDaySql } from './date-on-instant-column.js';

/**
* The `SqlDriver` members the bodies of {@link FilterHelpersFacet} read — every one of
Expand Down Expand Up @@ -96,6 +97,7 @@ export interface FilterHelpersHost {
coercionKey: SqlDriver['coercionKey'];
compileFilters: SqlDriver['compileFilters'];
readonly dateFields: SqlDriver['dateFields'];
readonly dateFieldsOnInstantColumn: SqlDriver['dateFieldsOnInstantColumn'];
readonly datetimeFields: SqlDriver['datetimeFields'];
filterColumnExpr: SqlDriver['filterColumnExpr'];
isJsonColumn: SqlDriver['isJsonColumn'];
Expand Down Expand Up @@ -357,7 +359,13 @@ export class FilterHelpersFacet {
}

/** The body of `SqlDriver#filterColumnExpr`; the method's docblock there is its contract. */
static filterColumnExpr(this: Pick<FilterHelpersHost, 'needsLegacyDatetimeRepair' | 'needsLegacyTimeRepair' | 'sqliteCanonicalDatetimeSql' | 'sqliteCanonicalTimeSql'>, table: string | null | undefined, field: string, column: string): { sql: string; bindings: any[] } | null {
static filterColumnExpr(this: Pick<FilterHelpersHost, 'dateFields' | 'dateFieldsOnInstantColumn' | 'needsLegacyDatetimeRepair' | 'needsLegacyTimeRepair' | 'sqliteCanonicalDatetimeSql' | 'sqliteCanonicalTimeSql'>, table: string | null | undefined, field: string, column: string): { sql: string; bindings: any[] } | null {
// [#32851] A `Field.date` on a Postgres `timestamptz` column compares as its
// UTC calendar day, the day the read door presents — never cast in the
// session's zone. See `date-on-instant-column.ts`.
if (isDateOnInstantColumn(this, table, field)) {
return { sql: utcCalendarDaySql('??'), bindings: [column] };
}
if (this.needsLegacyDatetimeRepair(table, field)) {
return {
sql: this.sqliteCanonicalDatetimeSql('??'),
Expand All @@ -379,7 +387,9 @@ export class FilterHelpersFacet {
}

/** The body of `SqlDriver#temporalFilterColumnSql`; the method's docblock there is its contract. */
static temporalFilterColumnSql(this: Pick<FilterHelpersHost, 'needsLegacyDatetimeRepair' | 'needsLegacyTimeRepair' | 'sqliteCanonicalDatetimeSql' | 'sqliteCanonicalTimeSql'>, objectName: string, field: string, columnSql: string): string {
static temporalFilterColumnSql(this: Pick<FilterHelpersHost, 'dateFields' | 'dateFieldsOnInstantColumn' | 'needsLegacyDatetimeRepair' | 'needsLegacyTimeRepair' | 'sqliteCanonicalDatetimeSql' | 'sqliteCanonicalTimeSql'>, objectName: string, field: string, columnSql: string): string {
// [#32851] The outside-the-builder twin of `filterColumnExpr`'s arm.
if (isDateOnInstantColumn(this, objectName, field)) return utcCalendarDaySql(columnSql);
if (this.needsLegacyDatetimeRepair(objectName, field)) {
return this.sqliteCanonicalDatetimeSql(columnSql);
}
Expand Down
Loading
Loading