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
36 changes: 36 additions & 0 deletions .changeset/20671-time-write-zone-less.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's time arm: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes. `packages/spec` gains one sentence in the built-in validation-message catalog (`invalid_time_zoned`, a rendering variant of the existing `invalid_time` wire code, which is unchanged). A stored row keeps whatever it holds, and which wall clock a zone-suffixed time of day meant is not something a ledger entry can decide. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

**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.
8 changes: 5 additions & 3 deletions content/docs/protocol/objectql/types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<readonly [string, unknown, string]> = [
['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<string, unknown>) =>
(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);
});
});
215 changes: 215 additions & 0 deletions packages/objectql/src/engine-time-write-zone-less.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = [];
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<string, unknown>) { writes.push(data); return { ...data }; },
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) { writes.push(data); return 0; },
async delete() { return true; },
async deleteMany() { return 0; },
async bulkCreate(_o: string, batch: Record<string, unknown>[]) { writes.push(...batch); return batch; },
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, writes };
}

const refusalOf = async (p: Promise<unknown>) =>
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<string, unknown>[];

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();
});
});
Loading
Loading