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
43 changes: 43 additions & 0 deletions .changeset/20525-temporal-write-real-day-iso.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@objectstack/objectql": minor
---

fix(objectql)!: a `date` / `datetime` string is written on a calendar day that exists, and a `datetime` string in an ISO 8601 spelling, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026-02-30T10:00:00Z"` is no longer stored as March 2, and `"07/15/2026 10:00"` is no longer read in the server's zone (#20525)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's date / datetime arm: no authorable key, spelling or stored shape of metadata moves, `packages/spec` is untouched, and a stored row keeps whatever it holds. What is refused is a temporal string naming a day that does not exist, or a datetime string outside the ISO spellings, and which instant such a string meant (a host zone, a locale's day order) is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (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 `date` and a `datetime` field accept 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).

Two kinds of string are now refused with `VALIDATION_FAILED` / 400, the field code `invalid_date` and its existing message ("must be a valid date (ISO-8601)" / "must be a valid datetime (ISO-8601)"), naming the field, before anything is written:

- **A day that does not exist**, as a `date` or as the day part of a `datetime`: `"2026-02-30"`, `"2026-02-29"` (2026 is not a leap year), `"2026-04-31"`, `"2026-02-30T10:00:00Z"`. `"2028-02-29"` is a real day and is accepted.
- **A `datetime` string in any spelling but these ISO 8601 ones**, after trimming: `YYYY-MM-DD` (midnight UTC); `YYYY-MM-DDTHH:MM[:SS[.fraction]]` followed by `Z`, a `±HH:MM` or `±HHMM` offset, or nothing (a zone-naive wall clock is UTC, ADR-0074); and `YYYY-MM-DD HH:MM[:SS[.fraction]]` with no zone (UTC the same way). Refused now, for example: `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"`, `"07/08/2026"`, `"2026-07-15 10:00 PM"`, `"Wed, 15 Jul 2026 10:00:00 GMT"`, `"2026"`, `"2026-07"`, `"2026-07-15t10:00:00z"` (lower case), `"+002026-07-15T10:00:00Z"`, and a space-separated time carrying a zone, `"2026-07-15 10:00:00+08:00"` (write it with a `T`).

