Skip to content

Commit f6ccca4

Browse files
fix(objectql,rest): a date or datetime refused for its year names the kind's years, not an ISO-8601 sentence (#20846) (#20952)
Fixes #20846 Clause-②: yes (widening) The claim on #20846 declared `Clause-②: no`. This diff adds one export to `@objectstack/core`'s root, `SUPPORTED_TEMPORAL_YEARS`, so the public surface widens. It is declared `yes (widening)` here and in the changeset (`@objectstack/core` minor), and the report says so. No value's verdict moves, and no wire key moves. ## What changes A `date` or `datetime` value refused for its year now gets a message that names the kind's years. A readable ISO 8601 value such as `0500-07-15T10:00:00Z`, `+010000-01-01` or `0000-06-15`, or a `Date`, used to get "must be a valid datetime (ISO-8601)", which is false for that value. The field code stays `invalid_date`, and its `constraint` stays `{ type }`. | door | value | before | after | |:--|:--|:--|:--| | `POST /api/v1/data/:object` | `opened_at: "0500-07-15T10:00:00Z"` | Opened must be a valid datetime (ISO-8601) | Opened must be a datetime whose UTC year falls in the years 1000 to 9999 | | `POST /api/v1/data/:object` | `placed_on: "+010000-01-01"` | Placed must be a valid date (ISO-8601) | Placed must be a date in the years 0001 to 9999 | | import row, refused by the write door | cell `0500-07-15T10:00:00Z` (`datetime`) | Opened must be a valid datetime (ISO-8601) | Opened must be a datetime whose UTC year falls in the years 1000 to 9999 | | import row, refused by the import's reader | cell `+010000-01-01` (`date`) | Placed: "+010000-01-01" is not a valid date | Placed must be a date in the years 0001 to 9999 | | both (control) | `not-a-date` | unchanged | unchanged | - **`packages/spec/src/system/validation-message.ts`**: adds two `invalid_date` sentences, `invalid_date_range` and `invalid_datetime_range`, in `en`, `zh-CN`, `ja-JP` and `es-ES`. The years come from the `{{firstYear}}` / `{{lastYear}}` parameters. No template spells a year, and a spec test pins that. - **`packages/core/src/utils/temporal-storage-form.ts`**: this landing site is outside the claim's file surface. The bounds were two private constants beside `isOutsideTemporalYearRange`. They are now one exported, frozen `SUPPORTED_TEMPORAL_YEARS`, and the predicate reads it. Triage's direction requires the sentence's years to come "from the same range function the doors use", with no literal copy, and the dispatch forbids a second copy of the bounds. Exporting them from their one producer was the only way to meet both. Nothing else in core changes. - **`packages/objectql/src/validation/record-validator.ts`**: the date arm picks the range sentence when the value is `readable` and `isOutsideTemporalYearRange(value, t)` holds. That is the same year class that `yearClassOf` in the temporal-comparand door names by its years. `buildFieldError` gains an internal `messageParams` channel, which is interpolated into the message and never shipped. The years reach the sentence without a new key on `constraint`. `buildFieldError` is not exported from the package entry. - **`packages/rest/src/import-coerce.ts`**: a `date` / `datetime` cell that the reader does not take, but whose year `Date.parse` reads outside the range (more than four year digits, such as `+010000-01-01`), gets the write door's range sentence with the write door's code. Both refusal paths of one import now give one sentence per kind. A number keeps the import's own sentence, as the write door keeps its sentence for a number. ## Premise check (measured on `origin/main` `bee75cebe`) - **H1**: one `return fail('invalid_date', …)` served both the malformed and the out-of-range value. The validator can tell them apart with core's existing `isOutsideTemporalYearRange` (already exported), so it needs no constraint key and no wire code. The new branch adds no second `fail('invalid_date', …)` call site, and `check:dispatcher-error-vocabulary` is green. - **H2**: both doors refuse import rows. A cell with a four-digit year (`0500-07-15T10:00:00Z`, `0000-06-15`) passes `import-coerce.ts` and the record validator refuses it. A cell with more than four year digits (`+010000-01-01`, `10000-01-01`) matches neither of the reader's shapes, so `import-coerce.ts` refuses it with `import_invalid_date`, the same false claim. The fix covers both. No other lane file refuses such a row. - **H3**: `{{…}}` placeholders were filled only from `constraint`, `value`, `label` and `field` at the write door, and only from `value` at the import. The bounds were private (`FIRST_SUPPORTED_YEAR` / `LAST_SUPPORTED_YEAR`), which is the core change above. - **H4**: no translation bundle or gate learns the keys. The catalog ships as constants outside the extract-and-gate bundle pipeline (its own module note). `check:i18n` reads none of it, and the catalog's own completeness test (`every locale defines every message key`) covers the four locales. ## Tests Full package suites, run on this branch under the shared verify lock. The runs differ from the head `6adb0dbbf` only in the objectql test file that the last commit restructured, and that file was re-run at the head. - `@objectstack/spec`: 584 files, 17183 passed, 1 todo. `typecheck` exit 0. `build`, then `check:generated`: all 15 generated artifacts up to date. - `@objectstack/core`: 61 files, 1798 passed. `typecheck` exit 0. - `@objectstack/objectql`: 347 files, 6813 passed. `typecheck` exit 0 at `6adb0dbbf`. `src/validation/record-validator.test.ts` at `6adb0dbbf`: 126 passed. - `@objectstack/rest`: 243 files, 4868 passed, 114 skipped. `typecheck` exit 0. New pins: - `packages/rest/src/data-temporal-year-range-message.test.ts` goes through `POST /api/v1/data/:object` and `POST /api/v1/data/:object/import` over a real `SqlDriver` on SQLite. It asserts `400`, `code: VALIDATION_FAILED`, the finding's `code: invalid_date` and `constraint` equal to `{ type }` exactly, the finding's message, and the first sentence of the envelope's `error`. It covers `datetime` years 500, 999, 10000 (as UTC) and `+010000`, and `date` years `+010000` and 0. The malformed controls keep the ISO sentence. On the import, each refused row carries the same code and sentence whichever reader refused it, the malformed cells keep the import's own sentence, and the years' edges are stored. - The spec catalog test checks that the placeholders are present in every locale and that no range template contains a digit. - The record-validator unit pins include a `Date` in year 500, the control sentences, the edges, and the `zh-CN` / `ja-JP` renderings. - The `import-coerce` unit pins are paired with controls. - A core test checks that the edges of `SUPPORTED_TEMPORAL_YEARS` are inside `isOutsideTemporalYearRange` and the years next to them are outside, and that the object is frozen. **Ablation.** Both selections were disabled at once through `scripts/ablation-replace.mjs`: in the record validator, the `const years = readable && isOutsideTemporalYearRange(…)` line was replaced by a `globalThis.__ablation20846` guard, and in `import-coerce.ts` the `outsideYears(raw, t)` line likewise. The anchors hit 1 then 0, and the blobs moved. objectql was rebuilt, and `ablation-dist-preflight` found the marker in 4 built files. - REST pins: 10 failed, 143 passed. The received messages were the base sentences: "Opened must be a valid datetime (ISO-8601)", "Placed must be a valid date (ISO-8601)", and `Due: "+010000-01-01" is not a valid date`. - objectql `record-validator.test.ts`: 11 failed, 115 passed. - Every control stayed green, and the failures went red in the expected direction. - Restore: each blob equals HEAD, `git diff HEAD` is empty, and `git status --porcelain` is empty. objectql was rebuilt, preflight `--absent` passed, and the pins are green again (rest 203 passed, 8 skipped; objectql 136 passed). **Type reverse check.** In `import-coerce.ts`, `SUPPORTED_TEMPORAL_YEARS[kind]` was replaced by `SUPPORTED_TEMPORAL_YEARS['time']`, and `tsc --noEmit` in `packages/rest` failed with `TS7053`, naming `Readonly…Record…"datetime" | "date"…`. That shows the consumer reads the rebuilt core `.d.ts`. The file was then restored, and its blob equals HEAD. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` over this diff (merge base `bee75cebe`) derives 88 commands. All 88 were run, with exit codes recorded, and `--ran` reconciles them: "88 derived famil(ies) accounted for — 88 run, 0 NOT-MEASURED (a DERIVED zero …)". - Four gates first exited non-zero. None of these was a pass. Their final runs: - `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` refused on a shallow clone. After `git fetch --shallow-since=2026-06-25 origin main`, both exit 0. - `check:dual-build-cjs-loads` and `check:type-check-debt` hit PREREQUISITE NOT MET (the whole tree was not built). After building the 43 missing packages, both exit 0. - `check:error-code-casing` was a real red: four `toMatchObject({ code: 'invalid_date', message })` literals in the new objectql test. I restructured them, and the gate exits 0. - The roster gates located in the touched directories also exit 0: `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:filter-alias-parity` and `check-changeset-fixed`. **Lint: a proven narrowing, not a repo-wide run.** At `6adb0dbbf`, `eslint --no-inline-config --format json` over the 9 touched `.ts` files gives 9 results, 0 errors, 0 warnings and 0 ignored. `--print-config` shows each file in eslint's own population (4 to 6 active rules each). This repo's `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, per its own note), so this diff cannot move the verdict on a file it does not touch. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **The comparand door still spells its years as literals.** Its sentences in `packages/objectql/src/temporal-comparand-door.ts` read "the years 0001 to 9999" and "the years 1000 to 9999". They are correct today, and they could now read `SUPPORTED_TEMPORAL_YEARS`. This is drift risk only, outside this card, and nobody holds it. - **Non-ISO spellings are read in the host zone.** `isOutsideTemporalYearRange` reads such a spelling (`10000-01-01`, `10000/01/01`) through `Date.parse`, in the host zone. On a UTC+ host that instant falls in year 9999 UTC, so such a value gets the ISO sentence rather than the range sentence. The ISO sentence is true for that spelling, and the verdict does not move on any host. The pins use the ISO 8601 expanded spelling `+010000-01-01`, which is read the same on every host. - **A value both malformed and out of range gets the range sentence.** An example is `07/15/0500 10:00` on a `datetime`. The comparand door picks its year class the same way, and after the year is fixed, the ISO sentence follows. - **Overrides of the old keys no longer cover these values.** A deployment that overrides `validation.field.invalid_date` / `invalid_datetime` does not cover the range values, because they use their own keys now. The changeset says this. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3693a1b commit f6ccca4

