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
40 changes: 40 additions & 0 deletions .changeset/20481-date-write-iso-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@objectstack/objectql": minor
---

fix(objectql)!: a `date` string is written in its `YYYY-MM-DD` form, or it is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) — `"2026/07/15"` is no longer stored verbatim as a non-day (#20481)

Clause-②: no (narrowing)

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's date 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 date string without a leading YYYY-MM-DD, and which calendar day such a string meant (07/08/2026 names two) 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` 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).

FROM a `date` field written as a string with no leading `YYYY-MM-DD` that `Date.parse` still reads — `"2026/07/15"`, `"07/15/2026"`, `"07/08/2026"`, `"15 July 2026"`, `"July 15, 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"+002026-07-15"` → TO `VALIDATION_FAILED` / 400 with the field code `invalid_date` and its existing message, nothing written. The fix is one line: send `YYYY-MM-DD` (`"2026-07-15"`), 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 guess stores the wrong day silently.

Measured through `POST /api/v1/data/:object` and a read-back, before this change, the process in America/New_York and PostgreSQL 16 at `DateStyle` `ISO, MDY`:

| written to a `date` | memory | SQLite | PostgreSQL | now, on all three |
|:--|:--|:--|:--|:--|
| `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, read back verbatim | 201, `"2026-07-15"` | 400 `invalid_date` |
| `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (a `DMY` server reads August 7) | 400 `invalid_date` |
| `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 `invalid_date` |

A verbatim `"2026/07/15"` is not a day: it sorts and compares as text beside real days, so it falls out of every date range and every date filter. PostgreSQL's reading was its server's `DateStyle`, not the writer's.

What changes:

- The record validator's `date` arm asks one more question of a string: does the `date` storage rule read it? That rule (`@objectstack/core`'s `temporalStorageForm`) collapses a string with a leading `YYYY-MM-DD` to that day and hands every other string back unchanged. The question is asked through `isUninterpretableTemporalComparand`, the predicate the engine's temporal-comparand door already refuses such a `date` comparand with, so a `date` string refused on `where` is refused as a written value too. It applies on insert, update, a multi-row update and `engine.validate` (the dry run), before any driver write.

**Who is affected.** A caller that writes a `date` field as a locale or free-form string: a REST or SDK client, a flow, an MCP `create_record` / `update_record` call written by a model. The server import (`POST /api/v1/data/:object/import`) is not affected: it already turns a date cell into `YYYY-MM-DD` before the write. A row that already holds such a string 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 the field as `YYYY-MM-DD`.

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

- a string with a leading `YYYY-MM-DD`, still stored as that day: `"2026-07-15"`, `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`;
- a `Date`, still stored as its UTC calendar day;
- an epoch-millisecond number, still refused with `invalid_date`;
- a string `Date.parse` cannot read, still refused: `"20260715"`, `"15/07/2026"`;
- the year range 0001..9999;
- every `datetime` and `time` value.
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20481] What the `date` write door admits, this driver stores as its day.
*
* The engine's record validator now admits a `date` string only when it carries
* a leading `YYYY-MM-DD` — the `date` storage rule's own reading
* (`@objectstack/core`'s `temporalStorageForm`) — and refuses every other
* spelling with `VALIDATION_FAILED` / `invalid_date` before any driver write
* (`packages/objectql/src/engine-date-write-iso-only.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 day it names and found by it, beside a
* `Date`.
*
* Measured on the base through REST over this driver: `"2026/07/15"`,
* `"07/15/2026"`, `"15 July 2026"` and `"2026-7-15"` were each a 201 read back
* verbatim — a non-day that compares as text beside real days. The engine
* refuses them now; the REST door over SQL is
* `packages/rest/src/data-date-write-iso-only.test.ts`.
*/

import { beforeAll, describe, expect, it } from 'vitest';
import { InMemoryDriver } from './memory-driver.js';

const OBJECT = 'ledger_date_20481';
const FIELDS = { placed_on: { type: 'date' } };

/** Each spelling the write door admits, and a `Date` — every one names 2026-07-15. */
const ADMITTED: ReadonlyArray<readonly [string, unknown]> = [
['bare', '2026-07-15'],
['iso', '2026-07-15T10:00:00Z'],
['naive', '2026-07-15 10:00'],
['blank', ' 2026-07-15'],
['date', new Date(Date.UTC(2026, 6, 15, 10))],
];

describe('[#20481] every date spelling the write door admits is stored as its day', () => {
let driver: InMemoryDriver;

beforeAll(async () => {
driver = new InMemoryDriver({});
await driver.connect();
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
for (const [id, placed_on] of ADMITTED) await driver.create(OBJECT, { id, placed_on });
await driver.create(OBJECT, { id: 'before', placed_on: '2026-07-14' });
});

it('reads each one back as 2026-07-15', async () => {
for (const [id] of ADMITTED) {
expect((await driver.findOne(OBJECT, { where: { id } }))?.placed_on, id).toBe('2026-07-15');
}
});

it('finds each one by the day, and orders it after 2026-07-14', async () => {
const ids = async (where: Record<string, unknown>) =>
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort();
const all = ADMITTED.map(([id]) => id).sort();
expect(await ids({ placed_on: { $eq: '2026-07-15' } })).toEqual(all);
expect(await ids({ placed_on: { $gt: '2026-07-14' } })).toEqual(all);
expect(await ids({ placed_on: { $lt: '2026-07-15' } })).toEqual(['before']);
});
});
172 changes: 172 additions & 0 deletions packages/objectql/src/engine-date-write-iso-only.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20481] A `date` string is written in its `YYYY-MM-DD` form or it is refused
* — `VALIDATION_FAILED` with the field's `invalid_date` code, on insert,
* update, a multi-row update and the dry-run `validate`, before any driver
* write.
*
* "Its `YYYY-MM-DD` form" is the `date` storage rule's own reading of a
* string (`@objectstack/core`'s `temporalStorageForm`): a leading `YYYY-MM-DD`
* after trimming, collapsed to that day. The write door asks the SAME
* predicate the temporal-comparand door asks (`isUninterpretableTemporalComparand`),
* so the two doors cannot disagree about which `date` strings the rule reads —
* the last block below holds them to that.
*
* Measured on the base (`0bbe4005e`) through REST, a create then a read-back,
* the process in America/New_York and PostgreSQL 16 at Asia/Shanghai with
* `DateStyle` `ISO, MDY`:
*
* | written to a `date` | memory | SQLite | PostgreSQL | now |
* |:--|:--|:--|:--|:--|
* | `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, read back verbatim | 201, `"2026-07-15"` (its `DateStyle` reading) | 400 |
* | `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (August 7 under DMY) | 400 |
* | `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 |
* | `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`, `"2026-07-15"` | 201, `"2026-07-15"` | 201, `"2026-07-15"` | 201, `"2026-07-15"` | unchanged |
* | `"20260715"`, `"15/07/2026"` (no `Date.parse` reading) | 400 | 400 | 400 | unchanged |
* | an epoch-millisecond number | 400 | 400 | 400 | unchanged |
* | a `Date` | 201, its UTC day | 201, its UTC day | 201, its UTC day | unchanged |
*
* No other spelling is canonicalised, on purpose: `07/08/2026` names two days,
* and a guess stores the wrong one silently. The REST door over real drivers is
* `packages/rest/src/data-date-write-iso-only.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 { 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 },
},
};

