Skip to content

Commit 210556d

Browse files
committed
fix(objectql,rest): a date or datetime refused for its year names the kind's years, not an ISO-8601 sentence
The record validator and the import's cell reader choose invalid_date_range / invalid_datetime_range for a readable value whose year falls outside the kind's supported years; the years come from core's SUPPORTED_TEMPORAL_YEARS, the range isOutsideTemporalYearRange judges by. The field code stays invalid_date and its constraint stays { type }. Claude-Session: https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU Co-authored-by: Claude <noreply@anthropic.com>
1 parent bee75ce commit 210556d

9 files changed

Lines changed: 363 additions & 24 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/rest': patch
6+
---
7+
8+
fix(objectql,rest): a `date` or `datetime` value refused for its year says so — "must be a date in the years 0001 to 9999" / "must be a datetime whose UTC year falls in the years 1000 to 9999" — instead of "must be a valid date (ISO-8601)", which was false for a value such as `0500-07-15T10:00:00Z` (#20846)
9+
10+
Clause-②: yes (widening) — one new export on `@objectstack/core`'s root, `SUPPORTED_TEMPORAL_YEARS`. No value's verdict moves and no wire key moves: the field code stays `invalid_date` and its `constraint` stays `{ type }`.
11+
12+
`POST` / `PATCH /api/v1/data/:object` and each row of `POST /api/v1/data/:object/import`
13+
refuse a `date` outside the years 0001 to 9999 and a `datetime` whose UTC year falls
14+
outside 1000 to 9999. When the value itself is readable — an ISO 8601 string such as
15+
`0500-07-15T10:00:00Z` or `+010000-01-01`, or a `Date` — the refusal's message now
16+
names the kind's years. An author who read "not valid ISO" rewrote the spelling, and no
17+
spelling of that year is admitted.
18+
19+
- `@objectstack/spec`: the validation message catalog gains `invalid_date_range` and
20+
`invalid_datetime_range` in `en`, `zh-CN`, `ja-JP` and `es-ES`. They are two more
21+
sentences of the `invalid_date` code, never a wire value. The years are the template
22+
parameters `{{firstYear}}` / `{{lastYear}}`. A deployment that overrides a message
23+
under `validation.field.invalid_date` or `validation.field.invalid_datetime` does not
24+
cover these values. To override their text, define
25+
`validation.field.invalid_date_range` / `validation.field.invalid_datetime_range`.
26+
- `@objectstack/core`: `SUPPORTED_TEMPORAL_YEARS` (`{ date: { first: 1, last: 9999 },
27+
datetime: { first: 1000, last: 9999 } }`, frozen) is the range
28+
`isOutsideTemporalYearRange` judges by. It is exported so a refusal names the range
29+
from the source the doors use, never a copy of its numbers.
30+
- `@objectstack/objectql` and `@objectstack/rest`: the record validator and the import's
31+
cell reader choose the range sentence for such a value. An import cell with more than
32+
four year digits (`+010000-01-01`) is refused by the import's reader. It used to read
33+
"is not a valid date" and now gets the same range sentence as the write door.
34+
35+
**What is not affected.** Which values are refused is unchanged, and so is the refusal's
36+
code (`invalid_date`) and `constraint`. A value that is not readable keeps its sentence:
37+
"must be a valid date (ISO-8601)" at the write door, `"…" is not a valid date` at the import.
38+
So does a number, which is never a written `date` or `datetime`.

‎packages/core/src/utils/temporal-storage-form.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// `sql-driver-temporal-storage-form.test.ts`). This file pins the rule itself.
1010

1111
import { describe, it, expect } from 'vitest';
12-
import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';
12+
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';
1313

1414
describe('temporalStorageForm — datetime: canonical UTC ISO text', () => {
1515
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
@@ -274,3 +274,27 @@ describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime v
274274
}
275275
});
276276
});
277+
278+
// [#20846] The exported range is the one the predicate judges by, so a refusal
279+
// that names it (the record validator's and the import's sentence for a
280+
// readable value in a year outside it) names the years the doors enforce: the
281+
// first and last year of each kind are inside, the year before and the year
282+
// after are outside, whatever the numbers are.
283+
describe('[#20846] SUPPORTED_TEMPORAL_YEARS — the range isOutsideTemporalYearRange judges by', () => {
284+
const day = (year: number) => new Date(Date.UTC(2000, 5, 15)).setUTCFullYear(year);
285+
286+
it.each(['date', 'datetime'] as const)('%s: its first and last years are inside, the years beside them outside', (kind) => {
287+
const { first, last } = SUPPORTED_TEMPORAL_YEARS[kind];
288+
expect(first).toBeLessThan(last);
289+
expect(isOutsideTemporalYearRange(day(first), kind), `${kind} ${first}`).toBe(false);
290+
expect(isOutsideTemporalYearRange(day(last), kind), `${kind} ${last}`).toBe(false);
291+
expect(isOutsideTemporalYearRange(day(first - 1), kind), `${kind} ${first - 1}`).toBe(true);
292+
expect(isOutsideTemporalYearRange(day(last + 1), kind), `${kind} ${last + 1}`).toBe(true);
293+
});
294+
295+
it('is frozen: a caller cannot move the range the doors enforce', () => {
296+
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS)).toBe(true);
297+
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS.date)).toBe(true);
298+
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS.datetime)).toBe(true);
299+
});
300+
});

