diff --git a/.changeset/sqlite-time-of-day-read.md b/.changeset/sqlite-time-of-day-read.md new file mode 100644 index 0000000000..0d7090d9d5 --- /dev/null +++ b/.changeset/sqlite-time-of-day-read.md @@ -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. diff --git a/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts b/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts new file mode 100644 index 0000000000..f47de5f0a1 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-time-of-day.test.ts @@ -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(); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index ba33870c36..87746185f5 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -321,6 +321,7 @@ export class SqlDriver implements IDataDriver { protected numericFields: Record = {}; protected dateFields: Record> = {}; protected datetimeFields: Record> = {}; + protected timeFields: Record> = {}; /** * Federation read path (ADR-0015). For external objects whose physical * remote table differs from the object name, these map between the two so @@ -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); @@ -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 @@ -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 }>): Promise { @@ -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. @@ -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. @@ -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; }