diff --git a/.changeset/20671-time-write-zone-less.md b/.changeset/20671-time-write-zone-less.md new file mode 100644 index 00000000000..e4aed71eec6 --- /dev/null +++ b/.changeset/20671-time-write-zone-less.md @@ -0,0 +1,36 @@ +--- +"@objectstack/objectql": minor +"@objectstack/spec": patch +--- + +fix(objectql)!: a `time` field is a zone-less wall clock — a time of day written with a `Z` or an offset (`"10:00Z"`, `"10:00+08:00"`), and an instant whose UTC year has no four-digit spelling (`"+010000-01-01T10:00:00Z"`), are refused with `VALIDATION_FAILED` / 400 (`invalid_time`) instead of being stored verbatim on memory and SQLite and read back differently, or failing with a 500, on PostgreSQL (#20671) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows what a `time` field accepts as a written value. It ships as `minor` under the launch-window convention for accept-set narrowings (`check-changeset-no-major` refuses `major` until GA; the breaking-ness is carried by this banner and the ADR-0087 disposition above). + +The record validator's `time` arm now asks `@objectstack/core`'s one temporal rule, the same one the `time` filter comparand door asks, so a value is refused as a written `time` exactly when it is refused as a `time` comparand. It reads two things: a bare wall clock `HH:MM[:SS[.fraction]]` in range, and an instant in one of the ISO 8601 spellings a `datetime` is written in, on a calendar day that exists, whose UTC year has four digits (its UTC time of day is stored). Everything else is refused with `VALIDATION_FAILED` / 400 and the field code `invalid_time`, naming the field, on insert, update, a multi-row update and `engine.validate`, before anything is written. + +Refused now, where they were accepted: + +- **A time of day with a zone suffix**: `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"`, `"10:00:00.250Z"`. A `time` field carries no zone. The refusal has its own sentence, which `@objectstack/spec`'s validation-message catalog now carries in all four locales (`invalid_time_zoned`; English: "… is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant"). The wire code stays `invalid_time`. +- **An instant the rule does not read as a time of day**: an extended year (`"+010000-01-01T10:00:00Z"`, or a `Date` of it), an instant whose UTC year is 10000 (`"9999-12-31T23:00:00-02:00"`), a day that does not exist (`"2026-02-30T10:00:00Z"`), and a spelling the `datetime` arm already refuses (`"2026-07-15 10:00Z"`, a space and a zone; `"2026-07-15t10:00:00z"`, lower case). + +What a caller sees, before and after, through `POST /api/v1/data/:object` and a read-back, the process in America/New_York, PostgreSQL 16 at `Asia/Shanghai`: + +| written to a `time` | memory | SQLite | PostgreSQL | now, on all three | +|:--|:--|:--|:--|:--| +| `"+010000-01-01T10:00:00Z"`, `"9999-12-31T23:00:00-02:00"` | 201, read back as written | the same | 500 `DATABASE_ERROR` | 400 `invalid_time` | +| `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"` | 201, read back as written | the same | 201, read back `"10:00:00"` | 400 `invalid_time`, the zone sentence | +| `"2026-07-15 10:00Z"` | 201, `"10:00:00"` | the same | the same | 400 `invalid_time` | + +**Who is affected.** A caller that writes a `time` field as a string with a `Z` or an offset, or as an out-of-range instant: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row already stored with such a value keeps it; nothing rewrites it. PostgreSQL stored a zone-suffixed time of day as its bare wall clock, so only a memory or SQLite deployment can hold one. An update that omits the field is not affected; one that sends the old value back is refused, naming the field. A `time` field whose literal `defaultValue` carries a `Z` or an offset has each insert that falls back to that default refused the same way. The server import (`POST /api/v1/data/:object/import`) turns a `time` cell into `HH:MM:SS` itself before the write, and already refused a zone-suffixed time-of-day cell, so its cells are unchanged. + +**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST: + +- a bare wall clock: `"10:00"` and `"10:00:00"` read back `"10:00:00"`, `"10:00:00.250"` reads back `"10:00:00.250"`; +- a full ISO instant with a four-digit year, stored as its UTC time of day: `"2026-07-15T10:00:00Z"` and `"2026-07-15T18:00:00+08:00"` read back `"10:00:00"`; +- a `Date` with a four-digit year, still accepted; an epoch-millisecond number, a `{placeholder}` and an out-of-range clock (`"25:00"`), still refused with `invalid_time`; +- every `date` and `datetime` value, and every filter comparand. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index b86b1780e44..402a74d6d55 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -465,9 +465,11 @@ business_hours_start: **Storage format:** `HH:MM:SS`, gaining a `.fff` millisecond suffix **only** when the milliseconds are non-zero (`14:30:00`, but `14:30:00.100`). Input accepts -`HH:MM` or `HH:MM:SS` (with an optional fractional part and `Z`/offset); `14:30` -is completed to `14:30:00`, so one wall clock can never split into several stored -values. A `Date`, an epoch, or a full timestamp folds to its **UTC** time-of-day. +`HH:MM` or `HH:MM:SS`, with an optional fractional part and no zone: `14:30Z` or +`14:30+08:00` is refused with `invalid_time` (drop the suffix, or use a `datetime` +field for an instant). `14:30` is completed to `14:30:00`, so one wall clock can +never split into several stored values. A `Date` or a full ISO 8601 timestamp with +a four-digit year folds to its **UTC** time-of-day; an epoch number is refused. A `time` is a wall-clock value, not an instant: it is validated as a time-of-day, not parsed as a date. diff --git a/packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts b/packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts new file mode 100644 index 00000000000..66685e860ff --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20671] What the `time` write door admits, this driver stores as the wall + * clock it names, and reads back identically. + * + * The engine's record validator now refuses a time of day with a `Z` or an + * offset (`"10:00Z"`, `"10:00+08:00"`) and an instant the `time` rule does not + * read (`"+010000-01-01T10:00:00Z"`), with `VALIDATION_FAILED` / `invalid_time` + * before any driver write (`packages/objectql/src/engine-time-write-zone-less.test.ts`). + * Measured on the base through REST over this driver, the process in + * America/New_York, both were stored verbatim here: `"10:00Z"` read back + * `"10:00Z"`, while PostgreSQL read the same write back as `"10:00:00"`. The + * refusal is therefore not this driver's. What it owes is the other half: a + * plain `"10:00"` / `"10:00:00"`, and an ISO instant with a four-digit UTC + * year, are stored as `HH:MM:SS` and found by it, with the process in a zone + * that is not UTC so a host-zone reading would show. SQLite and PostgreSQL + * read the same values back the same way in + * `packages/rest/src/data-temporal-write-real-day-iso.test.ts`. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; + +const OBJECT = 'schedule_time_20671'; +const FIELDS = { slot: { type: 'time' } }; +const HOST_ZONE = 'America/New_York'; + +/** Each spelling the `time` write door admits → the wall clock it is stored and read back as. */ +const ADMITTED: ReadonlyArray = [ + ['hh-mm', '10:00', '10:00:00'], + ['hh-mm-ss', '10:00:00', '10:00:00'], + ['fraction', '10:00:00.250', '10:00:00.250'], + ['utc-instant', '2026-07-15T10:00:00Z', '10:00:00'], + ['offset-instant', '2026-07-15T18:00:00+08:00', '10:00:00'], + ['naive-instant', '2026-07-15 10:00', '10:00:00'], + ['a-date', new Date(Date.UTC(2026, 6, 15, 10)), '10:00:00'], +]; + +const originalTz = process.env.TZ; + +describe('[#20671] every spelling the time write door admits is stored as its wall clock and read back identically', () => { + let driver: InMemoryDriver; + + beforeAll(async () => { + process.env.TZ = HOST_ZONE; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE); + driver = new InMemoryDriver({}); + await driver.connect(); + await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS }); + for (const [id, slot] of ADMITTED) await driver.create(OBJECT, { id, slot }); + await driver.create(OBJECT, { id: 'before', slot: '09:59:59' }); + }); + + afterAll(() => { + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + }); + + it('reads each one back as its wall clock', async () => { + for (const [id, , stored] of ADMITTED) { + expect((await driver.findOne(OBJECT, { where: { id } }))?.slot, id).toBe(stored); + } + }); + + it('finds each one by the wall clock it names, and orders it after the second before', async () => { + const ids = async (where: Record) => + (await driver.find(OBJECT, { where })).map((r) => r.id as string).sort(); + const ten = ADMITTED.filter(([, , stored]) => stored === '10:00:00').map(([id]) => id).sort(); + expect(await ids({ slot: { $eq: '10:00:00' } })).toEqual(ten); + expect(await ids({ slot: { $eq: '10:00' } }), 'the HH:MM spelling').toEqual(ten); + expect(await ids({ slot: { $gt: '09:59:59', $lt: '10:00:00.100' } })).toEqual(ten); + }); +}); diff --git a/packages/objectql/src/engine-time-write-zone-less.test.ts b/packages/objectql/src/engine-time-write-zone-less.test.ts new file mode 100644 index 00000000000..9329c78aa20 --- /dev/null +++ b/packages/objectql/src/engine-time-write-zone-less.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20671] A `time` field is a zone-less wall clock, and the record validator's + * `time` arm asks `@objectstack/core`'s one rule (`isUninterpretableTemporalComparand`, + * the one the `time` comparand door asks). A value that rule does not read is + * refused with `VALIDATION_FAILED` and the field's `invalid_time` code on insert, + * update, a multi-row update and the dry-run `validate`, before any driver + * write. A zone suffix on a time of day is refused in its own sentence (drop + * the suffix, or use a `datetime` field for an instant). + * + * Measured on the base (`fa0a4b661`) through `POST /api/v1/data/:object` and a + * read-back through `POST /api/v1/data/:object/query`, the process in + * America/New_York and PostgreSQL 16 at Asia/Shanghai: + * + * | written to a `time` | memory | SQLite | PostgreSQL | now | + * |:--|:--|:--|:--|:--| + * | `"+010000-01-01T10:00:00Z"` | 201, read back verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 | + * | `"9999-12-31T23:00:00-02:00"` (UTC year 10000) | 201, verbatim | 201, verbatim | 500 | 400 | + * | `"10:00Z"`, `"10:00+08:00"`, `"10:00:00+0800"` | 201, verbatim | 201, verbatim | 201, `"10:00:00"` | 400, the zone sentence | + * | `"10:00:00.250Z"` | 201, verbatim | 201, verbatim | 201, `"10:00:00.250"` | 400, the zone sentence | + * | `"2026-07-15 10:00Z"` (a space and a zone) | 201, `"10:00:00"` | the same | the same | 400 | + * | `"10:00"`, `"10:00:00"` | 201, `"10:00:00"` | the same | the same | unchanged | + * | `"2026-07-15T10:00:00Z"`, `"2026-07-15T18:00:00+08:00"` | 201, `"10:00:00"` | the same | the same | unchanged | + * | `"07/15/2026 10:00"`, `"{now}"`, the number `36000000` | 400 `invalid_time` | the same | the same | unchanged | + * + * The REST door over SQLite and PostgreSQL is + * `packages/rest/src/data-temporal-write-real-day-iso.test.ts`; the memory + * driver's half is `memory-20671-time-write-zone-less.test.ts`. This file's + * driver records writes and stores nothing, because the refusal sits in front + * of every driver. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { isUninterpretableTemporalComparand } from '@objectstack/core'; +import { renderValidationMessage } from '@objectstack/spec/system'; +import { ObjectQL } from './engine.js'; + +const schedule = { + name: 'schedule', + label: 'Schedule', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + customer_id: { name: 'customer_id', type: 'text' as const }, + slot: { name: 'slot', label: 'Slot', type: 'time' as const }, + }, +}; + +/** A time of day with a `Z` or an offset: the zone sentence. Each was a 201 at the base, stored two ways by backend. */ +const ZONED: readonly string[] = [ + '10:00Z', + '10:00+08:00', + '10:00:00Z', + '10:00:00+0800', + '10:00:00.250Z', + '10:00-05:30', + '23:59:59-00:00', + ' 10:00Z ', + '10:00z', +]; + +/** An instant the `time` rule does not read: the plain sentence. Each was a 201 (or a 500 on PostgreSQL) at the base. */ +const UNREAD_INSTANT: readonly unknown[] = [ + '+010000-01-01T10:00:00Z', + '9999-12-31T23:00:00-02:00', + '-000001-01-01T10:00:00Z', + '2026-02-30T10:00:00Z', + '2026-07-15 10:00Z', + '2026-07-15t10:00:00z', + new Date(Date.parse('+010000-01-01T10:00:00Z')), +]; + +/** Refused at the base already, kept refused, in the plain sentence. */ +const STILL_REFUSED: readonly unknown[] = [ + '25:00', + '25:00Z', + '14:60', + 'not-a-time', + '14', + '07/15/2026 10:00', + 'x2026-07-15T10:00:00Z', + '{now}', + 36000000, +]; + +/** A wall clock, or an ISO instant with a four-digit UTC year — each reaches the driver as written. */ +const ACCEPTED: readonly unknown[] = [ + '10:00', + '10:00:00', + '10:00:00.250', + ' 10:00 ', + '00:00', + '23:59:59.999', + '2026-07-15T10:00:00Z', + '2026-07-15T18:00:00+08:00', + '2026-07-15T10:00:00-0530', + '2026-07-15T10:00', + '2026-07-15 10:00', + new Date(Date.UTC(2026, 6, 15, 10)), +]; + +const REFUSED: readonly unknown[] = [...ZONED, ...UNREAD_INSTANT, ...STILL_REFUSED]; + +/** A driver that records every write, and answers none. */ +function makeRecordingDriver() { + const writes: Record[] = []; + const driver: any = { + name: 'recording', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find() { return []; }, + async findOne() { return { id: 'r1' }; }, + async count() { return 0; }, + async aggregate() { return []; }, + async create(_o: string, data: Record) { writes.push(data); return { ...data }; }, + async update(_o: string, id: string, data: Record) { writes.push(data); return { ...data, id }; }, + async updateMany(_o: string, _ast: unknown, data: Record) { writes.push(data); return 0; }, + async delete() { return true; }, + async deleteMany() { return 0; }, + async bulkCreate(_o: string, batch: Record[]) { writes.push(...batch); return batch; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, writes }; +} + +const refusalOf = async (p: Promise) => + p.then(() => null, (e: any) => e as Error & { code?: string; fields?: Array<{ field: string; code: string; message: string }> }); + +const labelOf = (value: unknown) => (value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value)); + +/** The sentence a refusal of `value` carries: the zone sentence for a zone-suffixed time of day, else the plain one. */ +const sentenceFor = (value: unknown) => + renderValidationMessage({ + messageKey: ZONED.includes(value as string) ? 'invalid_time_zoned' : 'invalid_time', + label: 'Slot', + field: 'slot', + }); + +describe('[#20671] the time write door — a zone-less wall clock, or an ISO instant with a four-digit UTC year, or VALIDATION_FAILED before any write', () => { + let engine: ObjectQL; + let writes: Record[]; + + beforeEach(async () => { + const rec = makeRecordingDriver(); + writes = rec.writes; + engine = new ObjectQL(); + engine.registerDriver(rec.driver, true); + await engine.init(); + engine.registry.registerObject(schedule, 'test'); + }); + + const doors = (value: unknown) => [ + ['insert', () => engine.insert('schedule', { id: 'n1', customer_id: 'c1', slot: value })], + ['update', () => engine.update('schedule', { id: 'r1', slot: value })], + ['multi-row update', () => engine.update('schedule', { slot: value }, { where: { customer_id: 'c1' }, multi: true })], + ] as const; + + it('refuses a zone suffix, an unread instant and the junk class on insert, update and a multi-row update, naming the field with invalid_time — and writes nothing', async () => { + for (const value of REFUSED) { + for (const [door, call] of doors(value)) { + const err = await refusalOf(call()); + expect(err, `${door}, ${labelOf(value)}`).not.toBeNull(); + expect(err!.code, `${door}, ${labelOf(value)}`).toBe('VALIDATION_FAILED'); + expect(err!.fields?.map((f) => [f.field, f.code]), `${door}, ${labelOf(value)}`).toEqual([['slot', 'invalid_time']]); + } + } + expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0); + }); + + it('a zone suffix on a time of day is refused in the zone sentence, every other refusal in the plain one', async () => { + expect(sentenceFor('10:00Z'), 'the two sentences differ').not.toBe(sentenceFor('25:00')); + for (const value of REFUSED) { + const err = await refusalOf(engine.insert('schedule', { id: 'n1', slot: value })); + expect(err!.fields?.[0]?.message, labelOf(value)).toBe(sentenceFor(value)); + } + }); + + it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => { + for (const value of REFUSED) { + const verdict = await engine.validate('schedule', { slot: value }); + expect(verdict.valid, labelOf(value)).toBe(false); + expect(verdict.results[0]!.errors, labelOf(value)).toEqual([expect.objectContaining({ field: 'slot', code: 'invalid_time' })]); + } + for (const value of ACCEPTED) { + expect((await engine.validate('schedule', { slot: value })).valid, labelOf(value)).toBe(true); + } + }); + + it('accepts a wall clock, an ISO instant with a four-digit UTC year and a Date on every door — the POSITIVE CONTROL', async () => { + for (const value of ACCEPTED) { + for (const [door, call] of doors(value)) { + const before = writes.length; + await expect(call(), `${door}, ${labelOf(value)}`).resolves.toBeDefined(); + expect(writes.length, `${door}, ${labelOf(value)} reached the driver`).toBe(before + 1); + expect(writes.at(-1)!.slot, `${door}, ${labelOf(value)} reached it as written`).toEqual(value); + } + } + }); + + it('one rule: a string is refused as a written time exactly when core refuses it as a time comparand, save the two write-only refusals', async () => { + const corpus = [...REFUSED, ...ACCEPTED].filter((v): v is string => typeof v === 'string'); + // The comparand door exempts a `{placeholder}` (its resolver's vocabulary); + // the write door refuses it, because a placeholder is not a value. + const writeOnly = new Set(['{now}']); + for (const value of corpus) { + const refused = (await refusalOf(engine.insert('schedule', { id: 'n1', slot: value }))) !== null; + const expected = writeOnly.has(value) || isUninterpretableTemporalComparand('time', value); + expect(refused, value).toBe(expected); + } + // A number is the other write-only refusal: epoch milliseconds are a + // comparand, never a written time. + expect(isUninterpretableTemporalComparand('time', 36000000), 'core reads the number').toBe(false); + expect(await refusalOf(engine.insert('schedule', { id: 'n1', slot: 36000000 })), 'the write door refuses it').not.toBeNull(); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index a548a4fe237..1bb10c5578c 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { FieldSchema } from '@objectstack/spec/data'; +import { renderValidationMessage } from '@objectstack/spec/system'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError } from './record-validator.js'; /** @@ -51,7 +52,7 @@ describe('validateRecord — required + autonumber exemption', () => { describe('validateRecord — time field accepts time-of-day', () => { const schema = { fields: { at: { type: 'time' } } }; - for (const v of ['14:30', '09:05:30', '23:59', '00:00:00', '14:30:00Z', '14:30:00.500', '08:15:00+02:00']) { + for (const v of ['14:30', '09:05:30', '23:59', '00:00:00', '14:30:00.500']) { it(`accepts ${v}`, () => { expect(() => validateRecord(schema, { at: v }, 'insert')).not.toThrow(); }); @@ -61,6 +62,24 @@ describe('validateRecord — time field accepts time-of-day', () => { expect(() => validateRecord(schema, { at: '2026-06-17T14:30:00Z' }, 'insert')).not.toThrow(); }); + // [#20671] A time field is a zone-less wall clock. These two were accepted + // here before, and were stored verbatim on memory and SQLite and as + // `14:30:00` / `08:15:00` on PostgreSQL. Refused now with `invalid_time`, in + // the sentence that says to drop the suffix or use a datetime field. + for (const v of ['14:30:00Z', '08:15:00+02:00']) { + it(`rejects the zone-suffixed ${v} with invalid_time and the zone sentence`, () => { + let err: ValidationError | undefined; + try { + validateRecord(schema, { at: v }, 'insert'); + } catch (e) { + err = e as ValidationError; + } + expect(err, v).toBeInstanceOf(ValidationError); + expect(err!.fields.map((f) => [f.field, f.code])).toEqual([['at', 'invalid_time']]); + expect(err!.fields[0]!.message).toBe(renderValidationMessage({ messageKey: 'invalid_time_zoned', label: 'at', field: 'at' })); + }); + } + for (const v of ['25:00', '14:60', 'not-a-time', '14']) { it(`rejects ${v}`, () => { expect(() => validateRecord(schema, { at: v }, 'insert')).toThrow(/must be a valid time/i); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 288aee3caf0..eb565fe32fe 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -58,6 +58,9 @@ * a `date` string also carries a leading `YYYY-MM-DD` (#20481); * a string's leading day exists, and a `datetime` string is * an ISO 8601 spelling (#20525) — refused, never rolled over + * - time: a zone-less wall clock `HH:MM[:SS[.f]]`, or an ISO instant + * with a four-digit UTC year (#20671); a `Z` / offset suffix + * on a time of day is refused with its own sentence * * System-injected fields (`id`, `created_at`, `created_by`, * `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly` @@ -86,6 +89,7 @@ import { NON_TEXT_STORED_VALUE_TYPES, percentScaleOf, parseNumericString, + classifyFilterToken, } from '@objectstack/spec/data'; import type { FieldErrorCode } from '@objectstack/spec/api'; import { isUninterpretableTemporalComparand } from '@objectstack/core'; @@ -840,6 +844,21 @@ function valueShapeDetail(error: { issues: ReadonlyArray<{ code: string; message return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value shape'; } +/** + * [#20671] Is this a time of day with a zone suffix — `"10:00Z"`, + * `"10:00:00+08:00"`, `"10:00-0530"` — whose wall clock is one the `time` + * rule reads once the suffix is dropped? It chooses the sentence of an + * `invalid_time` refusal, never the verdict: a `time` field carries no zone + * (ADR-0053 D-C1), so the sentence says to drop the suffix or to use a + * `datetime` field for an instant. The wall-clock half is judged by core's + * rule, the one the verdict asks, so `"25:00Z"` gets the plain sentence. + */ +function isZonedTimeOfDay(value: unknown): boolean { + if (typeof value !== 'string') return false; + const m = /^(\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(?:[Zz]|[+-]\d{2}:?\d{2})$/.exec(value.trim()); + return m !== null && !isUninterpretableTemporalComparand('time', m[1]); +} + function validateOne( name: string, def: FieldDef, @@ -1264,22 +1283,34 @@ function validateOne( } // ── time (time-of-day) ────────────────────────────────────────── - // A `Field.time` is a wall-clock time, NOT an instant — `Date.parse('14:30')` - // is NaN, so reusing the date branch rejected every valid time. Accept - // `HH:MM`, `HH:MM:SS`, optional fractional seconds and an optional Z/offset; - // also accept a Date or a full ISO datetime (callers that send a timestamp - // for a time field). + // A `Field.time` is a zone-less wall clock, NOT an instant (ADR-0053 D-C1). + // [#20671] Judged by `@objectstack/core`'s one rule, the one the date / + // datetime arm above asks, so a value is refused as a written `time` exactly + // when it is refused as a `time` comparand. What that rule reads: + // + // - a bare `HH:MM[:SS[.fraction]]` in range, stored as `HH:MM:SS`; + // - an instant in one of the ISO 8601 spellings a `datetime` is written in, + // on a calendar day that exists, whose UTC year has four digits; the + // storage rule keeps its UTC time of day. A `Date` is judged by that year. + // + // It replaced a private pair of patterns that admitted what the rule does not + // read. A time of day with a `Z` or an offset (`"10:00Z"`, `"10:00+08:00"`) + // was stored verbatim on memory and SQLite and as `"10:00:00"` on + // PostgreSQL, so one write read back two ways. A `hasDate` test with no + // anchor matched inside `"+010000-01-01T10:00:00Z"`, so an extended-year + // instant was stored verbatim on memory and SQLite and was a 500 on + // PostgreSQL. Each is refused now with `invalid_time`, never a 500. A zone + // suffix on a time of day gets its own sentence, which says what to do: drop + // the suffix, or use a `datetime` field for an instant. + // + // `readable` holds the write door to what the comparand door exempts. A + // number is refused as a written `time` (a comparand may be epoch + // milliseconds), and so is a `{placeholder}`, which is filter vocabulary and + // not a value. A blank is missing before this arm. if (t === 'time') { - if (value instanceof Date) return null; - if (typeof value === 'string') { - const timeOfDay = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/; - // Accept a valid time-of-day, OR a full datetime that carries a real date - // component. NOT a bare `Date.parse` check — `Date.parse('14:60')` returns - // a (bogus) number in Node, which would let malformed times through. - const hasDate = /\d{4}-\d{2}-\d{2}/.test(value); - if (timeOfDay.test(value.trim()) || (hasDate && !Number.isNaN(Date.parse(value)))) return null; - } - return fail('invalid_time'); + const readable = value instanceof Date || (typeof value === 'string' && classifyFilterToken(value) === null); + if (readable && !isUninterpretableTemporalComparand(t, value)) return null; + return fail('invalid_time', undefined, isZonedTimeOfDay(value) ? 'invalid_time_zoned' : 'invalid_time'); } // ── select / radio (single-value) ─────────────────────────────── diff --git a/packages/rest/src/data-temporal-write-real-day-iso.test.ts b/packages/rest/src/data-temporal-write-real-day-iso.test.ts index 99c04b348a7..2c0345de061 100644 --- a/packages/rest/src/data-temporal-write-real-day-iso.test.ts +++ b/packages/rest/src/data-temporal-write-real-day-iso.test.ts @@ -49,6 +49,25 @@ * instant answered 3 of 3 on SQLite. Each is `400 INVALID_FILTER` now, and the * same wall clock as a 2026 instant still answers 2 / 1. * + * ## [#20671] The same class as a WRITTEN time + * + * Measured on the base (`fa0a4b661`) through this door, a create then a + * read-back, the process in America/New_York, PostgreSQL 16 at Asia/Shanghai: + * + * | written to `slot` | SQLite | PostgreSQL 16 | + * |:--|:--|:--| + * | `"+010000-01-01T10:00:00Z"`, `"9999-12-31T23:00:00-02:00"` | 201, read back verbatim | 500 `DATABASE_ERROR` | + * | `"10:00Z"`, `"10:00+08:00"` | 201, read back verbatim | 201, `"10:00:00"` | + * | `"10:00"`, `"10:00:00"`, `"2026-07-15T10:00:00Z"` | 201, `"10:00:00"` | the same | + * + * (InMemoryDriver answered as SQLite.) The `time` write arm now asks the same + * core rule as the comparand door: the first four are `400 VALIDATION_FAILED` + * / `invalid_time` with no write, a zone suffix on a time of day in its own + * sentence, and the controls read back unchanged on both cells. The + * engine-level pin is `packages/objectql/src/engine-time-write-zone-less.test.ts`, + * and the memory driver's half is + * `packages/drivers/driver-memory/src/memory-20671-time-write-zone-less.test.ts`. + * * ## The dialect axis of THIS file * * The SQLite cell always runs. The PostgreSQL cell runs where @@ -65,6 +84,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { renderValidationMessage } from '@objectstack/spec/system'; import { RestServer } from './rest-server'; const OBJECT = 'rest_temporal_20525'; @@ -265,6 +285,50 @@ for (const cell of CELLS) { } }); + it('[#20671] a zone-suffixed time of day and an instant with no four-digit UTC year are 400 VALIDATION_FAILED / invalid_time on create and on PATCH — and a plain wall clock reads back identically', async () => { + const readSlot = async (id: string) => + (await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { id } })).body.records[0]?.slot; + const created0 = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 't0', customer_id: 'ct', slot: '09:00' }); + expect(created0.status, JSON.stringify(created0.body)).toBe(201); + const before = writes.n; + for (const [value, zoned] of [ + ['+010000-01-01T10:00:00Z', false], + ['9999-12-31T23:00:00-02:00', false], + ['10:00Z', true], + ['10:00+08:00', true], + ] as const) { + for (const [door, res] of [ + ['create', await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'refused-t', customer_id: 'ct', slot: value })], + ['PATCH', await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 't0' }, { slot: value })], + ] as const) { + expect(res.status, `${door} ${value}: ${JSON.stringify(res.body)}`).toBe(400); + expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res.body.fields.map((x: any) => [x.field, x.code]), `${door} ${value}`).toEqual([['slot', 'invalid_time']]); + const sentence = renderValidationMessage({ messageKey: zoned ? 'invalid_time_zoned' : 'invalid_time', label: res.body.fields[0].label, field: 'slot' }); + expect(res.body.fields[0].message, `${door} ${value}: the ${zoned ? 'zone' : 'plain'} sentence`).toBe(sentence); + } + } + expect(writes.n - before, 'no write — every refusal precedes the driver').toBe(0); + expect(await readSlot('t0'), 't0 kept its wall clock').toBe('09:00:00'); + expect(await readSlot('refused-t'), 'no row was created').toBeUndefined(); + + for (const [i, [value, stored]] of ([ + ['10:00', '10:00:00'], + ['10:00:00', '10:00:00'], + ['10:00:00.250', '10:00:00.250'], + // A full ISO instant with a four-digit year stays admitted, and keeps its UTC time of day. + ['2026-07-15T10:00:00Z', '10:00:00'], + ['2026-07-15T18:00:00+08:00', '10:00:00'], + ] as const).entries()) { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: `t${i + 1}`, customer_id: 'ct', slot: value }); + expect(created.status, `create ${value}: ${JSON.stringify(created.body)}`).toBe(201); + expect(await readSlot(`t${i + 1}`), `read back ${value}`).toBe(stored); + } + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 't0' }, { slot: '10:00' }); + expect(patched.status, JSON.stringify(patched.body)).toBe(200); + expect(await readSlot('t0')).toBe('10:00:00'); + }); + it('[#20549] the same values as filter comparands are 400 INVALID_FILTER naming the field, before any read — and the leap day and the ISO spellings still find their rows', async () => { // The rows the base's misreadings matched: March 2 (a rolled-over // February 30) and 14:00Z (10:00 in the process zone). diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index 9304d54f74b..e3727963e34 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -103,6 +103,10 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_date: '{{label}} must be a valid date (ISO-8601)', invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)', invalid_time: '{{label}} must be a valid time (HH:MM or HH:MM:SS)', + // `invalid_time`'s second sentence: a time of day written with a `Z` or an + // offset. A `time` field is a zone-less wall clock, so the sentence says + // what to do instead. + invalid_time_zoned: '{{label}} is a time of day with no time zone: drop the Z or offset (HH:MM or HH:MM:SS), or use a datetime field for an instant', invalid_option: '{{label}} must be one of: {{allowed}}', reference_not_found: '{{label}}: no {{target}} record has id "{{value}}"', invalid_option_value: '{{label}}: "{{value}}" is not one of: {{allowed}}', @@ -149,6 +153,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_date: '{{label}}必须是有效的日期(ISO-8601)', invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)', invalid_time: '{{label}}必须是有效的时间(HH:MM 或 HH:MM:SS)', + invalid_time_zoned: '{{label}}是不带时区的时刻:请去掉 Z 或时区偏移(HH:MM 或 HH:MM:SS),表示时间点请改用日期时间字段', invalid_option: '{{label}}必须是以下值之一:{{allowed}}', reference_not_found: '{{label}}:不存在 id 为“{{value}}”的{{target}}记录', invalid_option_value: '{{label}}:“{{value}}”不在允许的取值范围内:{{allowed}}', @@ -188,6 +193,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください', invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください', invalid_time: '{{label}}は有効な時刻(HH:MM または HH:MM:SS)を入力してください', + invalid_time_zoned: '{{label}}はタイムゾーンを持たない時刻です。Z やオフセットを外す(HH:MM または HH:MM:SS)か、時点を表すには日時フィールドを使ってください', invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}', reference_not_found: '{{label}}:id が「{{value}}」の{{target}}レコードは存在しません', invalid_option_value: '{{label}}:「{{value}}」は指定できません(指定可能:{{allowed}})', @@ -227,6 +233,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)', invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)', invalid_time: '{{label}} debe ser una hora válida (HH:MM o HH:MM:SS)', + invalid_time_zoned: '{{label}} es una hora del día sin zona horaria: quite la Z o el desfase (HH:MM o HH:MM:SS), o use un campo de fecha y hora para un instante', invalid_option: '{{label}} debe ser uno de: {{allowed}}', reference_not_found: '{{label}}: ningún registro de {{target}} tiene el id «{{value}}»', invalid_option_value: '{{label}}: «{{value}}» no es uno de: {{allowed}}',