‎packages/core/src/utils/temporal-storage-form.ts‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,14 +159,23 @@ function instantMs(value: unknown): number | undefined {
159159
}
160160

161161
/**
162-
* [#20264] The first year a value of each kind may name — [#20280] per kind:
163-
* a `date` from 0001, a `datetime` from 1000, MySQL's documented `DATETIME`
164-
* floor (see the module note). The `date` entry is also the year the `date`
165-
* rule pads to four digits from ({@link canonicalCalendarDay}).
162+
* [#20264] The supported years of each kind, the first and the last inclusive —
163+
* [#20280] a `date` from 0001, a `datetime` from 1000, MySQL's documented
164+
* `DATETIME` floor (see the module note); both to 9999. The one range
165+
* {@link isOutsideTemporalYearRange} judges a value by. The `date` entry's
166+
* first year is also the year the `date` rule pads to four digits from
167+
* ({@link canonicalCalendarDay}).
168+
*
169+
* [#20846] Exported so a refusal can NAME the range it refused a value for —
170+
* the record validator's and the import's sentence for a readable value in a
171+
* year outside it — from this one source, never from a copy of its numbers.
166172
*/
167-
const FIRST_SUPPORTED_YEAR: Readonly<Record<'date' | 'datetime', number>> = { date: 1, datetime: 1000 };
168-
/** [#20264] The last year a `date` or `datetime` value may name. */
169-
const LAST_SUPPORTED_YEAR = 9999;
173+
export const SUPPORTED_TEMPORAL_YEARS: Readonly<
174+
Record<'date' | 'datetime', Readonly<{ first: number; last: number }>>
175+
> = Object.freeze({
176+
date: Object.freeze({ first: 1, last: 9999 }),
177+
datetime: Object.freeze({ first: 1000, last: 9999 }),
178+
});
170179

