diff --git a/.changeset/20525-temporal-write-real-day-iso.md b/.changeset/20525-temporal-write-real-day-iso.md new file mode 100644 index 00000000000..c0b45c6d6df --- /dev/null +++ b/.changeset/20525-temporal-write-real-day-iso.md @@ -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) + + + +**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`). diff --git a/packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts b/packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts new file mode 100644 index 00000000000..cc0d56af4fb --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts @@ -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 = [ + ['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) => + (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']); + }); +}); diff --git a/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts b/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts new file mode 100644 index 00000000000..bc83c6b8594 --- /dev/null +++ b/packages/objectql/src/engine-temporal-write-real-day-iso.test.ts @@ -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 = [ + ['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 = [ + ['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 = [ + ['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 = [ + ...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[] = []; + 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; 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[]; + + 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); + } + } + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index b5c6bb81dc7..e51b0aff89c 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -55,7 +55,9 @@ * - 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; - * a `date` string also carries a leading `YYYY-MM-DD` (#20481) + * 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 * * System-injected fields (`id`, `created_at`, `created_by`, * `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly` @@ -838,6 +840,62 @@ function valueShapeDetail(error: { issues: ReadonlyArray<{ code: string; message return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value shape'; } +/** + * [#20525] The ISO 8601 spellings a `datetime` STRING is written in, after + * trimming — the ones the platform itself writes, each read the same on every + * host by the `datetime` storage rule (`@objectstack/core`'s + * `temporalStorageForm`): + * + * - `YYYY-MM-DD` — midnight UTC; + * - `YYYY-MM-DDTHH:MM[:SS[.f…]]`, then `Z`, a `±HH:MM` / `±HHMM` offset, or + * nothing — a zone-naive wall clock is read AS UTC (ADR-0074); + * - `YYYY-MM-DD HH:MM[:SS[.f…]]`, zone-naive only — read AS UTC the same way. + * + * Every other spelling is refused. `Date.parse` reads `"2026/07/15 10:00"`, + * `"07/15/2026 10:00"` or `"15 July 2026 10:00"` in the SERVER PROCESS's zone + * and `"07/08/2026"` month-first, so the stored instant was a property of the + * deployment host; it reads `"2026"` as a year, which the storage rule then + * stores as 2026 epoch milliseconds (`1970-01-01T00:00:02.026Z`). A space + * separator carries no zone because the storage rule hands such a string to + * `Date.parse` whole, and its non-ISO reading moves `"0050-01-01 10:00+01:00"` + * to 1950. `T` and `Z` are upper case: a zone-naive `"…t10:00"` is not the + * form the storage rule reads as UTC. + */ +const ISO_DATETIME_WRITE_FORM = + /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?| \d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?$/; + +/** + * [#20525] Does the string's leading `YYYY-MM-DD` name a calendar day that + * exists — month 01..12, day 01 to that month's length, February 29 only in a + * leap year? Arithmetic, never a `Date` round trip: `Date.UTC` reads a year + * 0..99 as 1900..1999, and `Date.parse` ROLLS an impossible day over + * (`"2026-02-30"` is March 2), which is the defect this answers. A string with + * no leading day answers `false`. + */ +function namesRealCalendarDay(s: string): boolean { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); + if (!m) return false; + const year = Number(m[1]); + const month = Number(m[2]); + const day = Number(m[3]); + if (month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const length = month === 2 ? (leap ? 29 : 28) : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; + return day <= length; +} + +/** + * [#20525] Is a `date` / `datetime` STRING written in a form its storage rule + * stores as written? A real leading calendar day for both kinds, and for a + * `datetime` an {@link ISO_DATETIME_WRITE_FORM} spelling. A `date`'s other + * spelling question is the #20481 one, asked beside this. + */ +function writesAsIsoTemporal(value: string, kind: 'date' | 'datetime'): boolean { + const s = value.trim(); + if (kind === 'datetime' && !ISO_DATETIME_WRITE_FORM.test(s)) return false; + return namesRealCalendarDay(s); +} + function validateOne( name: string, def: FieldDef, @@ -1243,7 +1301,19 @@ function validateOne( // untouched. const readsAsDay = t !== 'date' || typeof value !== 'string' || !isUninterpretableTemporalComparand('date', value); - if (readable && readsAsDay && !isOutsideTemporalYearRange(value, t)) return null; + // [#20525] …and a STRING names a calendar day that exists, for a `date` + // and for the day part of a `datetime`, and a `datetime` string is one of + // the ISO spellings `ISO_DATETIME_WRITE_FORM` names. `Date.parse` rolled + // an impossible day over (`"2026-02-30T10:00:00Z"` was stored as March 2 + // on every backend) and a `date` kept it verbatim (`"2026-02-30"`, a day + // that does not exist, on memory and SQLite; a 500 on PostgreSQL); it read + // a non-ISO `datetime` in the server process's zone. Refused, never rolled + // over and never re-read: the same `invalid_date` wire code as every other + // value that is not a valid date, naming the field. A `Date` names a real + // instant and is not a string, so it keeps its answer; a number stays + // refused by `readable`. + const writtenAsIso = typeof value !== 'string' || writesAsIsoTemporal(value, t); + if (readable && readsAsDay && writtenAsIso && !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'); } 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 new file mode 100644 index 00000000000..4b3349b13cf --- /dev/null +++ b/packages/rest/src/data-temporal-write-real-day-iso.test.ts @@ -0,0 +1,206 @@ +// 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 at the public + * door — `POST /api/v1/data/:object` and `PATCH /api/v1/data/:object/:id` + * answer `400 VALIDATION_FAILED` / `invalid_date` and write nothing — over a + * real `SqlDriver`, with the process in America/New_York so a host-zone + * reading would show, and a leap day and the ISO spellings read back beside + * them. + * + * Measured on the base (`b2b6a0643`) through this door, a create then a + * read-back, the process in America/New_York: + * + * | written | SQLite | PostgreSQL 16 | + * |:--|:--|:--| + * | `date` `"2026-02-30"` | 201, `"2026-02-30"` (a day that does not exist) | 500 `DATABASE_ERROR` | + * | `datetime` `"2026-02-30T10:00:00Z"` | 201, `"2026-03-02T10:00:00.000Z"` | the same | + * | `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 | + * | `datetime` `"07/08/2026"` | 201, `"2026-07-08T04:00:00.000Z"` — month-first, the process zone | the same | + * + * (InMemoryDriver answered as SQLite.) The refusal sits in the engine, in front + * of every driver; the engine-level pin with a recording driver and the dry-run + * `validate` is `packages/objectql/src/engine-temporal-write-real-day-iso.test.ts`. + * + * ## The dialect axis of THIS file + * + * The SQLite cell always runs. The PostgreSQL cell runs where + * `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise; no CI job + * provisions it for this package. It owns one table, dropped before and after. + * An in-memory cell is not here: `@objectstack/driver-memory` has no binding in + * this package, and a new one is a census decision + * (`scripts/driver-memory-census.ledger.json`), not a test's; the memory driver + * stores what this door admits as written in + * `packages/drivers/driver-memory/src/memory-20525-temporal-write-real-day-iso.test.ts`. + */ + +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 { RestServer } from './rest-server'; + +const OBJECT = 'rest_temporal_20525'; +const HOST_ZONE = 'America/New_York'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20525', + fields: { + 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'; + +interface Cell { + id: 'sqlite' | 'pg'; + label: string; + env: string | null; + config: () => Record | null; +} + +const CELLS: readonly Cell[] = [ + { id: 'sqlite', label: 'sqlite', env: null, config: () => ({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) }, + { + id: 'pg', + label: 'live postgres', + env: 'OS_TEST_POSTGRES_URL', + config: () => (process.env.OS_TEST_POSTGRES_URL ? { client: 'pg', connection: process.env.OS_TEST_POSTGRES_URL } : null), + }, +]; + +/** Arm 1, a day that does not exist, and Arm 2, the card's four non-ISO spellings — each a 201 or a 500 at the base. */ +const REFUSED: ReadonlyArray = [ + ['placed_on', '2026-02-30'], + ['opened_at', '2026-02-30T10:00:00Z'], + ['opened_at', '2026/07/15 10:00'], + ['opened_at', '07/15/2026 10:00'], + ['opened_at', '15 July 2026 10:00'], + ['opened_at', '07/08/2026'], +]; + +/** Written value → what reads back: the leap-day controls, and the ISO spellings read the same in every zone. */ +const ACCEPTED: ReadonlyArray = [ + ['placed_on', '2028-02-29', '2028-02-29'], + ['opened_at', '2028-02-29T10:00:00Z', '2028-02-29T10:00:00.000Z'], + ['opened_at', '2026-07-15T10:00:00Z', '2026-07-15T10:00:00.000Z'], + ['opened_at', '2026-07-15T10:00:00+08:00', '2026-07-15T02:00:00.000Z'], + ['opened_at', '2026-07-15 10:00', '2026-07-15T10:00:00.000Z'], + ['opened_at', '2026-07-15T10:00', '2026-07-15T10:00:00.000Z'], + ['opened_at', '2026-07-15', '2026-07-15T00:00:00.000Z'], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +const originalTz = process.env.TZ; + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20525] a real calendar day, and an ISO spelling for a datetime, at the public door — ${cell.label}${config ? '' : ` (skipped: set ${cell.env} to run this cell)`}`, + () => { + let engine: ObjectQL; + let driver: any; + const writes = { n: 0 }; + let call: (method: string, path: string, params: Record, body: unknown) => Promise<{ status: number; body: any }>; + const readBack = async (id: string, field: Field) => + (await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { id } })).body.records[0]?.[field]; + + beforeAll(async () => { + // A host whose zone is not UTC: the only configuration in which a + // host-zone reading of a datetime can show. Node re-reads TZ lazily. + process.env.TZ = HOST_ZONE; + expect(Intl.DateTimeFormat().resolvedOptions().timeZone, 'the host zone really changed').toBe(HOST_ZONE); + + driver = new SqlDriver(config as any); + if (cell.id !== 'sqlite') await driver.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + await engine.insert(OBJECT, { id: 'o1', customer_id: 'c1', placed_on: '2026-01-10', opened_at: '2026-01-10T09:00:00Z' } as any); + + // Writes of THIS object — the protocol's own metadata traffic is not the question. + for (const verb of ['create', 'update', 'bulkCreate', 'updateMany'] as const) { + if (typeof driver[verb] !== 'function') continue; + const real = driver[verb].bind(driver); + driver[verb] = (o: string, ...rest: unknown[]) => { if (o === OBJECT) writes.n += 1; return real(o, ...rest); }; + } + + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + call = async (method, path, params, body) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + if (cell.id !== 'sqlite') await driver?.execute(`drop table if exists ${OBJECT}`).catch(() => {}); + try { await engine?.destroy(); } catch { /* noop */ } + if (originalTz === undefined) delete process.env.TZ; + else process.env.TZ = originalTz; + }); + + it('an impossible day and a non-ISO datetime are 400 VALIDATION_FAILED / invalid_date on create and on PATCH — nothing written', async () => { + const before = writes.n; + for (const [field, value] of REFUSED) { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'refused', customer_id: 'cw', [field]: value }); + expect(created.status, `create ${field} ${value}: ${JSON.stringify(created.body)}`).toBe(400); + expect(created.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(created.body.fields.map((x: any) => [x.field, x.code]), `create ${field} ${value}`).toEqual([[field, 'invalid_date']]); + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 'o1' }, { [field]: value }); + expect(patched.status, `PATCH ${field} ${value}: ${JSON.stringify(patched.body)}`).toBe(400); + expect(patched.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(patched.body.fields.map((x: any) => [x.field, x.code]), `PATCH ${field} ${value}`).toEqual([[field, 'invalid_date']]); + } + expect(writes.n - before, 'no write — every refusal precedes the driver').toBe(0); + expect(await readBack('o1', 'placed_on'), 'o1 kept its day').toBe('2026-01-10'); + expect(await readBack('o1', 'opened_at'), 'o1 kept its instant').toBe('2026-01-10T09:00:00.000Z'); + expect(await readBack('refused', 'placed_on'), 'no row was created').toBeUndefined(); + }); + + it('an epoch-millisecond number stays refused for a datetime — the control for a non-string', async () => { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'n1', customer_id: 'cw', opened_at: Date.UTC(2026, 6, 15, 10) }); + expect(created.status, JSON.stringify(created.body)).toBe(400); + expect(created.body.fields.map((x: any) => [x.field, x.code])).toEqual([['opened_at', 'invalid_date']]); + }); + + it('a leap day and the ISO spellings are written and read back unchanged, in UTC — the POSITIVE CONTROL, on create and on PATCH', async () => { + for (const [i, [field, value, stored]] of ACCEPTED.entries()) { + const id = `w${i}`; + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, customer_id: 'cw', [field]: value }); + expect(created.status, `create ${field} ${JSON.stringify(value)}: ${JSON.stringify(created.body)}`).toBe(201); + expect(await readBack(id, field), `read back ${field} ${JSON.stringify(value)}`).toBe(stored); + } + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 'o1' }, { placed_on: '2028-02-29', opened_at: '2026-07-15 10:00' }); + expect(patched.status, JSON.stringify(patched.body)).toBe(200); + expect(await readBack('o1', 'placed_on')).toBe('2028-02-29'); + expect(await readBack('o1', 'opened_at'), 'zone-naive ISO is UTC, not the host zone').toBe('2026-07-15T10:00:00.000Z'); + }); + }, + ); +}