Skip to content

Commit b2b6a06

Browse files
fix(objectql)!: a date string is written in its YYYY-MM-DD form, or refused with VALIDATION_FAILED / invalid_date (#20481) (#20524)
Fixes #20481 Clause-②: no (narrowing) A `date` field's write door now stores a day or refuses. A `date` string is accepted only when it carries a leading `YYYY-MM-DD`, the `date` storage rule's own reading, which `temporalStorageForm` collapses to that day. Every other spelling is refused with `VALIDATION_FAILED` / 400 (`invalid_date`) before any driver write. This executes triage's ruling on the card (5875651303): "**Direction (triage's call, as the card asks): refuse.**" No spelling is canonicalised: "`07/08/2026` is ambiguous between locales, and a guess stores a wrong day silently." Measured head: `4c5740d2d`. It is the last change commit `9f0d29239` plus a true merge of `origin/main` `fb194c70e` (two parents). The merge brought PR #20517 (`packages/rest/src/import-coerce.ts` and a test). It touched no file under `packages/objectql`, `packages/core` or `packages/drivers/driver-memory`. ## The change One source file changes: `packages/objectql/src/validation/record-validator.ts`, the `date` / `datetime` arm, +26 / −3. - For a `date` **string**, the arm also asks `isUninterpretableTemporalComparand('date', value)` from `@objectstack/core`. The engine's temporal-comparand door already refuses these same strings on `where` with that predicate. It trims the string and tests for a leading `YYYY-MM-DD`, the same reading `temporalStorageForm` makes. So the write door and the comparand door agree on which `date` strings the rule reads, and no second copy of the leading-day regex is written. - `Date.parse` readability and the 0001..9999 year range still apply on top. - A `Date`, a number, every `datetime` and every `time` go through the arm exactly as before. - `packages/core` is not edited and gains no export, so the claim's `Clause-②: no (narrowing)` holds and only `@objectstack/objectql` carries a changeset. ## Before and after, at the public door `POST /api/v1/data/:object`, then a read-back. The process ran in America/New_York. PostgreSQL 16.13 was a local server at `Asia/Shanghai`, `DateStyle` `ISO, MDY`. Readings are from base `0bbe4005e` and head. | written to a `date` | memory | SQLite | PostgreSQL | head, 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"` (`ISO, DMY` reads August 7, measured in `psql`) | 400 `invalid_date` | | `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 `DATABASE_ERROR` | 400 `invalid_date` | | `"2026-07-15"`, `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"` | 201, `"2026-07-15"` | the same | the same | unchanged | | `"20260715"`, `"15/07/2026"` | 400 | 400 | 400 | unchanged | | an epoch-millisecond number | 400 `invalid_date` | 400 | 400 | unchanged | | a `Date` | 201, its UTC day | 201 | 201 | unchanged | ## The dispatch's hypotheses - **H1 holds.** Memory and SQLite answered 201 and read `"2026/07/15"` back verbatim. PostgreSQL does not store it verbatim. Its `DATE` input parser reads the spelling by the server's `DateStyle`, so the stored day is a property of the server's configuration. `"07/08/2026"` is July 8 under `MDY` and August 7 under `DMY`. A spelling it cannot parse (`"+002026-07-15"`) was a 500. - **H2.** Today `readable` is `Date.parse`-readable. These pass today and are refused now: `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"`, `"2026-7-15"`. These have a leading `YYYY-MM-DD`, are accepted and are stored as `"2026-07-15"` before and after: `"2026-07-15T10:00:00Z"`, `"2026-07-15 10:00"`, `" 2026-07-15"`. The last one is accepted because the rule trims. `"20260715"` has no `Date.parse` reading, so it was refused at the base and still is. The predicate is core's exported `isUninterpretableTemporalComparand` (its `date` branch, private `readsAsCalendarDay` in `temporal-comparand.ts`), so no new predicate was added. - **H3, producers.** No shipped producer generates a non-ISO `date` string on the shipped composition. The one path that forwards raw text runs only when `/import` is unreachable. Details are in the census section below. - **H4, the `datetime` arm.** It does not have this defect: no spelling was stored verbatim on any of the three drivers. It has a different one, reported and not edited: a zone-naive non-ISO spelling is read in the server process's zone. See Acceptance notes. - **H5 holds.** A `Date` and an epoch number keep the behaviour they had. A `Date` is stored as its UTC day. A number is refused with `invalid_date` on the write door, as it was at the base. Both are pinned as controls. ## Producer census (H3) Read at objectstack `0bbe4005e` and objectui `origin/main` `797a30f`. - **objectui `DateField`** (`packages/fields/src/widgets/DateField.tsx`): an `input` of type `date`, and `onChange` emits `e.target.value`, which is `YYYY-MM-DD` or empty. ISO. - **objectui calendar and gantt drag / quick-create writers** (`plugin-calendar/src/ObjectCalendar.tsx` `toStoredDateValue` / `toMovedDateValue`, `plugin-gantt/src/ObjectGantt.tsx` `toStoredDateValue`): `toDateInputValue` or `toISOString().slice(0, 10)` for a `date` field. ISO. - **Server `/import` cell reader** (`packages/rest/src/import-coerce.ts` `parseDateCell`): it always returns `YYYY-MM-DD` for a `date` cell. Both the bulk path and the per-row path of `import-runner.ts` call `coerceRow` first (`:775`). ISO. Not edited. - **objectui Import Wizard's legacy per-row fallback** (`plugin-grid/src/ImportWizard.tsx` `legacyImport` → `validateRow` `:561`, `validateValue` `:486`): it sends the raw cell text, checked only by `Date.parse`. It runs only when the data source has no `importRecords`, or the client has no `data.import` (`isUnsupportedImport` `:626`). objectui's `data-objectstack` adapter implements `importRecords` (`src/index.ts:4430`). On this path a non-ISO cell is now a per-row refusal instead of a stored non-day. See open question 1 in the report. - **Seeds under `examples/`**: CEL `daysAgo(n)` / `daysFromNow(n)` (a `Date`, normalised to `YYYY-MM-DD`) and ISO literals. A regex census of non-ISO date literals over `examples/**` finds 0, with a control regex for ISO literals finding hits in 6 files. - **AI / MCP writers**: `packages/mcp/src/mcp-http-tools.ts` `create_record` / `update_record` forward `data` values unchanged, as `z.unknown()`. They are pass-through, and a model-written non-ISO date now gets the 400 back as the tool error. ## Tests - `packages/objectql/src/engine-date-write-iso-only.test.ts` (new, 4 tests, recording driver): 8 refused spellings plus 5 already refused (including `{today}` and an epoch number), on insert, update, a multi-row update and `engine.validate`. Each asserts `code` `VALIDATION_FAILED` and `fields` `[placed_on, invalid_date]`, with zero driver writes. The accepted leading-day spellings and a `Date` reach the driver. One test holds both doors to one verdict per string: `validate` validity equals `where` acceptance. - `packages/rest/src/data-date-write-iso-only.test.ts` (new, 3 tests per cell): `POST` and `PATCH` over a real `SqlDriver`. SQLite always runs. Live PostgreSQL runs where `OS_TEST_POSTGRES_URL` is set and is a named skip otherwise. Each refused spelling asserts status 400, `code` `VALIDATION_FAILED` and the field code, with no write. There is an epoch-number control, and the ISO spellings read back as `"2026-07-15"`. - `packages/drivers/driver-memory/src/memory-20481-date-write-iso-only.test.ts` (new, 2 tests): each spelling the door admits, and a `Date`, is stored as `2026-07-15`, found by it, and ordered after `2026-07-14` on `InMemoryDriver`. Reverse verification: - **objectql:** the fix was committed first. `scripts/ablation-replace.mjs` put the base condition back in `record-validator.ts` (anchor 1 → 0, blob `b5c6bb81dc72` → `d501d34ad6bc`), and the new file went 3 failed / 1 passed. It passes 4/4 at head. The tool restored the file: blob equals HEAD and `git diff HEAD` is empty. - **REST:** the rest suite reads `@objectstack/objectql` from `dist`. Against the base `dist` (`readsAsDay` count 0), the new file went 2 failed / 4 passed on SQLite and live PostgreSQL, because `"2026/07/15"` answered 201. After `pnpm --filter @objectstack/objectql build` (count 2), it passed 6/6. Suites: - **objectql:** at `9f0d29239`, 332 files / 6632 tests passed. `test:repo` 1 / 5. `typecheck` exit 0; `check:test-typecheck` compiles the new file with the debt unchanged at 40 files. - **driver-memory:** at `9f0d29239`, 59 / 1380, and `typecheck` exit 0. - **rest:** at `4c5740d2d`, `--project local` 221 files, 4214 passed / 43 skipped. `test:repo` 1 / 8, and `typecheck` exit 0. - **REST file with live PostgreSQL, at `4c5740d2d`:** 6 / 6. The objectql and driver-memory trees are byte-identical between `9f0d29239` and `4c5740d2d`. ## Gates at `4c5740d2d` - **`dispatch-gates --commands --repo objectstack-ai/objectstack`:** 65 commands, each run with its exit code captured before any pipe. 63 exit 0. - **NOT MEASURED: `check:dual-build-cjs-loads`, `check:type-check-debt`.** Reason: both exit 3 `PREREQUISITE NOT MET` and need the whole tree built (`lint.yml` builds it first); only the rest and driver-memory closures are built here. Scoped reading: both `require` entries of `@objectstack/objectql` (`.` and `./core`) load from the rebuilt `dist`. - **`--ran` reconciliation:** 65 derived, 63 run, 2 NOT-MEASURED, 0 UNRUN. - **Rosters in the touched directories:** `check-changeset-fixed`, `check:authz-resolver`, `check:filter-alias-parity`, `check:object-def-param-keys` and `check:tenant-chokepoint` each exit 0. - **Narrowed lint:** `eslint --no-inline-config --format json` over the 4 changed TS files, which are inside `eslint.config.mjs`'s population: 4 files, 0 errors, 0 warnings. `--print-config` shows no `parserOptions.project` or `projectService`, so type-aware linting is off and this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - **REST on memory** is not a cell of the committed REST file. `@objectstack/driver-memory` has no binding in `packages/rest`, and a new binding is a `check:driver-memory-census` disposition, not a test's. Memory is covered by the engine pin (the refusal reaches no driver), by the driver-memory pin (admitted spellings are stored as the day), and by the before / after table above, measured through `RestServer` over `InMemoryDriver` with a scratch script that was not committed. - **The `invalid_date` message** still reads "must be a valid date (ISO-8601)". It lives in `packages/spec` (`system/validation-message.ts`), outside this card's file surface. `"20260715"` (ISO basic) and `"+002026-07-15"` (ISO extended year) are ISO-8601 spellings refused with that message. Carrier: none. - **Out of scope, measured and not edited (reported to the seat):** a `date` `"2026-02-30"` is `Date.parse`-readable and has a leading day shape. It is still 201 and read back verbatim on memory and SQLite, and a 500 on PostgreSQL. On the `datetime` arm, a non-ISO zone-naive spelling is read in the process zone (`"2026/07/15 10:00"` became `14:00Z` under America/New_York, while `"2026-07-15 10:00"` reads as UTC), `"07/08/2026"` is read month-first, and `"2026-02-30T10:00:00Z"` rolls over to `2026-03-02T10:00Z`. - **`driver-mongodb`** keeps its own copy of the storage rule and is unmeasured here. The refusal sits in the engine in front of it. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31d281d commit b2b6a06

5 files changed

Lines changed: 491 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
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)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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`). -->
10+
11+
**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).
12+
13+
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`.
14+
15+
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.
16+
17+
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`:
18+
19+
| written to a `date` | memory | SQLite | PostgreSQL | now, on all three |
20+
|:--|:--|:--|:--|:--|
21+
| `"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` |
22+
| `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (a `DMY` server reads August 7) | 400 `invalid_date` |
23+
| `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 `invalid_date` |
24+
25+
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.
26+
27+
What changes:
28+
29+
- 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.
30+
31+
**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`.
32+
33+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through REST:
34+
35+
- 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"`;
36+
- a `Date`, still stored as its UTC calendar day;
37+
- an epoch-millisecond number, still refused with `invalid_date`;
38+
- a string `Date.parse` cannot read, still refused: `"20260715"`, `"15/07/2026"`;
39+
- the year range 0001..9999;
40+
- every `datetime` and `time` value.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20481] What the `date` write door admits, this driver stores as its day.
5+
*
6+
* The engine's record validator now admits a `date` string only when it carries
7+
* a leading `YYYY-MM-DD` — the `date` storage rule's own reading
8+
* (`@objectstack/core`'s `temporalStorageForm`) — and refuses every other
9+
* spelling with `VALIDATION_FAILED` / `invalid_date` before any driver write
10+
* (`packages/objectql/src/engine-date-write-iso-only.test.ts`). The refusal is
11+
* therefore not this driver's; what it owes is the other half: every spelling
12+
* the door admits is STORED as the day it names and found by it, beside a
13+
* `Date`.
14+
*
15+
* Measured on the base through REST over this driver: `"2026/07/15"`,
16+
* `"07/15/2026"`, `"15 July 2026"` and `"2026-7-15"` were each a 201 read back
17+
* verbatim — a non-day that compares as text beside real days. The engine
18+
* refuses them now; the REST door over SQL is
19+
* `packages/rest/src/data-date-write-iso-only.test.ts`.
20+
*/
21+
22+
import { beforeAll, describe, expect, it } from 'vitest';
23+
import { InMemoryDriver } from './memory-driver.js';
24+
25+
const OBJECT = 'ledger_date_20481';
26+
const FIELDS = { placed_on: { type: 'date' } };
27+
28+
/** Each spelling the write door admits, and a `Date` — every one names 2026-07-15. */
29+
const ADMITTED: ReadonlyArray<readonly [string, unknown]> = [
30+
['bare', '2026-07-15'],
31+
['iso', '2026-07-15T10:00:00Z'],
32+
['naive', '2026-07-15 10:00'],
33+
['blank', ' 2026-07-15'],
34+
['date', new Date(Date.UTC(2026, 6, 15, 10))],
35+
];
36+
37+
describe('[#20481] every date spelling the write door admits is stored as its day', () => {
38+
let driver: InMemoryDriver;
39+
40+
beforeAll(async () => {
41+
driver = new InMemoryDriver({});
42+
await driver.connect();
43+
await driver.syncSchema(OBJECT, { name: OBJECT, fields: FIELDS });
44+
for (const [id, placed_on] of ADMITTED) await driver.create(OBJECT, { id, placed_on });
45+
await driver.create(OBJECT, { id: 'before', placed_on: '2026-07-14' });
46+
});
47+
48+
it('reads each one back as 2026-07-15', async () => {
49+
for (const [id] of ADMITTED) {
50+
expect((await driver.findOne(OBJECT, { where: { id } }))?.placed_on, id).toBe('2026-07-15');
51+
}
52+
});
53+
54+
it('finds each one by the day, and orders it after 2026-07-14', async () => {
55+
const ids = async (where: Record<string, unknown>) =>
56+
(await driver.find(OBJECT, { where })).map((r) => r.id as string).sort();
57+
const all = ADMITTED.map(([id]) => id).sort();
58+
expect(await ids({ placed_on: { $eq: '2026-07-15' } })).toEqual(all);
59+
expect(await ids({ placed_on: { $gt: '2026-07-14' } })).toEqual(all);
60+
expect(await ids({ placed_on: { $lt: '2026-07-15' } })).toEqual(['before']);
61+
});
62+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20481] A `date` string is written in its `YYYY-MM-DD` form or it is refused
5+
* — `VALIDATION_FAILED` with the field's `invalid_date` code, on insert,
6+
* update, a multi-row update and the dry-run `validate`, before any driver
7+
* write.
8+
*
9+
* "Its `YYYY-MM-DD` form" is the `date` storage rule's own reading of a
10+
* string (`@objectstack/core`'s `temporalStorageForm`): a leading `YYYY-MM-DD`
11+
* after trimming, collapsed to that day. The write door asks the SAME
12+
* predicate the temporal-comparand door asks (`isUninterpretableTemporalComparand`),
13+
* so the two doors cannot disagree about which `date` strings the rule reads —
14+
* the last block below holds them to that.
15+
*
16+
* Measured on the base (`0bbe4005e`) through REST, a create then a read-back,
17+
* the process in America/New_York and PostgreSQL 16 at Asia/Shanghai with
18+
* `DateStyle` `ISO, MDY`:
19+
*
20+
* | written to a `date` | memory | SQLite | PostgreSQL | now |
21+
* |:--|:--|:--|:--|:--|
22+
* | `"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 |
23+
* | `"07/08/2026"` | 201, verbatim | 201, verbatim | 201, `"2026-07-08"` (August 7 under DMY) | 400 |
24+
* | `"+002026-07-15"` | 201, verbatim | 201, verbatim | 500 | 400 |
25+
* | `"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 |
26+
* | `"20260715"`, `"15/07/2026"` (no `Date.parse` reading) | 400 | 400 | 400 | unchanged |
27+
* | an epoch-millisecond number | 400 | 400 | 400 | unchanged |
28+
* | a `Date` | 201, its UTC day | 201, its UTC day | 201, its UTC day | unchanged |
29+
*
30+
* No other spelling is canonicalised, on purpose: `07/08/2026` names two days,
31+
* and a guess stores the wrong one silently. The REST door over real drivers is
32+
* `packages/rest/src/data-date-write-iso-only.test.ts`; this file's driver
33+
* records writes and stores nothing, because the refusal sits in front of
34+
* every driver.
35+
*/
36+
37+
import { describe, it, expect, beforeEach } from 'vitest';
38+
import { ObjectQL } from './engine.js';
39+
40+
const ledger = {
41+
name: 'ledger',
42+
label: 'Ledger',
43+
fields: {
44+
id: { name: 'id', type: 'text' as const, primaryKey: true },
45+
customer_id: { name: 'customer_id', type: 'text' as const },
46+
placed_on: { name: 'placed_on', type: 'date' as const },
47+
},
48+
};
49+
50+
/** `Date.parse`-readable, no leading `YYYY-MM-DD` — each a 201 stored verbatim on memory and SQLite at the base. */
51+
const REFUSED: readonly string[] = [
52+
'2026/07/15',
53+
'07/15/2026',
54+
'07/08/2026',
55+
'15 July 2026',
56+
'July 15, 2026',
57+
'2026-7-15',
58+
'2026.07.15',
59+
'+002026-07-15',
60+
];
61+
62+
/** Refused at the base already — kept refused. */
63+
const STILL_REFUSED: ReadonlyArray<readonly [string, unknown]> = [
64+
['no Date.parse reading', '20260715'],
65+
['a day-first spelling Date.parse cannot read', '15/07/2026'],
66+
['a leading day shape with no reading', '2026-13-45'],
67+
['a {placeholder}', '{today}'],
68+
['an epoch-millisecond number', Date.UTC(2026, 6, 15)],
69+
];
70+
71+
/** A leading `YYYY-MM-DD` — the rule collapses each to `2026-07-15`, and a `Date` keeps its UTC day. */
72+
const ACCEPTED: ReadonlyArray<readonly [string, unknown]> = [
73+
['a bare day', '2026-07-15'],
74+
['an ISO instant', '2026-07-15T10:00:00Z'],
75+
['a zone-naive wall clock', '2026-07-15 10:00'],
76+
['a leading blank', ' 2026-07-15'],
77+
['a Date', new Date(Date.UTC(2026, 6, 15, 10))],
78+
];
79+
80+
/** A driver that records every read and write, and answers none. */
81+
function makeRecordingDriver() {
82+
const reads: unknown[] = [];
83+
const writes: Record<string, unknown>[] = [];
84+
const driver: any = {
85+
name: 'recording', version: '0.0.0', supports: {},
86+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
87+
async find(_o: string, ast: unknown) { reads.push(ast); return []; },
88+
async findOne(_o: string, ast: unknown) { reads.push(ast); return { id: 'r1' }; },
89+
async count(_o: string, ast: unknown) { reads.push(ast); return 0; },
90+
async aggregate(_o: string, ast: unknown) { reads.push(ast); return []; },
91+
async create(_o: string, data: Record<string, unknown>) { writes.push(data); return { ...data }; },
92+
async update(_o: string, id: string, data: Record<string, unknown>) { writes.push(data); return { ...data, id }; },
93+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) { writes.push(data); return 0; },
94+
async delete() { return true; },
95+
async deleteMany() { return 0; },
96+
async bulkCreate(_o: string, batch: Record<string, unknown>[]) { writes.push(...batch); return batch; },
97+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
98+
async commit() {}, async rollback() {},
99+
};
100+
return { driver, reads, writes };
101+
}
102+
103+
const refusalOf = async (p: Promise<unknown>) =>
104+
p.then(() => null, (e: any) => e as Error & { code?: string; status?: number; fields?: Array<{ field: string; code: string }> });
105+
106+
const labelOf = (value: unknown) => (value instanceof Date ? `Date ${value.toISOString()}` : JSON.stringify(value));
107+
108+
describe('[#20481] the write door — a date string is written in its YYYY-MM-DD form, or it is VALIDATION_FAILED before any write', () => {
109+
let engine: ObjectQL;
110+
let reads: unknown[];
111+
let writes: Record<string, unknown>[];
112+
113+
beforeEach(async () => {
114+
const rec = makeRecordingDriver();
115+
reads = rec.reads;
116+
writes = rec.writes;
117+
engine = new ObjectQL();
118+
engine.registerDriver(rec.driver, true);
119+
await engine.init();
120+
engine.registry.registerObject(ledger, 'test');
121+
});
122+
123+
const doors = (value: unknown) => [
124+
['insert', () => engine.insert('ledger', { id: 'n1', customer_id: 'c1', placed_on: value })],
125+
['update', () => engine.update('ledger', { id: 'r1', placed_on: value })],
126+
['multi-row update', () => engine.update('ledger', { placed_on: value }, { where: { customer_id: 'c1' }, multi: true })],
127+
] as const;
128+
129+
it('refuses every other spelling on insert, update and a multi-row update, with the field and invalid_date — and writes nothing', async () => {
130+
for (const value of [...REFUSED, ...STILL_REFUSED.map(([, v]) => v)]) {
131+
for (const [door, call] of doors(value)) {
132+
const err = await refusalOf(call());
133+
expect(err, `${door}, ${labelOf(value)}`).not.toBeNull();
134+
expect(err!.code, `${door}, ${labelOf(value)}`).toBe('VALIDATION_FAILED');
135+
expect(err!.fields, `${door}, ${labelOf(value)}`).toEqual([expect.objectContaining({ field: 'placed_on', code: 'invalid_date' })]);
136+
}
137+
}
138+
expect(writes, 'no write — every refusal precedes the driver').toHaveLength(0);
139+
});
140+
141+
it('the dry-run validate predicts each refusal — and each accepted value as valid', async () => {
142+
for (const value of [...REFUSED, ...STILL_REFUSED.map(([, v]) => v)]) {
143+
const verdict = await engine.validate('ledger', { placed_on: value });
144+
expect(verdict.valid, labelOf(value)).toBe(false);
145+
expect(verdict.results[0]!.errors, labelOf(value)).toEqual([expect.objectContaining({ field: 'placed_on', code: 'invalid_date' })]);
146+
}
147+
for (const [name, value] of ACCEPTED) {
148+
expect((await engine.validate('ledger', { placed_on: value })).valid, name).toBe(true);
149+
}
150+
});
151+
152+
it('accepts a leading YYYY-MM-DD and a Date on every door — the POSITIVE CONTROL', async () => {
153+
for (const [name, value] of ACCEPTED) {
154+
for (const [door, call] of doors(value)) {
155+
const before = writes.length;
156+
await expect(call(), `${door}, ${name}`).resolves.toBeDefined();
157+
expect(writes.length, `${door}, ${name} reached the driver`).toBe(before + 1);
158+
}
159+
}
160+
});
161+
162+
it('one reading at both doors: a date string is refused as a written value exactly when it is refused as a comparand', async () => {
163+
const strings = [...REFUSED, ...ACCEPTED.map(([, v]) => v).filter((v): v is string => typeof v === 'string')];
164+
for (const value of strings) {
165+
const written = (await engine.validate('ledger', { placed_on: value })).valid;
166+
const err = await refusalOf(engine.find('ledger', { where: { placed_on: { $gte: value } } }));
167+
if (err) expect(err, `where ${JSON.stringify(value)}`).toMatchObject({ code: 'INVALID_FILTER', status: 400 });
168+
expect({ written, compared: err === null }, JSON.stringify(value)).toEqual({ written: !REFUSED.includes(value), compared: !REFUSED.includes(value) });
169+
}
170+
expect(reads, 'a read for each accepted comparand, none for a refused one').toHaveLength(strings.length - REFUSED.length);
171+
});
172+
});

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
* - format email / url / phone (lightweight RFC-aware regex)
5555
* - select / multiselect: value must appear in `options`
5656
* - boolean / toggle: must coerce to boolean
57-
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999
57+
* - date / datetime: must be ISO-parsable, naming a year from 0001 to 9999;
58+
* a `date` string also carries a leading `YYYY-MM-DD` (#20481)
5859
*
5960
* System-injected fields (`id`, `created_at`, `created_by`,
6061
* `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly`
@@ -85,7 +86,7 @@ import {
8586
parseNumericString,
8687
} from '@objectstack/spec/data';
8788
import type { FieldErrorCode } from '@objectstack/spec/api';
88-
import { isOutsideTemporalYearRange } from '@objectstack/core';
89+
import { isOutsideTemporalYearRange, isUninterpretableTemporalComparand } from '@objectstack/core';
8990
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
9091
import {
9192
renderValidationMessage,
@@ -1220,7 +1221,29 @@ function validateOne(
12201221
// 201 on memory and SQLite) and PostgreSQL refused it with a 500; year 0
12211222
// is a 500 on PostgreSQL on both kinds. Same code and words as any other
12221223
// value that is not a valid date.
1223-
if (readable && !isOutsideTemporalYearRange(value, t)) return null;
1224+
//
1225+
// [#20481] …and a `date` STRING is one the `date` storage rule reads: a
1226+
// leading `YYYY-MM-DD` (after trimming), which `temporalStorageForm`
1227+
// collapses to that day. The rule hands every other string back unchanged,
1228+
// so `"2026/07/15"`, `"07/15/2026"`, `"15 July 2026"` or `"2026-7-15"` —
1229+
// each `Date.parse`-readable — was stored verbatim on memory and SQLite, a
1230+
// non-day that sorts and compares as text beside real days, while
1231+
// PostgreSQL read it by its `DateStyle` (`07/08/2026` is July 8 under
1232+
// MDY and August 7 under DMY). No other spelling is canonicalised, on
1233+
// purpose: `07/08/2026` names two days, and a guess stores the wrong one
1234+
// silently. The question is asked of `@objectstack/core`'s
1235+
// `isUninterpretableTemporalComparand` — the temporal-comparand door's
1236+
// reading of the same rule, never a second copy of it here — so every
1237+
// `date` string that door refuses as a comparand is refused as a written
1238+
// value too; the `Date.parse` check above still applies on top of it
1239+
// (`2026-13-45` has a leading day shape and no reading). Its two
1240+
// comparand-only exemptions never reach here: a blank is missing before
1241+
// this arm, and a `{placeholder}` is not `Date.parse`-readable. A `Date`
1242+
// is not a string and keeps its UTC calendar day; a `datetime` is
1243+
// untouched.
1244+
const readsAsDay =
1245+
t !== 'date' || typeof value !== 'string' || !isUninterpretableTemporalComparand('date', value);
1246+
if (readable && readsAsDay && !isOutsideTemporalYearRange(value, t)) return null;
12241247
// Same wire code, two sentences: "a valid date" vs "a valid datetime".
12251248
return fail('invalid_date', { type: t }, t === 'datetime' ? 'invalid_datetime' : 'invalid_date');
12261249
}

0 commit comments

Comments
 (0)