171180
/**
172181
* [#20264] Does `value` name a year outside the supported years for a column
@@ -203,7 +212,8 @@ export function isOutsideTemporalYearRange(value: unknown, kind: TemporalCompara
203212
if (ms === undefined) return false;
204213
year = new Date(ms).getUTCFullYear();
205214
}
206-
return year < FIRST_SUPPORTED_YEAR[kind] || year > LAST_SUPPORTED_YEAR;
215+
const { first, last } = SUPPORTED_TEMPORAL_YEARS[kind];
216+
return year < first || year > last;
207217
}
208218

209219
function canonicalCalendarDay(value: unknown): unknown {
@@ -227,7 +237,7 @@ function canonicalCalendarDay(value: unknown): unknown {
227237
// ({@link isOutsideTemporalYearRange}). [#20280] The `date` floor, never
228238
// the `datetime` one: a calendar day in 0001..0999 is a supported `date`.
229239
const y = instant.getUTCFullYear();
230-
const yyyy = y >= FIRST_SUPPORTED_YEAR.date ? String(y).padStart(4, '0') : String(y);
240+
const yyyy = y >= SUPPORTED_TEMPORAL_YEARS.date.first ? String(y).padStart(4, '0') : String(y);
231241
const m = String(instant.getUTCMonth() + 1).padStart(2, '0');
232242
const d = String(instant.getUTCDate()).padStart(2, '0');
233243
return `${yyyy}-${m}-${d}`;

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,3 +1360,76 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (#
13601360
});
13611361
});
13621362
});
1363+
1364+
/**
1365+
* [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls
1366+
* outside the kind's supported years, is refused with the SAME wire code as
1367+
* before — `invalid_date`, constraint `{ type }` and nothing more — and a
1368+
* sentence that names the years. "Must be a valid datetime (ISO-8601)" was
1369+
* false for `0500-07-15T10:00:00Z`, which is valid ISO 8601: the year is what
1370+
* is refused. A value that is not readable keeps the ISO sentence (the
1371+
* CONTROL), and so does a readable value inside the years that is refused for
1372+
* its spelling.
1373+
*/
1374+
describe('[#20846] validateRecord — a readable date / datetime outside its years names the years', () => {
1375+
const schema = { fields: { due: { type: 'date', label: 'Due' }, at: { type: 'datetime', label: 'At' } } };
1376+
const refusal = (data: Record<string, unknown>, messages?: { locale: string }) => {
1377+
try {
1378+
validateRecord(schema, data, 'insert', messages ? { messages } : undefined);
1379+
} catch (e) {
1380+
expect(e).toBeInstanceOf(ValidationError);
1381+
const fields = (e as ValidationError).fields;
1382+
expect(fields).toHaveLength(1);
1383+
return fields[0];
1384+
}
1385+
throw new Error('expected a ValidationError');
1386+
};
1387+
const DATETIME_RANGE = 'At must be a datetime whose UTC year falls in the years 1000 to 9999';
1388+
const DATE_RANGE = 'Due must be a date in the years 0001 to 9999';
1389+
1390+
it.each([
1391+
['a datetime in year 500', { at: '0500-07-15T10:00:00Z' }, 'datetime', DATETIME_RANGE],
1392+
['a datetime in year 0', { at: '0000-06-15T10:00:00.000Z' }, 'datetime', DATETIME_RANGE],
1393+
['a datetime in 9999 in its zone, 10000 in UTC', { at: '9999-12-31T23:59:59-01:00' }, 'datetime', DATETIME_RANGE],
1394+
['a datetime in 1000 in its zone, 999 in UTC', { at: '1000-01-01T00:00:00+08:00' }, 'datetime', DATETIME_RANGE],
1395+
['a datetime in year 10000, the extended ISO string', { at: '+010000-01-01T00:00:00.000Z' }, 'datetime', DATETIME_RANGE],
1396+
['a Date in year 500', { at: new Date(Date.parse('0500-07-15T10:00:00Z')) }, 'datetime', DATETIME_RANGE],
1397+
['a date in year 10000, the extended ISO string', { due: '+010000-01-01T00:00:00.000Z' }, 'date', DATE_RANGE],
1398+
['a date in year 10000, the extended ISO day', { due: '+010000-01-01' }, 'date', DATE_RANGE],
1399+
['a date in year 0, a bare day', { due: '0000-06-15' }, 'date', DATE_RANGE],
1400+
['a date in year -1', { due: '-000001-01-01T00:00:00.000Z' }, 'date', DATE_RANGE],
1401+
] as const)('%s: invalid_date, constraint { type } only, and the range sentence', (_name, data, type, message) => {
1402+
const f = refusal(data as Record<string, unknown>);
1403+
expect(f.code).toBe('invalid_date');
1404+
expect(f.constraint).toEqual({ type });
1405+
expect(f.message).toBe(message);
1406+
});
1407+
1408+
it('CONTROL — a value that is not readable, or a number, keeps the ISO sentence', () => {
1409+
expect(refusal({ at: 'not-a-date' })).toMatchObject({ code: 'invalid_date', message: 'At must be a valid datetime (ISO-8601)' });
1410+
expect(refusal({ due: 'not-a-date' })).toMatchObject({ code: 'invalid_date', message: 'Due must be a valid date (ISO-8601)' });
1411+
// Epoch milliseconds in year 10000: never a written value, whatever its year.
1412+
const y10000 = Date.parse('+010000-01-01T00:00:00.000Z');
1413+
expect(refusal({ at: y10000 })).toMatchObject({ code: 'invalid_date', message: 'At must be a valid datetime (ISO-8601)' });
1414+
expect(refusal({ due: y10000 })).toMatchObject({ code: 'invalid_date', message: 'Due must be a valid date (ISO-8601)' });
1415+
});
1416+
1417+
it('CONTROL — a readable value inside the years, refused for its spelling, keeps the ISO sentence', () => {
1418+
expect(refusal({ at: '2026-02-30T10:00:00Z' }).message).toBe('At must be a valid datetime (ISO-8601)');
1419+
expect(refusal({ at: '07/15/2026 10:00' }).message).toBe('At must be a valid datetime (ISO-8601)');
1420+
expect(refusal({ due: '2026/07/15' }).message).toBe('Due must be a valid date (ISO-8601)');
1421+
});
1422+
1423+
it('CONTROL — the years\' edges are written', () => {
1424+
for (const data of [{ at: '1000-01-01T00:00:00.000Z' }, { at: '9999-12-31T23:59:59.999Z' }, { due: '0001-01-01' }, { due: '9999-12-31' }]) {
1425+
expect(() => validateRecord(schema, data, 'insert'), JSON.stringify(data)).not.toThrow();
1426+
}
1427+
});
1428+
1429+
it('names the years in the caller\'s locale', () => {
1430+
expect(refusal({ at: '0500-07-15T10:00:00Z' }, { locale: 'zh-CN' }).message)
1431+
.toBe('At必须是 UTC 年份在 1000 年至 9999 年之间的日期时间');
1432+
expect(refusal({ due: '0000-06-15' }, { locale: 'ja-JP' }).message)
1433+
.toBe('Dueは 0001 年から 9999 年までの日付を入力してください');
1434+
});
1435+
});

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@
5858
* for a `date` and from 1000 to 9999 for a `datetime` (#20280);
5959
* a `date` string also carries a leading `YYYY-MM-DD` (#20481);
6060
* a string's leading day exists, and a `datetime` string is
61-
* an ISO 8601 spelling (#20525) — refused, never rolled over
61+
* an ISO 8601 spelling (#20525) — refused, never rolled over;
62+
* a readable value outside its kind's years is refused with
63+
* a sentence naming those years (#20846)
6264
* - time: a zone-less wall clock `HH:MM[:SS[.f]]`, or an ISO instant
6365
* with a four-digit UTC year (#20671); a `Z` / offset suffix
6466
* on a time of day is refused with its own sentence
@@ -93,7 +95,7 @@ import {
9395
classifyFilterToken,
9496
} from '@objectstack/spec/data';
9597
import type { FieldErrorCode } from '@objectstack/spec/api';
96-
import { isUninterpretableTemporalComparand } from '@objectstack/core';
98+
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand } from '@objectstack/core';
9799
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
98100
import {
99101
renderValidationMessage,
@@ -434,6 +436,12 @@ export function resolveFieldLabel(
434436
* is keyed by message, the wire by `code` — ADR-0114's vocabulary does not split
435437
* just because a sentence differs.
436438
*
439+
* `messageParams` fills a sentence's words that are no constraint of THIS field
440+
* — [#20846] the supported years of a `date` / `datetime`, a fact of the
441+
* platform — so they are interpolated into the message and never shipped:
442+
* `constraint` is the published payload, and a key added there for a sentence
443+
* would widen it with no reader.
444+
*
437445
* Exported because the object-level rule evaluator (`rule-validator.ts`) emits
438446
* into the SAME envelope and must localize its built-in messages the same way —
439447
* two constructors would drift.
@@ -451,6 +459,8 @@ export function buildFieldError(
451459
/** Catalog key; defaults to `code`. */
452460
messageKey?: string;
453461
options?: string[];
462+
/** Interpolated into the message ONLY — never shipped (see above). */
463+
messageParams?: Record<string, unknown>;
454464
},
455465
ctx?: ValidationMessageContext,
456466
): FieldValidationError {
@@ -462,7 +472,11 @@ export function buildFieldError(
462472
field: args.field,
463473
// `value` rides the same interpolation namespace as the constraint keys,
464474
// so a template can say `{{value}}` without a second parameter channel.
465-
params: { ...(args.constraint ?? {}), ...(args.value !== undefined ? { value: args.value } : {}) },
475+
params: {
476+
...(args.messageParams ?? {}),
477+
...(args.constraint ?? {}),
478+
...(args.value !== undefined ? { value: args.value } : {}),
479+
},
466480
},
467481
{ locale: ctx?.locale, translate: ctx?.translate },
468482
);
@@ -845,6 +859,14 @@ function valueShapeDetail(error: { issues: ReadonlyArray<{ code: string; message
845859
return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value shape';
846860
}
847861

862+
/**
863+
* [#20846] A supported year as a range sentence names it: four digits, the
864+
* `YYYY` a value in that year is written with (`0001`, never `1`).
865+
*/
866+
function fourDigitYear(year: number): string {
867+
return String(year).padStart(4, '0');
868+
}
869+
848870
/**
849871
* [#20671] Is this a time of day with a zone suffix — `"10:00Z"`,
850872
* `"10:00:00+08:00"`, `"10:00-0530"` — whose wall clock is one the `time`
@@ -876,7 +898,8 @@ function validateOne(
876898
messageKey?: string,
877899
options?: string[],
878900
value?: string | number | boolean,
879-
) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value }, ctx);
901+
messageParams?: Record<string, unknown>,
902+
) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value, messageParams }, ctx);
880903

881904
// ── required ────────────────────────────────────────────────────
882905
// `autonumber` is runtime-owned: the value is generated by the engine /
@@ -1282,8 +1305,32 @@ function validateOne(
12821305
// which the `date` rule reads as its day when it is a comparand. A `Date`
12831306
// names a real instant and is judged by its year only.
12841307
if (readable && !isUninterpretableTemporalComparand(t, value)) return null;
1285-
// Same wire code, two sentences: "a valid date" vs "a valid datetime".
1286-
return fail('invalid_date', { type: t }, t === 'datetime' ? 'invalid_datetime' : 'invalid_date');
1308+
// Same wire code, four sentences: "a valid date" vs "a valid datetime",
1309+
// and [#20846] each kind's range sentence for a readable value whose year
1310+
// falls outside the kind's supported years — the class the one rule asks
1311+
// `isOutsideTemporalYearRange` about, whatever else is wrong with the value,
1312+
// which is the class the temporal-comparand door names by its years too.
1313+
// "Must be a valid datetime (ISO-8601)" is false for `0500-07-15T10:00:00Z`
1314+
// (valid ISO 8601) and sends its author to rewrite a spelling when no
1315+
// spelling of year 500 is admitted: the year is what is refused. The years
1316+
// are core's `SUPPORTED_TEMPORAL_YEARS`, the range that one predicate
1317+
// judges by, handed to the sentence as message-only parameters: ⛔ no new
1318+
// key on `constraint` (the published payload) and no literal year here or
1319+
// in the catalog. A value that is not `readable` — unparseable, or a
1320+
// number, which is never a written `date` / `datetime` whatever its year —
1321+
// keeps the ISO sentence.
1322+
const years = readable && isOutsideTemporalYearRange(value, t) ? SUPPORTED_TEMPORAL_YEARS[t] : undefined;
1323+
const sentence = t === 'datetime'
1324+
? (years ? 'invalid_datetime_range' : 'invalid_datetime')
1325+
: (years ? 'invalid_date_range' : 'invalid_date');
1326+
return fail(
1327+
'invalid_date',
1328+
{ type: t },
1329+
sentence,
1330+
undefined,
1331+
undefined,
1332+
years && { firstYear: fourDigitYear(years.first), lastYear: fourDigitYear(years.last) },
1333+
);
12871334
}
12881335

12891336
// ── time (time-of-day) ──────────────────────────────────────────

0 commit comments

Comments
 (0)