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
17 changes: 17 additions & 0 deletions .changeset/date-only-storage-adr0053.md
Original file line number Diff line number Diff line change
@@ -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.
139 changes: 139 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-date-only.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
82 changes: 66 additions & 16 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}

Expand Down
Loading