Skip to content

Commit 796f0d6

Browse files
os-zhuangclaude
andauthored
fix(driver-sql): store/return Field.date as a tz-naive YYYY-MM-DD calendar day (ADR-0053 Phase 1) (#1968)
A `Field.date` is semantically a timezone-naive calendar day, but the SQL driver treated it as an instant: `formatInput` wrote the value verbatim (keeping its time component) while `coerceFilterValue` already normalized filter comparands to date-only `YYYY-MM-DD`. That write/filter asymmetry made date-equality filters (`close_date == '2026-07-15'`, `$in`, `daysFromNow(n)`-style comparands) silently match nothing. Align the write/read boundary with the filter's existing date-only contract: - Extract the date-only truncation into a shared `toDateOnly` helper used by the filter, write, and read paths so all three agree on what a date is. - `formatInput`: collapse every `Field.date` value (Date | full-ISO string | date-only string) to `YYYY-MM-DD` before insert/update. A Date collapses to its UTC calendar day, matching `coerceFilterValue`. - `formatOutput`: return `Field.date` as `YYYY-MM-DD`, slicing any stored time component — transparently repairing legacy timestamped rows on read with no data migration. Read normalization now runs on the `find` path for every dialect (previously only `findOne`); the json/boolean deserialisation stays SQLite-gated internally. `Field.datetime` is unchanged (full-instant UTC-ms semantics). Out of scope (ADR-0053 Phase 2): tz-aware today()/daysFromNow()/daysAgo(), org/user reference timezone, datetime render-time TZ. Refs ADR-0053, #1928 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d172878 commit 796f0d6

3 files changed

Lines changed: 222 additions & 16 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): `Field.date` is now stored and returned as a tz-naive `YYYY-MM-DD` calendar day (ADR-0053 Phase 1)
6+
7+
A `Field.date` ("close date", "due date", "birthday") is semantically a **timezone-naive calendar day**, but the SQL driver was treating it as an *instant*: `formatInput` wrote the value verbatim (keeping any time component, so `dev.db` held `close_date = "2026-07-15T17:24:56.533Z"`), while the filter layer (`coerceFilterValue`) already normalized the comparand to date-only `YYYY-MM-DD`. That write/filter asymmetry meant a date-equality filter — `close_date == '2026-07-15'`, `expires_on: { $in: [...] }`, or a `daysFromNow(n)`-style comparand — compared `"2026-07-15T17:24Z"` against `"2026-07-15"` and **silently matched nothing**.
8+
9+
This patch aligns the write/read boundary with the date-only contract the filter already enforced:
10+
11+
- **Write** (`formatInput`): every `Field.date` value (a JS `Date`, a full-ISO string, or an already date-only string) collapses to `YYYY-MM-DD` before insert/update. A `Date` collapses to its UTC calendar day, matching `coerceFilterValue`.
12+
- **Read** (`formatOutput`): `Field.date` values are returned as `YYYY-MM-DD`, slicing any stored time component. This transparently repairs legacy rows that were written as a full timestamp, so date-equality works **without a data migration**. Read normalization now runs on the `find` path for every dialect (previously only `findOne`), matching the new behaviour.
13+
- The truncation logic is shared by the filter, write and read paths via a single `toDateOnly` helper, so all three agree on what a date *is*.
14+
15+
`Field.datetime` is **unchanged** — it keeps full-instant (UTC millisecond) semantics.
16+
17+
Out of scope (ADR-0053 Phase 2): timezone-aware `today()`/`daysFromNow()`/`daysAgo()`, an org/user reference timezone, and `datetime` render-time TZ. See ADR-0053 and issue #1928.
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day. The
5+
* driver must store and return it as a `YYYY-MM-DD` string, never as an
6+
* instant — aligning the write/read boundary with the date-only contract
7+
* the filter layer (`coerceFilterValue`) already enforces.
8+
*
9+
* Before this change `formatInput` stored the value verbatim (keeping the
10+
* time component), while filters normalized the comparand to `YYYY-MM-DD`,
11+
* so `close_date == '2026-07-15'` compared `'2026-07-15T17:24Z'` against
12+
* `'2026-07-15'` and silently matched nothing. These tests pin the fixed
13+
* behaviour and guard that `Field.datetime` keeps its full-instant meaning.
14+
*/
15+
16+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
17+
import { SqlDriver } from '../src/index.js';
18+
19+
describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', () => {
20+
let driver: SqlDriver;
21+
22+
beforeEach(async () => {
23+
driver = new SqlDriver({
24+
client: 'better-sqlite3',
25+
connection: { filename: ':memory:' },
26+
useNullAsDefault: true,
27+
});
28+
29+
await driver.initObjects([
30+
{
31+
name: 'deal',
32+
fields: {
33+
name: { type: 'string' },
34+
close_date: { type: 'date' },
35+
signed_at: { type: 'datetime' },
36+
amount: { type: 'integer' },
37+
},
38+
},
39+
]);
40+
});
41+
42+
afterEach(async () => {
43+
await driver.disconnect();
44+
});
45+
46+
it('stores a JS Date in a date field as a YYYY-MM-DD calendar day', async () => {
47+
await driver.create(
48+
'deal',
49+
{ id: 'd1', name: 'A', close_date: new Date('2026-07-15T17:24:56.533Z') },
50+
{ bypassTenantAudit: true },
51+
);
52+
const row = await driver.findOne('deal', 'd1', { bypassTenantAudit: true });
53+
expect(row.close_date).toBe('2026-07-15');
54+
});
55+
56+
it('stores a full-ISO string in a date field as date-only', async () => {
57+
await driver.create(
58+
'deal',
59+
{ id: 'd2', name: 'B', close_date: '2026-07-15T17:24:56.533Z' },
60+
{ bypassTenantAudit: true },
61+
);
62+
const row = await driver.findOne('deal', 'd2', { bypassTenantAudit: true });
63+
expect(row.close_date).toBe('2026-07-15');
64+
});
65+
66+
it('leaves an already date-only string unchanged', async () => {
67+
await driver.create(
68+
'deal',
69+
{ id: 'd3', name: 'C', close_date: '2026-07-15' },
70+
{ bypassTenantAudit: true },
71+
);
72+
const row = await driver.findOne('deal', 'd3', { bypassTenantAudit: true });
73+
expect(row.close_date).toBe('2026-07-15');
74+
});
75+
76+
it('keeps Field.datetime as a full instant (not collapsed to a day)', async () => {
77+
await driver.create(
78+
'deal',
79+
{ id: 'd4', name: 'D', signed_at: new Date('2026-03-20T12:34:56.000Z') },
80+
{ bypassTenantAudit: true },
81+
);
82+
const row = await driver.findOne('deal', 'd4', { bypassTenantAudit: true });
83+
// datetime must retain its wall-clock time — never sliced to YYYY-MM-DD.
84+
expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z');
85+
});
86+
87+
it('matches a date-only equality filter against a timestamped write (the silent-miss regression)', async () => {
88+
// Written with a Date carrying a time component. Pre-fix this stored
89+
// '2026-07-15T17:24…' and the equality filter below matched nothing.
90+
await driver.create(
91+
'deal',
92+
{ id: 'd5', name: 'E', close_date: new Date('2026-07-15T17:24:56.533Z') },
93+
{ bypassTenantAudit: true },
94+
);
95+
const rows = await driver.find('deal', { where: { close_date: '2026-07-15' } });
96+
expect(rows.map((r: any) => r.id)).toEqual(['d5']);
97+
});
98+
99+
it('matches a $in of calendar days', async () => {
100+
await driver.create('deal', { id: 'd6', name: 'F', close_date: new Date('2026-07-15T08:00:00Z') }, { bypassTenantAudit: true });
101+
await driver.create('deal', { id: 'd7', name: 'G', close_date: '2026-07-16' }, { bypassTenantAudit: true });
102+
await driver.create('deal', { id: 'd8', name: 'H', close_date: '2026-07-17' }, { bypassTenantAudit: true });
103+
const rows = await driver.find('deal', { where: { close_date: { $in: ['2026-07-15', '2026-07-17'] } } });
104+
expect(rows.map((r: any) => r.id).sort()).toEqual(['d6', 'd8']);
105+
});
106+
107+
it('keeps date range filters working ($gte / $lt)', async () => {
108+
await driver.create('deal', { id: 'r1', close_date: '2025-01-15' }, { bypassTenantAudit: true });
109+
await driver.create('deal', { id: 'r2', close_date: '2026-03-20' }, { bypassTenantAudit: true });
110+
await driver.create('deal', { id: 'r3', close_date: '2026-05-25' }, { bypassTenantAudit: true });
111+
const rows = await driver.find('deal', { where: { close_date: { $gte: '2026-01-01', $lt: '2026-05-01' } } });
112+
expect(rows.map((r: any) => r.id)).toEqual(['r2']);
113+
});
114+
115+
it('repairs a legacy timestamped row on read; an in-place rewrite makes it filter-matchable', async () => {
116+
// Simulate a row written before this normalization by inserting a full
117+
// timestamp straight into the (TEXT-affinity) date column, bypassing
118+
// formatInput.
119+
await (driver as any).knex('deal').insert({ id: 'legacy', name: 'L', close_date: '2026-08-15T17:24:56.533Z' });
120+
121+
// Read-side repair: the returned value is date-only with no migration.
122+
const row = await driver.findOne('deal', 'legacy', { bypassTenantAudit: true });
123+
expect(row.close_date).toBe('2026-08-15');
124+
125+
// …but the value still stored in SQL keeps its time, so a SQL equality
126+
// filter against the un-rewritten row still misses. This is the limitation
127+
// ADR-0053 calls out: read-repair fixes display/read, and an optional
128+
// one-time migration (or any write through the normalized path) rewrites
129+
// legacy rows at rest.
130+
const beforeRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } });
131+
expect(beforeRewrite.map((r: any) => r.id)).toEqual([]);
132+
133+
// Rewriting through the normalized write path (formatInput) collapses the
134+
// stored value to date-only, after which the equality filter matches.
135+
await driver.update('deal', 'legacy', { close_date: '2026-08-15' }, { bypassTenantAudit: true });
136+
const afterRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } });
137+
expect(afterRewrite.map((r: any) => r.id)).toEqual(['legacy']);
138+
});
139+
});

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

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -437,10 +437,12 @@ export class SqlDriver implements IDataDriver {
437437
return [];
438438
}
439439