/** `Date.parse`-readable, no leading `YYYY-MM-DD` — each a 201 stored verbatim on memory and SQLite at the base. */
const REFUSED: readonly string[] = [
'2026/07/15',
'07/15/2026',
'07/08/2026',
'15 July 2026',
'July 15, 2026',
'2026-7-15',
'2026.07.15',
'+002026-07-15',
];

/** Refused at the base already — kept refused. */
const STILL_REFUSED: ReadonlyArray<readonly [string, unknown]> = [
['no Date.parse reading', '20260715'],
['a day-first spelling Date.parse cannot read', '15/07/2026'],
['a leading day shape with no reading', '2026-13-45'],
['a {placeholder}', '{today}'],
['an epoch-millisecond number', Date.UTC(2026, 6, 15)],
];

/** A leading `YYYY-MM-DD` — the rule collapses each to `2026-07-15`, and a `Date` keeps its UTC day. */
const ACCEPTED: ReadonlyArray<readonly [string, unknown]> = [
['a bare day', '2026-07-15'],
['an ISO instant', '2026-07-15T10:00:00Z'],
['a zone-naive wall clock', '2026-07-15 10:00'],
['a leading blank', ' 2026-07-15'],
['a Date', new Date(Date.UTC(2026, 6, 15, 10))],
];

