diff --git a/.changeset/20481-date-write-iso-only.md b/.changeset/20481-date-write-iso-only.md new file mode 100644 index 00000000000..836527e89ed --- /dev/null +++ b/.changeset/20481-date-write-iso-only.md @@ -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) + + + +**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. diff --git a/packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts b/packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts new file mode 100644 index 00000000000..af04a1155e0 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts @@ -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 = [ + ['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) => + (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']); + }); +}); diff --git a/packages/objectql/src/engine-date-write-iso-only.test.ts b/packages/objectql/src/engine-date-write-iso-only.test.ts new file mode 100644 index 00000000000..da0a2ed9752 --- /dev/null +++ b/packages/objectql/src/engine-date-write-iso-only.test.ts @@ -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 = [ + ['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 = [ + ['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[] = []; + 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) { 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, reads, 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 = (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[]; + + 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); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index a041aa18aba..b5c6bb81dc7 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -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` @@ -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, @@ -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'); } diff --git a/packages/rest/src/data-date-write-iso-only.test.ts b/packages/rest/src/data-date-write-iso-only.test.ts new file mode 100644 index 00000000000..f28ec5f17ea --- /dev/null +++ b/packages/rest/src/data-date-write-iso-only.test.ts @@ -0,0 +1,191 @@ +// 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 + * at the public door — `POST /api/v1/data/:object` and + * `PATCH /api/v1/data/:object/:id` answer `400 VALIDATION_FAILED` / + * `invalid_date` for every other spelling, and write nothing — over a real + * `SqlDriver`, with the ISO spellings and a 2026 day read back beside them. + * + * Measured on the base (`0bbe4005e`) through this door, a create then a + * read-back: + * + * | written to a `date` | SQLite | PostgreSQL 16 (`DateStyle` `ISO, MDY`) | + * |:--|:--|:--| + * | `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`, `"2026.07.15"`, `"July 15, 2026"` | 201, read back verbatim | 201, `"2026-07-15"` | + * | `"07/08/2026"` | 201, verbatim | 201, `"2026-07-08"` — August 7 on a `DMY` server | + * | `"+002026-07-15"` | 201, verbatim | 500 | + * + * (InMemoryDriver answered as SQLite.) A verbatim `"2026/07/15"` is a non-day + * that sorts and compares as text beside real days; PostgreSQL's reading is its + * server's `DateStyle`, not the author's. No other spelling is canonicalised: + * `07/08/2026` names two days. The refusal sits in the engine, in front of + * every driver, so one verdict holds on each cell; the engine-level pin with a + * recording driver, the dry-run `validate` and the comparand door's agreement + * are `packages/objectql/src/engine-date-write-iso-only.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 its day in + * `packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.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_date_20481'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20481', + fields: { + customer_id: { name: 'customer_id', type: 'text' as const }, + placed_on: { name: 'placed_on', type: 'date' as const }, + }, +}; + +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), + }, +]; + +/** `Date.parse`-readable, no leading `YYYY-MM-DD` — refused now. */ +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', +]; + +/** Written value → what reads back: a leading `YYYY-MM-DD`, collapsed to that day. */ +const ACCEPTED: ReadonlyArray = [ + ['2026-07-15', '2026-07-15'], + ['2026-07-15T10:00:00Z', '2026-07-15'], + ['2026-07-15 10:00', '2026-07-15'], + [' 2026-07-15', '2026-07-15'], +]; + +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; +} + +for (const cell of CELLS) { + const config = cell.config(); + describe.skipIf(!config)( + `[#20481] a date string is written in its YYYY-MM-DD form 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) => + (await call('POST', '/api/v1/data/:object/query', { object: OBJECT }, { where: { id } })).body.records[0]?.placed_on; + + beforeAll(async () => { + 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' } 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 */ } + }); + + it('every other spelling is 400 VALIDATION_FAILED / invalid_date on create and on PATCH — nothing written', async () => { + const before = writes.n; + for (const value of REFUSED) { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'refused', customer_id: 'cw', placed_on: value }); + expect(created.status, `create ${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 ${value}`).toEqual([['placed_on', 'invalid_date']]); + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 'o1' }, { placed_on: value }); + expect(patched.status, `PATCH ${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 ${value}`).toEqual([['placed_on', 'invalid_date']]); + } + expect(writes.n - before, 'no write — every refusal precedes the driver').toBe(0); + expect(await readBack('o1'), 'o1 kept its day').toBe('2026-01-10'); + expect(await readBack('refused'), 'no row was created').toBeUndefined(); + }); + + it('an epoch-millisecond number stays refused — the control for a non-string', async () => { + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id: 'n1', customer_id: 'cw', placed_on: Date.UTC(2026, 6, 15) }); + expect(created.status, JSON.stringify(created.body)).toBe(400); + expect(created.body.fields.map((x: any) => [x.field, x.code])).toEqual([['placed_on', 'invalid_date']]); + }); + + it('the ISO spellings are written and read back as the day — the POSITIVE CONTROL, on create and on PATCH', async () => { + for (const [i, [value, day]] of ACCEPTED.entries()) { + const id = `w${i}`; + const created = await call('POST', '/api/v1/data/:object', { object: OBJECT }, { id, customer_id: 'cw', placed_on: value }); + expect(created.status, `create ${JSON.stringify(value)}: ${JSON.stringify(created.body)}`).toBe(201); + expect(await readBack(id), `read back ${JSON.stringify(value)}`).toBe(day); + } + const patched = await call('PATCH', '/api/v1/data/:object/:id', { object: OBJECT, id: 'o1' }, { placed_on: '2026-07-15T10:00:00Z' }); + expect(patched.status, JSON.stringify(patched.body)).toBe(200); + expect(await readBack('o1')).toBe('2026-07-15'); + }); + }, + ); +}