The fix is to send the value in one of those spellings — `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` or `"2026-07-15 10:00"` — or a JS `Date`. No other spelling is read for you, on purpose: `07/08/2026` is July 8 in one locale and August 7 in another, and a wall clock with no zone was read in whatever zone the server process ran in.

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 | memory | SQLite | PostgreSQL | now, on all three |
|:--|:--|:--|:--|:--|
| `date` `"2026-02-30"` | 201, read back `"2026-02-30"`, a day that does not exist | the same | 500 `DATABASE_ERROR` | 400 `invalid_date` |
| `datetime` `"2026-02-30T10:00:00Z"` | 201, read back `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 `invalid_date` |
| `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"`, the server process's zone | the same | the same | 400 `invalid_date` |
| `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"`, month-first in the process zone | the same | the same | 400 `invalid_date` |
| `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 `invalid_date` |

The stored instant of a non-ISO `datetime` was a property of the deployment host: the same request landed hours apart on two servers.

What changes: the record validator's `date` / `datetime` arm asks two more questions of a string, on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write. Does its leading `YYYY-MM-DD` name a day that exists (month 01..12, day up to that month's length, February 29 only in a leap year)? And, for a `datetime`, is it one of the ISO spellings above? A `Date` names a real instant and keeps its answer.

**Who is affected.** A caller that writes a `date` or `datetime` field as a string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. A row that already holds such a value keeps it, since nothing re-reads stored rows. An update that omits the field is not affected; one that sends the old string back is refused, so re-write it in an ISO spelling. The server import (`POST /api/v1/data/:object/import`) turns a `datetime` cell into ISO text itself before the write, so its `datetime` cells reach this check already converted; a `date` cell naming a day that does not exist (`2026-02-30`) is now a per-row `invalid_date`, where memory and SQLite stored it and PostgreSQL failed the row.

**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:

- a real leap day: `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"`;
- each ISO spelling above, stored as the same instant: `"2026-07-15T10:00:00Z"`, `"2026-07-15T10:00:00+08:00"` (`"2026-07-15T02:00:00.000Z"`), `"2026-07-15 10:00"` and `"2026-07-15T10:00"` (`"2026-07-15T10:00:00.000Z"`, UTC, not the host zone), `"2026-07-15"` (`"2026-07-15T00:00:00.000Z"`);
- a `date` string with a leading real `YYYY-MM-DD`, still stored as that day;
- a `Date`, still accepted; an epoch-millisecond number, still refused with `invalid_date`;
- the year range 0001..9999;
- every `time` value, and every filter comparand (`where`, a per-aggregation `filter`, `having`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20525] What the temporal write door admits, this driver stores as written:
* a leap day as that day, and each ISO `datetime` spelling as the instant it
* names — the same instant whatever the server process's zone.
*
* The engine's record validator now refuses a string whose leading
* `YYYY-MM-DD` names a day that does not exist, and a `datetime` string outside
* the ISO spellings the storage rule reads the same on every host, with
* `VALIDATION_FAILED` / `invalid_date` before any driver write
* (`packages/objectql/src/engine-temporal-write-real-day-iso.test.ts`). The
* refusal is therefore not this driver's; what it owes is the other half: every
* spelling the door admits is STORED as the value it names and found by it,
* with the process in a zone that is not UTC so a host-zone reading would show.
*
* Measured on the base through REST over this driver, the process in
* America/New_York: `datetime` `"2026-02-30T10:00:00Z"` was stored as
* `"2026-03-02T10:00:00.000Z"`, `"2026/07/15 10:00"` as
* `"2026-07-15T14:00:00.000Z"`, and `date` `"2026-02-30"` verbatim. The engine
* refuses those now; the REST door over SQL is
* `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 = 'ledger_temporal_20525';
const FIELDS = { placed_on: { type: 'date' }, opened_at: { type: 'datetime' } };
const HOST_ZONE = 'America/New_York';

/** Each `datetime` spelling the write door admits → the instant it names, in UTC. */
const ADMITTED_DATETIME: ReadonlyArray<readonly [string, unknown, string]> = [
['leap', '2028-02-29T10:00:00Z', '2028-02-29T10:00:00.000Z'],
['utc', '2026-07-15T10:00:00Z', '2026-07-15T10:00:00.000Z'],
['offset', '2026-07-15T18:00:00+08:00', '2026-07-15T10:00:00.000Z'],
['naive-t', '2026-07-15T10:00', '2026-07-15T10:00:00.000Z'],
['naive-space', '2026-07-15 10:00:00', '2026-07-15T10:00:00.000Z'],
['a-date', new Date(Date.UTC(2026, 6, 15, 10)), '2026-07-15T10:00:00.000Z'],
];

const originalTz = process.env.TZ;

describe('[#20525] every temporal spelling the write door admits is stored as the value it names', () => {
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, opened_at] of ADMITTED_DATETIME) await driver.create(OBJECT, { id, opened_at });
await driver.create(OBJECT, { id: 'leap-day', placed_on: '2028-02-29' });
await driver.create(OBJECT, { id: 'before', placed_on: '2028-02-28', opened_at: '2026-07-15T09:59:59Z' });
});

afterAll(() => {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
});

it('reads each datetime back as its UTC instant, and the leap day as itself', async () => {
for (const [id, , instant] of ADMITTED_DATETIME) {
expect((await driver.findOne(OBJECT, { where: { id } }))?.opened_at, id).toBe(instant);
}
expect((await driver.findOne(OBJECT, { where: { id: 'leap-day' } }))?.placed_on).toBe('2028-02-29');
});

it('finds each one by the instant 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 july = ADMITTED_DATETIME.filter(([id]) => id !== 'leap').map(([id]) => id).sort();
expect(await ids({ opened_at: { $eq: '2026-07-15T10:00:00Z' } })).toEqual(july);
expect(await ids({ opened_at: { $gt: '2026-07-15T09:59:59Z', $lt: '2027-01-01' } })).toEqual(july);
expect(await ids({ placed_on: { $gt: '2028-02-28' } })).toEqual(['leap-day']);
});
});
194 changes: 194 additions & 0 deletions packages/objectql/src/engine-temporal-write-real-day-iso.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20525] A temporal string is written on a calendar day that exists, and a
* `datetime` string in an ISO 8601 spelling — or it is refused with
* `VALIDATION_FAILED` and the field's `invalid_date` code, on insert, update, a
* multi-row update and the dry-run `validate`, before any driver write. Never
* rolled over, never re-read in the server's zone.
*
* Measured on the base (`b2b6a0643`) through `POST /api/v1/data/:object` and a
* read-back, the process in America/New_York, PostgreSQL 16 at Asia/Shanghai:
*
* | written | memory | SQLite | PostgreSQL | now |
* |:--|:--|:--|:--|:--|
* | `date` `"2026-02-30"` | 201, `"2026-02-30"` | 201, `"2026-02-30"` | 500 `DATABASE_ERROR` | 400 |
* | `datetime` `"2026-02-30T10:00:00Z"` | 201, `"2026-03-02T10:00:00.000Z"` | the same | the same | 400 |
* | `datetime` `"2026/07/15 10:00"`, `"07/15/2026 10:00"`, `"15 July 2026 10:00"` | 201, `"2026-07-15T14:00:00.000Z"` (the process zone) | the same | the same | 400 |
* | `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"` (month-first, the process zone) | the same | the same | 400 |
* | `datetime` `"2026"` | 201, `"1970-01-01T00:00:02.026Z"` | the same | the same | 400 |
* | `date` `"2028-02-29"`, `datetime` `"2028-02-29T10:00:00Z"` (a leap day) | 201, as written | the same | the same | unchanged |
* | `datetime` `"2026-07-15 10:00"` (zone-naive ISO) | 201, `"2026-07-15T10:00:00.000Z"` (UTC, ADR-0074) | the same | the same | unchanged |
*
* The REST door over real drivers is
* `packages/rest/src/data-temporal-write-real-day-iso.test.ts`; the memory
* driver's half is `memory-20525-temporal-write-real-day-iso.test.ts`. This
* file's driver records writes and stores nothing, because the refusal sits in
* front of every driver — so these verdicts hold whatever the process zone.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { ObjectQL } from './engine.js';

