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
23 changes: 23 additions & 0 deletions .changeset/sqlite-time-of-day-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/driver-sql': patch
---

Fix: present `Field.time` as a wall-clock time-of-day on read (SQLite)

`Field.time` is a tz-naive time-of-day, not an instant (#2004). A
`defaultValue: 'NOW()'` time column historically took the full SQLite
`CURRENT_TIMESTAMP` default, so a defaulted/legacy row read back a full
`'YYYY-MM-DD HH:MM:SS'` timestamp instead of a time-of-day.

`formatOutput` now repairs a `Field.time` value to just its time portion
(`toTimeOnly`): a legacy full timestamp — or a full ISO value that leaked into
the column — is sliced to `HH:MM[:SS[.fff]]`, while a value already stored as a
bare time-of-day is left untouched. This is a deliberately NARROW, read-only
normalization with no write/filter counterpart, so it introduces no write/read
asymmetry and preserves exact round-trips for bare time-of-day values (e.g. the
field-zoo `f_time` guard). Runs for every dialect (a native TIME column already
returns a time-of-day, so it is a no-op there).

Completes the temporal-field read normalization alongside #2346: `datetime`
folds to a canonical ISO-8601-`Z` instant, `date` to `YYYY-MM-DD`, and `time` to
a wall-clock time-of-day.
91 changes: 91 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Read-side time-of-day normalization for `Field.time` on SQLite.
*
* `Field.time` is a wall-clock time-of-day, not an instant (#2004). A
* `defaultValue: 'NOW()'` time column historically took the full
* `CURRENT_TIMESTAMP` default, so a defaulted row read back a full
* `'YYYY-MM-DD HH:MM:SS'` timestamp instead of a time-of-day. `formatOutput` now
* repairs such legacy/raw rows to just the time portion (`toTimeOnly`), while
* leaving a value already stored as a bare time-of-day untouched — read-only, so
* no write/read asymmetry is introduced and the field-zoo round-trip
* (`f_time: '14:30:00'`, #2022) is unaffected.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

const TIME_OF_DAY = /^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;

describe('Field.time read normalization (time-of-day, SQLite)', () => {
let driver: SqlDriver;
let raw: any;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
});
raw = (driver as any).knex;
await driver.initObjects([
{
name: 'shift',
fields: {
label: { type: 'string' },
starts_at: { type: 'time' },
auto_at: { type: 'time', defaultValue: 'NOW()' },
},
},
]);
});

afterEach(async () => {
await driver.disconnect();
});

it('repairs a legacy full-timestamp time value to its time-of-day on read', async () => {
// A row written by a raw insert that took the OLD full `CURRENT_TIMESTAMP`
// default (or any full timestamp that leaked into the column), bypassing the
// driver write path.
await raw('shift').insert({ id: 'legacy', label: 'L', starts_at: '2026-01-15 14:30:00' });
const row: any = await driver.findOne('shift', 'legacy', { bypassTenantAudit: true });
expect(row.starts_at).toBe('14:30:00');
});

it('repairs a full-ISO value (with Z) in a time column to its time-of-day', async () => {
await raw('shift').insert({ id: 'iso', label: 'I', starts_at: '2026-01-15T14:30:00.500Z' });
const row: any = await driver.findOne('shift', 'iso', { bypassTenantAudit: true });
expect(row.starts_at).toBe('14:30:00.500');
});

it('leaves a bare time-of-day untouched (field-zoo parity — no write/read asymmetry)', async () => {
for (const [id, v] of [['a', '14:30'], ['b', '14:30:00'], ['c', '09:05:30']] as const) {
await driver.create('shift', { id, label: id, starts_at: v }, { bypassTenantAudit: true });
const row: any = await driver.findOne('shift', id, { bypassTenantAudit: true });
expect(row.starts_at).toBe(v); // unchanged — round-trips identically
}
});

it('a NOW()-default time column reads back a time-of-day, not a full timestamp', async () => {
// `auto_at` omitted → the DDL default fires.
await driver.create('shift', { id: 'd', label: 'D' }, { bypassTenantAudit: true });
const row: any = await driver.findOne('shift', 'd', { bypassTenantAudit: true });
expect(row.auto_at).toMatch(TIME_OF_DAY);
expect(row.auto_at).not.toContain('-'); // not a `YYYY-MM-DD …` timestamp
});

it('find() (list path) normalizes time identically to findOne()', async () => {
await raw('shift').insert({ id: 'l1', label: 'L1', starts_at: '2026-02-02 08:15:00' });
await driver.create('shift', { id: 'l2', label: 'L2', starts_at: '08:15:00' }, { bypassTenantAudit: true });
const rows = await driver.find('shift', { orderBy: [{ field: 'id', order: 'asc' }] });
const byId = Object.fromEntries(rows.map((r: any) => [r.id, r]));
expect(byId.l1.starts_at).toBe('08:15:00'); // legacy full-timestamp repaired
expect(byId.l2.starts_at).toBe('08:15:00'); // bare time-of-day preserved
});

it('leaves null untouched', async () => {
await driver.create('shift', { id: 'n', label: 'N', starts_at: null }, { bypassTenantAudit: true });
const row: any = await driver.findOne('shift', 'n', { bypassTenantAudit: true });
expect(row.starts_at).toBeNull();
});
});
53 changes: 53 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export class SqlDriver implements IDataDriver {
protected numericFields: Record<string, string[]> = {};
protected dateFields: Record<string, Set<string>> = {};
protected datetimeFields: Record<string, Set<string>> = {};
protected timeFields: Record<string, Set<string>> = {};
/**
* Federation read path (ADR-0015). For external objects whose physical
* remote table differs from the object name, these map between the two so
Expand Down Expand Up @@ -1546,6 +1547,7 @@ export class SqlDriver implements IDataDriver {
const numericCols: string[] = [];
const dateCols: string[] = [];
const datetimeCols: string[] = [];
const timeCols: string[] = [];
const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = [];

const tenantField = this.computeTenantField(schema);
Expand All @@ -1557,6 +1559,7 @@ export class SqlDriver implements IDataDriver {
if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
if (type === 'date') dateCols.push(name);
if (type === 'datetime') datetimeCols.push(name);
if (type === 'time') timeCols.push(name);
if (type === 'auto_number' || type === 'autonumber') {
const rawFmt = (typeof field.autonumberFormat === 'string' && field.autonumberFormat)
? field.autonumberFormat
Expand All @@ -1573,6 +1576,7 @@ export class SqlDriver implements IDataDriver {
this.tenantFieldByTable[key] = tenantField;
if (dateCols.length) this.dateFields[key] = new Set(dateCols);
if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
if (timeCols.length) this.timeFields[key] = new Set(timeCols);
}

async initObjects(objects: Array<{ name: string; fields?: Record<string, any> }>): Promise<void> {
Expand Down Expand Up @@ -1622,6 +1626,9 @@ export class SqlDriver implements IDataDriver {
if (type === 'datetime') {
(this.datetimeFields[tableName] ??= new Set()).add(name);
}
if (type === 'time') {
(this.timeFields[tableName] ??= new Set()).add(name);
}
if (type === 'auto_number' || type === 'autonumber') {
// Honor either the spec-canonical `autonumberFormat` or the
// shorthand `format` (both appear in metadata) — see #1603.
Expand Down Expand Up @@ -2366,6 +2373,35 @@ export class SqlDriver implements IDataDriver {
return value;
}

/**
* Read-side repair for a `Field.time` value to its wall-clock time-of-day
* (`Field.time` is a tz-naive time-of-day, not an instant — #2004). This is a
* deliberately NARROW, read-only normalization (no write/filter counterpart):
* it only strips a leading `YYYY-MM-DD` date — exactly what a legacy
* `defaultValue: 'NOW()'` column took when the default was still the full
* `CURRENT_TIMESTAMP` (or a full ISO datetime that leaked into the column) —
* and any trailing zone, leaving the time portion. A value that is ALREADY a
* bare time-of-day (`HH:MM[:SS[.fff]]`, with or without `Z`/offset) is returned
* untouched, so the common case never changes and no write/read asymmetry is
* introduced. A `Date`/epoch-ms (defensive — a Date bound to a time column)
* maps to its UTC time-of-day. `null`/unrecognised shapes pass through.
*/
protected toTimeOnly(value: any): any {
if (value == null) return value;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(11, 19);
}
if (typeof value === 'number' && Number.isFinite(value)) {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toISOString().slice(11, 19);
}
if (typeof value !== 'string') return value;
// Legacy full date+time → keep just the time-of-day (strip date + any zone).
// A bare time-of-day is left exactly as stored.
const m = /^\d{4}-\d{2}-\d{2}[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(?:[Zz]|[+-]\d{2}:?\d{2})?$/.exec(value.trim());
return m ? m[1] : 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 @@ -3096,6 +3132,23 @@ export class SqlDriver implements IDataDriver {
}
}

// Present `Field.time` as a wall-clock time-of-day (#2004), repairing a
// legacy row stored as a full timestamp — what a `defaultValue: 'NOW()'`
// column took when the SQLite default was still the full `CURRENT_TIMESTAMP`
// — to just its time portion. A value already stored as a bare time-of-day
// is left untouched, so this is read-only and asymmetry-free. Runs for every
// dialect (a native TIME column already returns a time-of-day → no-op). See
// `toTimeOnly`.
const timeFields = this.timeFields[object];
if (timeFields && timeFields.size > 0) {
for (const field of timeFields) {
const v = data[field];
if (v == null) continue;
const normalized = this.toTimeOnly(v);
if (normalized !== v) data[field] = normalized;
}
}

return data;
}

Expand Down