diff --git a/.changeset/date-only-storage-adr0053.md b/.changeset/date-only-storage-adr0053.md new file mode 100644 index 0000000000..d98846c376 --- /dev/null +++ b/.changeset/date-only-storage-adr0053.md @@ -0,0 +1,17 @@ +--- +"@objectstack/driver-sql": patch +--- + +fix(driver-sql): `Field.date` is now stored and returned as a tz-naive `YYYY-MM-DD` calendar day (ADR-0053 Phase 1) + +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**. + +This patch aligns the write/read boundary with the date-only contract the filter already enforced: + +- **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`. +- **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. +- 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*. + +`Field.datetime` is **unchanged** — it keeps full-instant (UTC millisecond) semantics. + +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. diff --git a/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts b/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts new file mode 100644 index 0000000000..7e7f248607 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-date-only.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day. The + * driver must store and return it as a `YYYY-MM-DD` string, never as an + * instant — aligning the write/read boundary with the date-only contract + * the filter layer (`coerceFilterValue`) already enforces. + * + * Before this change `formatInput` stored the value verbatim (keeping the + * time component), while filters normalized the comparand to `YYYY-MM-DD`, + * so `close_date == '2026-07-15'` compared `'2026-07-15T17:24Z'` against + * `'2026-07-15'` and silently matched nothing. These tests pin the fixed + * behaviour and guard that `Field.datetime` keeps its full-instant meaning. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + + await driver.initObjects([ + { + name: 'deal', + fields: { + name: { type: 'string' }, + close_date: { type: 'date' }, + signed_at: { type: 'datetime' }, + amount: { type: 'integer' }, + }, + }, + ]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + it('stores a JS Date in a date field as a YYYY-MM-DD calendar day', async () => { + await driver.create( + 'deal', + { id: 'd1', name: 'A', close_date: new Date('2026-07-15T17:24:56.533Z') }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('deal', 'd1', { bypassTenantAudit: true }); + expect(row.close_date).toBe('2026-07-15'); + }); + + it('stores a full-ISO string in a date field as date-only', async () => { + await driver.create( + 'deal', + { id: 'd2', name: 'B', close_date: '2026-07-15T17:24:56.533Z' }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('deal', 'd2', { bypassTenantAudit: true }); + expect(row.close_date).toBe('2026-07-15'); + }); + + it('leaves an already date-only string unchanged', async () => { + await driver.create( + 'deal', + { id: 'd3', name: 'C', close_date: '2026-07-15' }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('deal', 'd3', { bypassTenantAudit: true }); + expect(row.close_date).toBe('2026-07-15'); + }); + + it('keeps Field.datetime as a full instant (not collapsed to a day)', async () => { + await driver.create( + 'deal', + { id: 'd4', name: 'D', signed_at: new Date('2026-03-20T12:34:56.000Z') }, + { bypassTenantAudit: true }, + ); + const row = await driver.findOne('deal', 'd4', { bypassTenantAudit: true }); + // datetime must retain its wall-clock time — never sliced to YYYY-MM-DD. + expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z'); + }); + + it('matches a date-only equality filter against a timestamped write (the silent-miss regression)', async () => { + // Written with a Date carrying a time component. Pre-fix this stored + // '2026-07-15T17:24…' and the equality filter below matched nothing. + await driver.create( + 'deal', + { id: 'd5', name: 'E', close_date: new Date('2026-07-15T17:24:56.533Z') }, + { bypassTenantAudit: true }, + ); + const rows = await driver.find('deal', { where: { close_date: '2026-07-15' } }); + expect(rows.map((r: any) => r.id)).toEqual(['d5']); + }); + + it('matches a $in of calendar days', async () => { + await driver.create('deal', { id: 'd6', name: 'F', close_date: new Date('2026-07-15T08:00:00Z') }, { bypassTenantAudit: true }); + await driver.create('deal', { id: 'd7', name: 'G', close_date: '2026-07-16' }, { bypassTenantAudit: true }); + await driver.create('deal', { id: 'd8', name: 'H', close_date: '2026-07-17' }, { bypassTenantAudit: true }); + const rows = await driver.find('deal', { where: { close_date: { $in: ['2026-07-15', '2026-07-17'] } } }); + expect(rows.map((r: any) => r.id).sort()).toEqual(['d6', 'd8']); + }); + + it('keeps date range filters working ($gte / $lt)', async () => { + await driver.create('deal', { id: 'r1', close_date: '2025-01-15' }, { bypassTenantAudit: true }); + await driver.create('deal', { id: 'r2', close_date: '2026-03-20' }, { bypassTenantAudit: true }); + await driver.create('deal', { id: 'r3', close_date: '2026-05-25' }, { bypassTenantAudit: true }); + const rows = await driver.find('deal', { where: { close_date: { $gte: '2026-01-01', $lt: '2026-05-01' } } }); + expect(rows.map((r: any) => r.id)).toEqual(['r2']); + }); + + it('repairs a legacy timestamped row on read; an in-place rewrite makes it filter-matchable', async () => { + // Simulate a row written before this normalization by inserting a full + // timestamp straight into the (TEXT-affinity) date column, bypassing + // formatInput. + await (driver as any).knex('deal').insert({ id: 'legacy', name: 'L', close_date: '2026-08-15T17:24:56.533Z' }); + + // Read-side repair: the returned value is date-only with no migration. + const row = await driver.findOne('deal', 'legacy', { bypassTenantAudit: true }); + expect(row.close_date).toBe('2026-08-15'); + + // …but the value still stored in SQL keeps its time, so a SQL equality + // filter against the un-rewritten row still misses. This is the limitation + // ADR-0053 calls out: read-repair fixes display/read, and an optional + // one-time migration (or any write through the normalized path) rewrites + // legacy rows at rest. + const beforeRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } }); + expect(beforeRewrite.map((r: any) => r.id)).toEqual([]); + + // Rewriting through the normalized write path (formatInput) collapses the + // stored value to date-only, after which the equality filter matches. + await driver.update('deal', 'legacy', { close_date: '2026-08-15' }, { bypassTenantAudit: true }); + const afterRewrite = await driver.find('deal', { where: { close_date: '2026-08-15' } }); + expect(afterRewrite.map((r: any) => r.id)).toEqual(['legacy']); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 919bcf3965..ba7a763601 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -437,10 +437,12 @@ export class SqlDriver implements IDataDriver { return []; } - if (this.isSqlite) { - for (const row of results) { - this.formatOutput(object, row); - } + // formatOutput is dialect-agnostic for `Field.date` (ADR-0053 Phase 1); + // its json/boolean deserialisation stays SQLite-gated internally. Run it + // for every dialect so reads match `findOne` and date columns come back + // as `YYYY-MM-DD`. + for (const row of results) { + this.formatOutput(object, row); } return results; } @@ -1493,6 +1495,31 @@ export class SqlDriver implements IDataDriver { return null; } + /** + * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD` + * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC + * calendar day; a string keeps its leading date and drops any time + * component. Anything else (and `null`/`undefined`) passes through + * unchanged. This is the single source of truth for date-only truncation, + * shared by the filter (`coerceFilterValue`), write (`formatInput`) and + * read (`formatOutput`) paths so all three agree on what a date *is*. + */ + protected toDateOnly(value: any): any { + if (value == null) return value; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return value; + const y = value.getUTCFullYear(); + const m = String(value.getUTCMonth() + 1).padStart(2, '0'); + const d = String(value.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10); + } + return value; + } + /** * Normalise a filter value for a single column so the comparison the * driver sends to SQLite matches the on-disk representation. @@ -1540,18 +1567,8 @@ export class SqlDriver implements IDataDriver { return ms == null ? value : ms; } - // Field.date — normalise to YYYY-MM-DD. - if (value instanceof Date) { - const y = value.getUTCFullYear(); - const m = String(value.getUTCMonth() + 1).padStart(2, '0'); - const d = String(value.getUTCDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10); - } - return value; + // Field.date — normalise the comparand to YYYY-MM-DD (ADR-0053 Phase 1). + return this.toDateOnly(value); } protected applyFilters(builder: Knex.QueryBuilder, filters: any) { @@ -1983,6 +2000,25 @@ export class SqlDriver implements IDataDriver { } } + // ADR-0053 Phase 1: a `Field.date` is a timezone-naive calendar day, not + // an instant. Collapse any `Date` or full-ISO value to `YYYY-MM-DD` before + // it hits the wire so storage matches the date-only contract the filter + // layer (`coerceFilterValue`) already enforces — the write/filter + // asymmetry was the root cause of the silent date-equality miss. + // `Field.datetime` is untouched (it keeps full-instant semantics). + const dateFields = this.dateFields[object]; + if (dateFields && dateFields.size > 0 && copy && typeof copy === 'object') { + for (const field of dateFields) { + const v = copy[field]; + if (v == null) continue; + const normalized = this.toDateOnly(v); + if (normalized !== v) { + if (!copied) { copy = { ...copy }; copied = true; } + copy[field] = normalized; + } + } + } + if (!this.isSqlite) return copy; const fields = this.jsonFields[object]; @@ -2024,6 +2060,20 @@ export class SqlDriver implements IDataDriver { } } + // ADR-0053 Phase 1: present `Field.date` as a timezone-naive `YYYY-MM-DD` + // string, slicing any stored time component. This transparently repairs + // legacy rows written as a full timestamp before this normalization, so + // date-equality works without a data migration. Runs for every dialect. + const dateFields = this.dateFields[object]; + if (dateFields && dateFields.size > 0) { + for (const field of dateFields) { + const v = data[field]; + if (v == null) continue; + const normalized = this.toDateOnly(v); + if (normalized !== v) data[field] = normalized; + } + } + return data; }