const ledger = {
name: 'ledger',
label: 'Ledger',
fields: {
id: { name: 'id', type: 'text' as const, primaryKey: true },
customer_id: { name: 'customer_id', type: 'text' as const },
placed_on: { name: 'placed_on', type: 'date' as const },
opened_at: { name: 'opened_at', type: 'datetime' as const },
},
};

type Field = 'placed_on' | 'opened_at';

/** Arm 1 — a leading day that does not exist. Each was `Date.parse`-readable, and stored as written or rolled over at the base. */
const IMPOSSIBLE_DAY: ReadonlyArray<readonly [Field, string]> = [
['placed_on', '2026-02-30'],
['placed_on', '2026-02-29'],
['placed_on', '2026-04-31'],
['placed_on', '2026-02-30T10:00:00Z'],
['opened_at', '2026-02-30T10:00:00Z'],
['opened_at', '2026-02-30'],
['opened_at', '2026-02-30 10:00'],
['opened_at', '2026-02-29T10:00:00Z'],
['opened_at', '2026-04-31T10:00:00+08:00'],
];

/** Arm 2 — a `datetime` string in no ISO spelling the storage rule reads the same on every host. Each was a 201 at the base. */
const NON_ISO_DATETIME: readonly string[] = [
'2026/07/15 10:00',
'07/15/2026 10:00',
'15 July 2026 10:00',
'07/08/2026',
'2026-07-15 10:00 PM',
'Wed, 15 Jul 2026 10:00:00 GMT',
'2026',
'2026-07',
'2026-07-15t10:00:00z',
'+002026-07-15T10:00:00Z',
'2026-07-15 10:00Z',
'2026-07-15 10:00:00+08:00',
];