10 files changed

Lines changed: 529 additions & 24 deletions
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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,3 +1360,82 @@ 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+
// Epoch milliseconds in year 10000: never a written value, whatever its year.
1410+
const y10000 = Date.parse('+010000-01-01T00:00:00.000Z');
1411+
for (const [data, sentence] of [
1412+
[{ at: 'not-a-date' }, 'At must be a valid datetime (ISO-8601)'],
1413+
[{ due: 'not-a-date' }, 'Due must be a valid date (ISO-8601)'],
1414+
[{ at: y10000 }, 'At must be a valid datetime (ISO-8601)'],
1415+
[{ due: y10000 }, 'Due must be a valid date (ISO-8601)'],
1416+
] as const) {
1417+
const f = refusal(data);
1418+
expect(f.code, JSON.stringify(data)).toBe('invalid_date');
1419+
expect(f.message, JSON.stringify(data)).toBe(sentence);
1420+
}
1421+
});
1422+
1423+
it('CONTROL — a readable value inside the years, refused for its spelling, keeps the ISO sentence', () => {
1424+
expect(refusal({ at: '2026-02-30T10:00:00Z' }).message).toBe('At must be a valid datetime (ISO-8601)');
1425+
expect(refusal({ at: '07/15/2026 10:00' }).message).toBe('At must be a valid datetime (ISO-8601)');
1426+
expect(refusal({ due: '2026/07/15' }).message).toBe('Due must be a valid date (ISO-8601)');
1427+
});
1428+
1429+
it('CONTROL — the years\' edges are written', () => {
1430+
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' }]) {
1431+
expect(() => validateRecord(schema, data, 'insert'), JSON.stringify(data)).not.toThrow();
1432+
}
1433+
});
1434+
1435+
it('names the years in the caller\'s locale', () => {
1436+
expect(refusal({ at: '0500-07-15T10:00:00Z' }, { locale: 'zh-CN' }).message)
1437+
.toBe('At必须是 UTC 年份在 1000 年至 9999 年之间的日期时间');
1438+
expect(refusal({ due: '0000-06-15' }, { locale: 'ja-JP' }).message)
1439+
.toBe('Dueは 0001 年から 9999 年までの日付を入力してください');
1440+
});
1441+
});

‎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)