440-
if (this.isSqlite) {
441-
for (const row of results) {
442-
this.formatOutput(object, row);
443-
}
440+
// formatOutput is dialect-agnostic for `Field.date` (ADR-0053 Phase 1);
441+
// its json/boolean deserialisation stays SQLite-gated internally. Run it
442+
// for every dialect so reads match `findOne` and date columns come back
443+
// as `YYYY-MM-DD`.
444+
for (const row of results) {
445+
this.formatOutput(object, row);
444446
}
445447
return results;
446448
}
@@ -1493,6 +1495,31 @@ export class SqlDriver implements IDataDriver {
14931495
return null;
14941496
}
14951497

1498+
/**
1499+
* Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
1500+
* calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
1501+
* calendar day; a string keeps its leading date and drops any time
1502+
* component. Anything else (and `null`/`undefined`) passes through
1503+
* unchanged. This is the single source of truth for date-only truncation,
1504+
* shared by the filter (`coerceFilterValue`), write (`formatInput`) and
1505+
* read (`formatOutput`) paths so all three agree on what a date *is*.
1506+
*/
1507+
protected toDateOnly(value: any): any {
1508+
if (value == null) return value;
1509+
if (value instanceof Date) {
1510+
if (Number.isNaN(value.getTime())) return value;
1511+
const y = value.getUTCFullYear();
1512+
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
1513+
const d = String(value.getUTCDate()).padStart(2, '0');
1514+
return `${y}-${m}-${d}`;
1515+
}
1516+
if (typeof value === 'string') {
1517+
const trimmed = value.trim();
1518+
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
1519+
}
1520+
return value;
1521+
}
1522+
14961523
/**
14971524
* Normalise a filter value for a single column so the comparison the
14981525
* driver sends to SQLite matches the on-disk representation.
@@ -1540,18 +1567,8 @@ export class SqlDriver implements IDataDriver {
15401567
return ms == null ? value : ms;
15411568
}
15421569

1543-
// Field.date — normalise to YYYY-MM-DD.
1544-
if (value instanceof Date) {
1545-
const y = value.getUTCFullYear();
1546-
const m = String(value.getUTCMonth() + 1).padStart(2, '0');
1547-
const d = String(value.getUTCDate()).padStart(2, '0');
1548-
return `${y}-${m}-${d}`;
1549-
}
1550-
if (typeof value === 'string') {
1551-
const trimmed = value.trim();
1552-
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10);
1553-
}
1554-
return value;
1570+
// Field.date — normalise the comparand to YYYY-MM-DD (ADR-0053 Phase 1).
1571+
return this.toDateOnly(value);
15551572
}
15561573

15571574
protected applyFilters(builder: Knex.QueryBuilder, filters: any) {
@@ -1983,6 +2000,25 @@ export class SqlDriver implements IDataDriver {
19832000
}
19842001
}
19852002

2003+
// ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not
2004+
// an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before
2005+
// it hits the wire so storage matches the date-only contract the filter
2006+
// layer (`coerceFilterValue`) already enforces — the write/filter
2007+
// asymmetry was the root cause of the silent date-equality miss.
2008+
// `Field.datetime` is untouched (it keeps full-instant semantics).
2009+
const dateFields = this.dateFields[object];
2010+
if (dateFields && dateFields.size > 0 && copy && typeof copy === 'object') {
2011+
for (const field of dateFields) {
2012+
const v = copy[field];
2013+
if (v == null) continue;
2014+
const normalized = this.toDateOnly(v);
2015+
if (normalized !== v) {
2016+
if (!copied) { copy = { ...copy }; copied = true; }
2017+
copy[field] = normalized;
2018+
}
2019+
}
2020+
}
2021+
19862022
if (!this.isSqlite) return copy;
19872023

19882024
const fields = this.jsonFields[object];
@@ -2024,6 +2060,20 @@ export class SqlDriver implements IDataDriver {
20242060
}
20252061
}
20262062

2063+
// ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD`
2064+
// string, slicing any stored time component. This transparently repairs
2065+
// legacy rows written as a full timestamp before this normalization, so
2066+
// date-equality works without a data migration. Runs for every dialect.
2067+
const dateFields = this.dateFields[object];
2068+
if (dateFields && dateFields.size > 0) {
2069+
for (const field of dateFields) {
2070+
const v = data[field];
2071+
if (v == null) continue;
2072+
const normalized = this.toDateOnly(v);
2073+
if (normalized !== v) data[field] = normalized;
2074+
}
2075+
}
2076+
20272077
return data;
20282078
}
20292079

0 commit comments

Comments
 (0)