/** Refused at the base already — kept refused. */
const STILL_REFUSED: ReadonlyArray<readonly [Field, unknown]> = [
['opened_at', 'not-a-date'],
['opened_at', '2026-07-15T25:00:00Z'],
['opened_at', Date.UTC(2026, 6, 15, 10)],
['placed_on', '2026/07/15'],
];

/** The leap-day control and the ISO controls — each reaches the driver. */
const ACCEPTED: ReadonlyArray<readonly [Field, unknown]> = [
['placed_on', '2028-02-29'],
['placed_on', '2026-02-28'],
['placed_on', '2026-07-15'],
['placed_on', '2026-07-15T10:00:00Z'],
['placed_on', '2026-07-15 10:00'],
['placed_on', ' 2026-07-15'],
['placed_on', '0050-01-01'],
['placed_on', new Date(Date.UTC(2026, 6, 15, 10))],
['opened_at', '2028-02-29T10:00:00Z'],
['opened_at', '2026-07-15'],
['opened_at', '2026-07-15T10:00'],
['opened_at', '2026-07-15T10:00:00'],
['opened_at', '2026-07-15T10:00:00Z'],
['opened_at', '2026-07-15T10:00:00.123Z'],
['opened_at', '2026-07-15T10:00:00+08:00'],
['opened_at', '2026-07-15T10:00:00-0530'],
['opened_at', '2026-07-15 10:00'],
['opened_at', '2026-07-15 10:00:00.5'],
['opened_at', ' 2026-07-15 10:00'],
['opened_at', '0050-01-01T10:00:00Z'],
['opened_at', new Date(Date.UTC(2026, 6, 15, 10))],
];

const REFUSED: ReadonlyArray<readonly [Field, unknown]> = [
...IMPOSSIBLE_DAY,
...NON_ISO_DATETIME.map((v) => ['opened_at', v] as const),
...STILL_REFUSED,
];

/** A driver that records every read and 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; status?: number; fields?: Array<{ field: string; code: string }> });

const labelOf = (field: Field, value: unknown) =>
`${field} ${value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value)}`;

describe('[#20525] the write door — a real calendar day, and an ISO spelling for a datetime, 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(ledger, 'test');
});

const doors = (field: Field, value: unknown) => [
['insert', () => engine.insert('ledger', { id: 'n1', customer_id: 'c1', [field]: value })],
['update', () => engine.update('ledger', { id: 'r1', [field]: value })],
['multi-row update', () => engine.update('ledger', { [field]: value }, { where: { customer_id: 'c1' }, multi: true })],
] as const;

it('refuses an impossible day and a non-ISO datetime on insert, update and a multi-row update, naming the field with invalid_date — and writes nothing', async () => {
for (const [field, value] of REFUSED) {
for (const [door, call] of doors(field, value)) {
const err = await refusalOf(call());
expect(err, `${door}, ${labelOf(field, value)}`).not.toBeNull();
expect(err!.code, `${door}, ${labelOf(field, value)}`).toBe('VALIDATION_FAILED');
expect(err!.fields, `${door}, ${labelOf(field, value)}`).toEqual([expect.objectContaining({ field, code: 'invalid_date' })]);
}
}
expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0);
});

it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => {
for (const [field, value] of REFUSED) {
const verdict = await engine.validate('ledger', { [field]: value });
expect(verdict.valid, labelOf(field, value)).toBe(false);
expect(verdict.results[0]!.errors, labelOf(field, value)).toEqual([expect.objectContaining({ field, code: 'invalid_date' })]);
}
for (const [field, value] of ACCEPTED) {
expect((await engine.validate('ledger', { [field]: value })).valid, labelOf(field, value)).toBe(true);
}
});

it('accepts a leap day, the ISO spellings and a Date on every door — the POSITIVE CONTROL', async () => {
for (const [field, value] of ACCEPTED) {
for (const [door, call] of doors(field, value)) {
const before = writes.length;
await expect(call(), `${door}, ${labelOf(field, value)}`).resolves.toBeDefined();
expect(writes.length, `${door}, ${labelOf(field, value)} reached the driver`).toBe(before + 1);
expect(writes.at(-1)![field], `${door}, ${labelOf(field, value)} reached it as written`).toEqual(value);
}
}
});
});
Loading
Loading