Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/20846-temporal-range-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/core': minor
'@objectstack/spec': patch
'@objectstack/objectql': patch
'@objectstack/rest': patch
---

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)

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 }`.

`POST` / `PATCH /api/v1/data/:object` and each row of `POST /api/v1/data/:object/import`
refuse a `date` outside the years 0001 to 9999 and a `datetime` whose UTC year falls
outside 1000 to 9999. When the value itself is readable — an ISO 8601 string such as
`0500-07-15T10:00:00Z` or `+010000-01-01`, or a `Date` — the refusal's message now
names the kind's years. An author who read "not valid ISO" rewrote the spelling, and no
spelling of that year is admitted.

- `@objectstack/spec`: the validation message catalog gains `invalid_date_range` and
`invalid_datetime_range` in `en`, `zh-CN`, `ja-JP` and `es-ES`. They are two more
sentences of the `invalid_date` code, never a wire value. The years are the template
parameters `{{firstYear}}` / `{{lastYear}}`. A deployment that overrides a message
under `validation.field.invalid_date` or `validation.field.invalid_datetime` does not
cover these values. To override their text, define
`validation.field.invalid_date_range` / `validation.field.invalid_datetime_range`.
- `@objectstack/core`: `SUPPORTED_TEMPORAL_YEARS` (`{ date: { first: 1, last: 9999 },
datetime: { first: 1000, last: 9999 } }`, frozen) is the range
`isOutsideTemporalYearRange` judges by. It is exported so a refusal names the range
from the source the doors use, never a copy of its numbers.
- `@objectstack/objectql` and `@objectstack/rest`: the record validator and the import's
cell reader choose the range sentence for such a value. An import cell with more than
four year digits (`+010000-01-01`) is refused by the import's reader. It used to read
"is not a valid date" and now gets the same range sentence as the write door.

**What is not affected.** Which values are refused is unchanged, and so is the refusal's
code (`invalid_date`) and `constraint`. A value that is not readable keeps its sentence:
"must be a valid date (ISO-8601)" at the write door, `"…" is not a valid date` at the import.
So does a number, which is never a written `date` or `datetime`.
26 changes: 25 additions & 1 deletion packages/core/src/utils/temporal-storage-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// `sql-driver-temporal-storage-form.test.ts`). This file pins the rule itself.

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

describe('temporalStorageForm — datetime: canonical UTC ISO text', () => {
const cases: ReadonlyArray<readonly [string, unknown, unknown]> = [
Expand Down Expand Up @@ -274,3 +274,27 @@ describe('[#20264] isOutsideTemporalYearRange — the years a date or datetime v
}
});
});

// [#20846] The exported range is the one the predicate judges by, so a refusal
// that names it (the record validator's and the import's sentence for a
// readable value in a year outside it) names the years the doors enforce: the
// first and last year of each kind are inside, the year before and the year
// after are outside, whatever the numbers are.
describe('[#20846] SUPPORTED_TEMPORAL_YEARS — the range isOutsideTemporalYearRange judges by', () => {
const day = (year: number) => new Date(Date.UTC(2000, 5, 15)).setUTCFullYear(year);

it.each(['date', 'datetime'] as const)('%s: its first and last years are inside, the years beside them outside', (kind) => {
const { first, last } = SUPPORTED_TEMPORAL_YEARS[kind];
expect(first).toBeLessThan(last);
expect(isOutsideTemporalYearRange(day(first), kind), `${kind} ${first}`).toBe(false);
expect(isOutsideTemporalYearRange(day(last), kind), `${kind} ${last}`).toBe(false);
expect(isOutsideTemporalYearRange(day(first - 1), kind), `${kind} ${first - 1}`).toBe(true);
expect(isOutsideTemporalYearRange(day(last + 1), kind), `${kind} ${last + 1}`).toBe(true);
});

it('is frozen: a caller cannot move the range the doors enforce', () => {
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS)).toBe(true);
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS.date)).toBe(true);
expect(Object.isFrozen(SUPPORTED_TEMPORAL_YEARS.datetime)).toBe(true);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/utils/temporal-storage-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,23 @@ function instantMs(value: unknown): number | undefined {
}

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

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

function canonicalCalendarDay(value: unknown): unknown {
Expand All @@ -227,7 +237,7 @@ function canonicalCalendarDay(value: unknown): unknown {
// ({@link isOutsideTemporalYearRange}). [#20280] The `date` floor, never
// the `datetime` one: a calendar day in 0001..0999 is a supported `date`.
const y = instant.getUTCFullYear();
const yyyy = y >= FIRST_SUPPORTED_YEAR.date ? String(y).padStart(4, '0') : String(y);
const yyyy = y >= SUPPORTED_TEMPORAL_YEARS.date.first ? String(y).padStart(4, '0') : String(y);
const m = String(instant.getUTCMonth() + 1).padStart(2, '0');
const d = String(instant.getUTCDate()).padStart(2, '0');
return `${yyyy}-${m}-${d}`;
Expand Down
79 changes: 79 additions & 0 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,3 +1360,82 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (#
});
});
});

/**
* [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls
* outside the kind's supported years, is refused with the SAME wire code as
* before — `invalid_date`, constraint `{ type }` and nothing more — and a
* sentence that names the years. "Must be a valid datetime (ISO-8601)" was
* false for `0500-07-15T10:00:00Z`, which is valid ISO 8601: the year is what
* is refused. A value that is not readable keeps the ISO sentence (the
* CONTROL), and so does a readable value inside the years that is refused for
* its spelling.
*/
describe('[#20846] validateRecord — a readable date / datetime outside its years names the years', () => {
const schema = { fields: { due: { type: 'date', label: 'Due' }, at: { type: 'datetime', label: 'At' } } };
const refusal = (data: Record<string, unknown>, messages?: { locale: string }) => {
try {
validateRecord(schema, data, 'insert', messages ? { messages } : undefined);
} catch (e) {
expect(e).toBeInstanceOf(ValidationError);
const fields = (e as ValidationError).fields;
expect(fields).toHaveLength(1);
return fields[0];
}
throw new Error('expected a ValidationError');
};
const DATETIME_RANGE = 'At must be a datetime whose UTC year falls in the years 1000 to 9999';
const DATE_RANGE = 'Due must be a date in the years 0001 to 9999';

it.each([
['a datetime in year 500', { at: '0500-07-15T10:00:00Z' }, 'datetime', DATETIME_RANGE],
['a datetime in year 0', { at: '0000-06-15T10:00:00.000Z' }, 'datetime', DATETIME_RANGE],
['a datetime in 9999 in its zone, 10000 in UTC', { at: '9999-12-31T23:59:59-01:00' }, 'datetime', DATETIME_RANGE],
['a datetime in 1000 in its zone, 999 in UTC', { at: '1000-01-01T00:00:00+08:00' }, 'datetime', DATETIME_RANGE],
['a datetime in year 10000, the extended ISO string', { at: '+010000-01-01T00:00:00.000Z' }, 'datetime', DATETIME_RANGE],
['a Date in year 500', { at: new Date(Date.parse('0500-07-15T10:00:00Z')) }, 'datetime', DATETIME_RANGE],
['a date in year 10000, the extended ISO string', { due: '+010000-01-01T00:00:00.000Z' }, 'date', DATE_RANGE],
['a date in year 10000, the extended ISO day', { due: '+010000-01-01' }, 'date', DATE_RANGE],
['a date in year 0, a bare day', { due: '0000-06-15' }, 'date', DATE_RANGE],
['a date in year -1', { due: '-000001-01-01T00:00:00.000Z' }, 'date', DATE_RANGE],
] as const)('%s: invalid_date, constraint { type } only, and the range sentence', (_name, data, type, message) => {
const f = refusal(data as Record<string, unknown>);
expect(f.code).toBe('invalid_date');
expect(f.constraint).toEqual({ type });
expect(f.message).toBe(message);
});

it('CONTROL — a value that is not readable, or a number, keeps the ISO sentence', () => {
// Epoch milliseconds in year 10000: never a written value, whatever its year.
const y10000 = Date.parse('+010000-01-01T00:00:00.000Z');
for (const [data, sentence] of [
[{ at: 'not-a-date' }, 'At must be a valid datetime (ISO-8601)'],
[{ due: 'not-a-date' }, 'Due must be a valid date (ISO-8601)'],
[{ at: y10000 }, 'At must be a valid datetime (ISO-8601)'],
[{ due: y10000 }, 'Due must be a valid date (ISO-8601)'],
] as const) {
const f = refusal(data);
expect(f.code, JSON.stringify(data)).toBe('invalid_date');
expect(f.message, JSON.stringify(data)).toBe(sentence);
}
});

it('CONTROL — a readable value inside the years, refused for its spelling, keeps the ISO sentence', () => {
expect(refusal({ at: '2026-02-30T10:00:00Z' }).message).toBe('At must be a valid datetime (ISO-8601)');
expect(refusal({ at: '07/15/2026 10:00' }).message).toBe('At must be a valid datetime (ISO-8601)');
expect(refusal({ due: '2026/07/15' }).message).toBe('Due must be a valid date (ISO-8601)');
});

it('CONTROL — the years\' edges are written', () => {
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' }]) {
expect(() => validateRecord(schema, data, 'insert'), JSON.stringify(data)).not.toThrow();
}
});

it('names the years in the caller\'s locale', () => {
expect(refusal({ at: '0500-07-15T10:00:00Z' }, { locale: 'zh-CN' }).message)
.toBe('At必须是 UTC 年份在 1000 年至 9999 年之间的日期时间');
expect(refusal({ due: '0000-06-15' }, { locale: 'ja-JP' }).message)
.toBe('Dueは 0001 年から 9999 年までの日付を入力してください');
});
});
59 changes: 53 additions & 6 deletions packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@
* for a `date` and from 1000 to 9999 for a `datetime` (#20280);
* 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
* an ISO 8601 spelling (#20525) — refused, never rolled over;
* a readable value outside its kind's years is refused with
* a sentence naming those years (#20846)
* - time: a zone-less wall clock `HH:MM[:SS[.f]]`, or an ISO instant
* with a four-digit UTC year (#20671); a `Z` / offset suffix
* on a time of day is refused with its own sentence
Expand Down Expand Up @@ -93,7 +95,7 @@ import {
classifyFilterToken,
} from '@objectstack/spec/data';
import type { FieldErrorCode } from '@objectstack/spec/api';
import { isUninterpretableTemporalComparand } from '@objectstack/core';
import { SUPPORTED_TEMPORAL_YEARS, isOutsideTemporalYearRange, isUninterpretableTemporalComparand } from '@objectstack/core';
import { isValueDomainMember, type ValueDomain } from '@objectstack/spec/shared';
import {
renderValidationMessage,
Expand Down Expand Up @@ -434,6 +436,12 @@ export function resolveFieldLabel(
* is keyed by message, the wire by `code` — ADR-0114's vocabulary does not split
* just because a sentence differs.
*
* `messageParams` fills a sentence's words that are no constraint of THIS field
* — [#20846] the supported years of a `date` / `datetime`, a fact of the
* platform — so they are interpolated into the message and never shipped:
* `constraint` is the published payload, and a key added there for a sentence
* would widen it with no reader.
*
* Exported because the object-level rule evaluator (`rule-validator.ts`) emits
* into the SAME envelope and must localize its built-in messages the same way —
* two constructors would drift.
Expand All @@ -451,6 +459,8 @@ export function buildFieldError(
/** Catalog key; defaults to `code`. */
messageKey?: string;
options?: string[];
/** Interpolated into the message ONLY — never shipped (see above). */
messageParams?: Record<string, unknown>;
},
ctx?: ValidationMessageContext,
): FieldValidationError {
Expand All @@ -462,7 +472,11 @@ export function buildFieldError(
field: args.field,
// `value` rides the same interpolation namespace as the constraint keys,
// so a template can say `{{value}}` without a second parameter channel.
params: { ...(args.constraint ?? {}), ...(args.value !== undefined ? { value: args.value } : {}) },
params: {
...(args.messageParams ?? {}),
...(args.constraint ?? {}),
...(args.value !== undefined ? { value: args.value } : {}),
},
},
{ locale: ctx?.locale, translate: ctx?.translate },
);
Expand Down Expand Up @@ -845,6 +859,14 @@ function valueShapeDetail(error: { issues: ReadonlyArray<{ code: string; message
return (issues.find((i) => i.code === 'unrecognized_keys') ?? issues[0])?.message ?? 'invalid value shape';
}

/**
* [#20846] A supported year as a range sentence names it: four digits, the
* `YYYY` a value in that year is written with (`0001`, never `1`).
*/
function fourDigitYear(year: number): string {
return String(year).padStart(4, '0');
}

/**
* [#20671] Is this a time of day with a zone suffix — `"10:00Z"`,
* `"10:00:00+08:00"`, `"10:00-0530"` — whose wall clock is one the `time`
Expand Down Expand Up @@ -876,7 +898,8 @@ function validateOne(
messageKey?: string,
options?: string[],
value?: string | number | boolean,
) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value }, ctx);
messageParams?: Record<string, unknown>,
) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value, messageParams }, ctx);

// ── required ────────────────────────────────────────────────────
// `autonumber` is runtime-owned: the value is generated by the engine /
Expand Down Expand Up @@ -1282,8 +1305,32 @@ function validateOne(
// which the `date` rule reads as its day when it is a comparand. A `Date`
// names a real instant and is judged by its year only.
if (readable && !isUninterpretableTemporalComparand(t, value)) 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');
// Same wire code, four sentences: "a valid date" vs "a valid datetime",
// and [#20846] each kind's range sentence for a readable value whose year
// falls outside the kind's supported years — the class the one rule asks
// `isOutsideTemporalYearRange` about, whatever else is wrong with the value,
// which is the class the temporal-comparand door names by its years too.
// "Must be a valid datetime (ISO-8601)" is false for `0500-07-15T10:00:00Z`
// (valid ISO 8601) and sends its author to rewrite a spelling when no
// spelling of year 500 is admitted: the year is what is refused. The years
// are core's `SUPPORTED_TEMPORAL_YEARS`, the range that one predicate
// judges by, handed to the sentence as message-only parameters: ⛔ no new
// key on `constraint` (the published payload) and no literal year here or
// in the catalog. A value that is not `readable` — unparseable, or a
// number, which is never a written `date` / `datetime` whatever its year —
// keeps the ISO sentence.
const years = readable && isOutsideTemporalYearRange(value, t) ? SUPPORTED_TEMPORAL_YEARS[t] : undefined;
const sentence = t === 'datetime'
? (years ? 'invalid_datetime_range' : 'invalid_datetime')
: (years ? 'invalid_date_range' : 'invalid_date');
return fail(
'invalid_date',
{ type: t },
sentence,
undefined,
undefined,
years && { firstYear: fourDigitYear(years.first), lastYear: fourDigitYear(years.last) },
);
}

// ── time (time-of-day) ──────────────────────────────────────────
Expand Down
Loading
Loading