Skip to content

Commit 3174702

Browse files
committed
test(driver-memory),chore(changeset): the date spellings the write door admits are stored as their day; objectql minor, BREAKING
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
1 parent 089e9ce commit 3174702

2 files changed

Lines changed: 102 additions & 0 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+
});

0 commit comments

Comments
 (0)