/** A driver that records every read and write, and answers none. */
function makeRecordingDriver() {
const reads: unknown[] = [];
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(_o: string, ast: unknown) { reads.push(ast); return []; },
async findOne(_o: string, ast: unknown) { reads.push(ast); return { id: 'r1' }; },
async count(_o: string, ast: unknown) { reads.push(ast); return 0; },
async aggregate(_o: string, ast: unknown) { reads.push(ast); 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, reads, 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 = (value: unknown) => (value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value));

describe('[#20481] the write door — a date string is written in its YYYY-MM-DD form, or it is VALIDATION_FAILED before any write', () => {
let engine: ObjectQL;
let reads: unknown[];
let writes: Record<string, unknown>[];

beforeEach(async () => {
const rec = makeRecordingDriver();
reads = rec.reads;
writes = rec.writes;
engine = new ObjectQL();
engine.registerDriver(rec.driver, true);
await engine.init();
engine.registry.registerObject(ledger, 'test');
});

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

it('refuses every other spelling on insert, update and a multi-row update, with the field and invalid_date — and writes nothing', async () => {
for (const value of [...REFUSED, ...STILL_REFUSED.map(([, v]) => v)]) {
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, `${door}, ${labelOf(value)}`).toEqual([expect.objectContaining({ field: 'placed_on', 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 value of [...REFUSED, ...STILL_REFUSED.map(([, v]) => v)]) {
const verdict = await engine.validate('ledger', { placed_on: value });
expect(verdict.valid, labelOf(value)).toBe(false);
expect(verdict.results[0]!.errors, labelOf(value)).toEqual([expect.objectContaining({ field: 'placed_on', code: 'invalid_date' })]);
}
for (const [name, value] of ACCEPTED) {
expect((await engine.validate('ledger', { placed_on: value })).valid, name).toBe(true);
}
});

it('accepts a leading YYYY-MM-DD and a Date on every door — the POSITIVE CONTROL', async () => {
for (const [name, value] of ACCEPTED) {
for (const [door, call] of doors(value)) {
const before = writes.length;
await expect(call(), `${door}, ${name}`).resolves.toBeDefined();
expect(writes.length, `${door}, ${name} reached the driver`).toBe(before + 1);
}
}
});

it('one reading at both doors: a date string is refused as a written value exactly when it is refused as a comparand', async () => {
const strings = [...REFUSED, ...ACCEPTED.map(([, v]) => v).filter((v): v is string => typeof v === 'string')];
for (const value of strings) {
const written = (await engine.validate('ledger', { placed_on: value })).valid;
const err = await refusalOf(engine.find('ledger', { where: { placed_on: { $gte: value } } }));
if (err) expect(err, `where ${JSON.stringify(value)}`).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
expect({ written, compared: err === null }, JSON.stringify(value)).toEqual({ written: !REFUSED.includes(value), compared: !REFUSED.includes(value) });
}
expect(reads, 'a read for each accepted comparand, none for a refused one').toHaveLength(strings.length - REFUSED.length);
});
});
29 changes: 26 additions & 3 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
* - format email / url / phone (lightweight RFC-aware regex)
* - select / multiselect: value must appear in `options`
* - boolean / toggle: must coerce to boolean
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999;
* a `date` string also carries a leading `YYYY-MM-DD` (#20481)
*
* System-injected fields (`id`, `created_at`, `created_by`,
* `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly`
Expand Down Expand Up @@ -85,7 +86,7 @@ import {
parseNumericString,
} from '@objectstack/spec/data';
import type { FieldErrorCode } from '@objectstack/spec/api';
import { isOutsideTemporalYearRange } from '@objectstack/core';
import { isOutsideTemporalYearRange, isUninterpretableTemporalComparand } from '@objectstack/core';
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
import {
renderValidationMessage,
Expand Down Expand Up @@ -1220,7 +1221,29 @@ function validateOne(
// 201 on memory and SQLite) and PostgreSQL refused it with a 500; year 0
// is a 500 on PostgreSQL on both kinds. Same code and words as any other
// value that is not a valid date.
if (readable && !isOutsideTemporalYearRange(value, t)) return null;
//
// [#20481] …and a `date` STRING is one the `date` storage rule reads: a
// leading `YYYY-MM-DD` (after trimming), which `temporalStorageForm`
// collapses to that day. The rule hands every other string back unchanged,
// so `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"` or `"2026-7-15"` —
// each `Date.parse`-readable — was stored verbatim on memory and SQLite, a
// non-day that sorts and compares as text beside real days, while
// PostgreSQL read it by its `DateStyle` (`07/08/2026` is July 8 under
// MDY and August 7 under DMY). No other spelling is canonicalised, on
// purpose: `07/08/2026` names two days, and a guess stores the wrong one
// silently. The question is asked of `@objectstack/core`'s
// `isUninterpretableTemporalComparand` — the temporal-comparand door's
// reading of the same rule, never a second copy of it here — so every
// `date` string that door refuses as a comparand is refused as a written
// value too; the `Date.parse` check above still applies on top of it
// (`2026-13-45` has a leading day shape and no reading). Its two
// comparand-only exemptions never reach here: a blank is missing before
// this arm, and a `{placeholder}` is not `Date.parse`-readable. A `Date`
// is not a string and keeps its UTC calendar day; a `datetime` is
// untouched.
const readsAsDay =
t !== 'date' || typeof value !== 'string' || !isUninterpretableTemporalComparand('date', value);
if (readable && readsAsDay && !isOutsideTemporalYearRange(value, t)) return null;
// Same wire code, two sentences: "a valid date" vs "a valid datetime".
return fail('invalid_date', { type: t }, t === 'datetime' ? 'invalid_datetime' : 'invalid_date');
}
Expand Down
Loading
Loading