Skip to content

Commit bc22a89

Browse files
os-zhuangclaude
andauthored
fix(driver-sql): present Field.time as a wall-clock time-of-day on read (SQLite) (#2347)
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. Deliberately NARROW + read-only (no write/filter counterpart) so it introduces no write/read asymmetry and preserves exact round-trips for bare time-of-day values (the field-zoo f_time guard). Runs for every dialect (a native TIME column already returns a time-of-day → no-op). Adds a timeFields registry + registration mirroring dateFields/datetimeFields. Completes the temporal read normalization alongside #2346: datetime -> ISO-8601-Z, date -> YYYY-MM-DD, time -> wall-clock time-of-day. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a7e9f1 commit bc22a89

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
Fix: present `Field.time` as a wall-clock time-of-day on read (SQLite)
6+
7+
`Field.time` is a tz-naive time-of-day, not an instant (#2004). A
8+
`defaultValue: 'NOW()'` time column historically took the full SQLite
9+
`CURRENT_TIMESTAMP` default, so a defaulted/legacy row read back a full
10+
`'YYYY-MM-DD HH:MM:SS'` timestamp instead of a time-of-day.
11+
12+
`formatOutput` now repairs a `Field.time` value to just its time portion
13+
(`toTimeOnly`): a legacy full timestamp — or a full ISO value that leaked into
14+
the column — is sliced to `HH:MM[:SS[.fff]]`, while a value already stored as a
15+
bare time-of-day is left untouched. This is a deliberately NARROW, read-only
16+
normalization with no write/filter counterpart, so it introduces no write/read
17+
asymmetry and preserves exact round-trips for bare time-of-day values (e.g. the
18+
field-zoo `f_time` guard). Runs for every dialect (a native TIME column already
19+
returns a time-of-day, so it is a no-op there).
20+
21+
Completes the temporal-field read normalization alongside #2346: `datetime`
22+
folds to a canonical ISO-8601-`Z` instant, `date` to `YYYY-MM-DD`, and `time` to
23+
a wall-clock time-of-day.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Read-side time-of-day normalization for `Field.time` on SQLite.
5+
*
6+
* `Field.time` is a wall-clock time-of-day, not an instant (#2004). A
7+
* `defaultValue: 'NOW()'` time column historically took the full
8+
* `CURRENT_TIMESTAMP` default, so a defaulted row read back a full
9+
* `'YYYY-MM-DD HH:MM:SS'` timestamp instead of a time-of-day. `formatOutput` now
10+
* repairs such legacy/raw rows to just the time portion (`toTimeOnly`), while
11+
* leaving a value already stored as a bare time-of-day untouched — read-only, so
12+
* no write/read asymmetry is introduced and the field-zoo round-trip
13+
* (`f_time: '14:30:00'`, #2022) is unaffected.
14+
*/
15+
16+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
17+
import { SqlDriver } from '../src/index.js';
18+
19+
const TIME_OF_DAY = /^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
20+
21+
describe('Field.time read normalization (time-of-day, SQLite)', () => {
22+
let driver: SqlDriver;
23+
let raw: any;
24+
25+
beforeEach(async () => {
26+
driver = new SqlDriver({
27+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
28+
});
29+
raw = (driver as any).knex;
30+
await driver.initObjects([
31+
{
32+
name: 'shift',
33+
fields: {
34+
label: { type: 'string' },
35+
starts_at: { type: 'time' },
36+
auto_at: { type: 'time', defaultValue: 'NOW()' },
37+
},
38+
},
39+
]);
40+
});
41+
42+
afterEach(async () => {
43+
await driver.disconnect();
44+
});
45+
46+
it('repairs a legacy full-timestamp time value to its time-of-day on read', async () => {
47+
// A row written by a raw insert that took the OLD full `CURRENT_TIMESTAMP`
48+
// default (or any full timestamp that leaked into the column), bypassing the
49+
// driver write path.
50+
await raw('shift').insert({ id: 'legacy', label: 'L', starts_at: '2026-01-15 14:30:00' });
51+
const row: any = await driver.findOne('shift', 'legacy', { bypassTenantAudit: true });
52+
expect(row.starts_at).toBe('14:30:00');
53+
});
54+
55+
it('repairs a full-ISO value (with Z) in a time column to its time-of-day', async () => {
56+
await raw('shift').insert({ id: 'iso', label: 'I', starts_at: '2026-01-15T14:30:00.500Z' });
57+
const row: any = await driver.findOne('shift', 'iso', { bypassTenantAudit: true });
58+
expect(row.starts_at).toBe('14:30:00.500');
59+
});
60+
61+
it('leaves a bare time-of-day untouched (field-zoo parity — no write/read asymmetry)', async () => {
62+
for (const [id, v] of [['a', '14:30'], ['b', '14:30:00'], ['c', '09:05:30']] as const) {
63+
await driver.create('shift', { id, label: id, starts_at: v }, { bypassTenantAudit: true });
64+
const row: any = await driver.findOne('shift', id, { bypassTenantAudit: true });
65+
expect(row.starts_at).toBe(v); // unchanged — round-trips identically
66+
}
67+
});
68+
69+
it('a NOW()-default time column reads back a time-of-day, not a full timestamp', async () => {
70+
// `auto_at` omitted → the DDL default fires.
71+
await driver.create('shift', { id: 'd', label: 'D' }, { bypassTenantAudit: true });
72+
const row: any = await driver.findOne('shift', 'd', { bypassTenantAudit: true });
73+
expect(row.auto_at).toMatch(TIME_OF_DAY);
74+
expect(row.auto_at).not.toContain('-'); // not a `YYYY-MM-DD …` timestamp
75+
});
76+
77+
it('find() (list path) normalizes time identically to findOne()', async () => {
78+
await raw('shift').insert({ id: 'l1', label: 'L1', starts_at: '2026-02-02 08:15:00' });
79+
await driver.create('shift', { id: 'l2', label: 'L2', starts_at: '08:15:00' }, { bypassTenantAudit: true });
80+
const rows = await driver.find('shift', { orderBy: [{ field: 'id', order: 'asc' }] });
81+
const byId = Object.fromEntries(rows.map((r: any) => [r.id, r]));
82+
expect(byId.l1.starts_at).toBe('08:15:00'); // legacy full-timestamp repaired
83+
expect(byId.l2.starts_at).toBe('08:15:00'); // bare time-of-day preserved
84+
});
85+
86+
it('leaves null untouched', async () => {
87+
await driver.create('shift', { id: 'n', label: 'N', starts_at: null }, { bypassTenantAudit: true });
88+
const row: any = await driver.findOne('shift', 'n', { bypassTenantAudit: true });
89+
expect(row.starts_at).toBeNull();
90+
});
91+
});

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ export class SqlDriver implements IDataDriver {
321321
protected numericFields: Record<string, string[]> = {};
322322
protected dateFields: Record<string, Set<string>> = {};
323323
protected datetimeFields: Record<string, Set<string>> = {};
324+
protected timeFields: Record<string, Set<string>> = {};
324325
/**
325326
* Federation read path (ADR-0015). For external objects whose physical
326327
* remote table differs from the object name, these map between the two so
@@ -1546,6 +1547,7 @@ export class SqlDriver implements IDataDriver {
15461547
const numericCols: string[] = [];
15471548
const dateCols: string[] = [];
15481549
const datetimeCols: string[] = [];
1550+
const timeCols: string[] = [];
15491551
const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = [];
15501552

15511553
const tenantField = this.computeTenantField(schema);
@@ -1557,6 +1559,7 @@ export class SqlDriver implements IDataDriver {
15571559
if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) numericCols.push(name);
15581560
if (type === 'date') dateCols.push(name);
15591561
if (type === 'datetime') datetimeCols.push(name);
1562+
if (type === 'time') timeCols.push(name);
15601563
if (type === 'auto_number' || type === 'autonumber') {
15611564
const rawFmt = (typeof field.autonumberFormat === 'string' && field.autonumberFormat)
15621565
? field.autonumberFormat
@@ -1573,6 +1576,7 @@ export class SqlDriver implements IDataDriver {
15731576
this.tenantFieldByTable[key] = tenantField;
15741577
if (dateCols.length) this.dateFields[key] = new Set(dateCols);
15751578
if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1579+
if (timeCols.length) this.timeFields[key] = new Set(timeCols);
15761580
}
15771581

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

2376+
/**
2377+
* Read-side repair for a `Field.time` value to its wall-clock time-of-day
2378+
* (`Field.time` is a tz-naive time-of-day, not an instant — #2004). This is a
2379+
* deliberately NARROW, read-only normalization (no write/filter counterpart):
2380+
* it only strips a leading `YYYY-MM-DD` date — exactly what a legacy
2381+
* `defaultValue: 'NOW()'` column took when the default was still the full
2382+
* `CURRENT_TIMESTAMP` (or a full ISO datetime that leaked into the column) —
2383+
* and any trailing zone, leaving the time portion. A value that is ALREADY a
2384+
* bare time-of-day (`HH:MM[:SS[.fff]]`, with or without `Z`/offset) is returned
2385+
* untouched, so the common case never changes and no write/read asymmetry is
2386+
* introduced. A `Date`/epoch-ms (defensive — a Date bound to a time column)
2387+
* maps to its UTC time-of-day. `null`/unrecognised shapes pass through.
2388+
*/
2389+
protected toTimeOnly(value: any): any {
2390+
if (value == null) return value;
2391+
if (value instanceof Date) {
2392+
return Number.isNaN(value.getTime()) ? value : value.toISOString().slice(11, 19);
2393+
}
2394+
if (typeof value === 'number' && Number.isFinite(value)) {
2395+
const d = new Date(value);
2396+
return Number.isNaN(d.getTime()) ? value : d.toISOString().slice(11, 19);
2397+
}
2398+
if (typeof value !== 'string') return value;
2399+
// Legacy full date+time → keep just the time-of-day (strip date + any zone).
2400+
// A bare time-of-day is left exactly as stored.
2401+
const m = /^\d{4}-\d{2}-\d{2}[ T](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(?:[Zz]|[+-]\d{2}:?\d{2})?$/.exec(value.trim());
2402+
return m ? m[1] : value;
2403+
}
2404+
23692405
/**
23702406
* Normalise a filter value for a single column so the comparison the
23712407
* driver sends to SQLite matches the on-disk representation.
@@ -3096,6 +3132,23 @@ export class SqlDriver implements IDataDriver {
30963132
}
30973133
}
30983134

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

0 commit comments

Comments
 (0)