diff --git a/.changeset/20846-temporal-range-message.md b/.changeset/20846-temporal-range-message.md new file mode 100644 index 00000000000..45cb9d7abc6 --- /dev/null +++ b/.changeset/20846-temporal-range-message.md @@ -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`. diff --git a/packages/core/src/utils/temporal-storage-form.test.ts b/packages/core/src/utils/temporal-storage-form.test.ts index 6bcca4b5dca..a5008c241df 100644 --- a/packages/core/src/utils/temporal-storage-form.test.ts +++ b/packages/core/src/utils/temporal-storage-form.test.ts @@ -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 = [ @@ -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); + }); +}); diff --git a/packages/core/src/utils/temporal-storage-form.ts b/packages/core/src/utils/temporal-storage-form.ts index 17abe8b044a..1cf5270e075 100644 --- a/packages/core/src/utils/temporal-storage-form.ts +++ b/packages/core/src/utils/temporal-storage-form.ts @@ -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> = { 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 @@ -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 { @@ -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}`; diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 1bb10c5578c..864ab991759 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -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, 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); + 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 年までの日付を入力してください'); + }); +}); diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 6ce850ae006..21c8b07b279 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -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 @@ -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, @@ -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. @@ -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; }, ctx?: ValidationMessageContext, ): FieldValidationError { @@ -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 }, ); @@ -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` @@ -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, + ) => buildFieldError({ field: name, code, def, constraint, messageKey, options, value, messageParams }, ctx); // ── required ──────────────────────────────────────────────────── // `autonumber` is runtime-owned: the value is generated by the engine / @@ -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) ────────────────────────────────────────── diff --git a/packages/rest/src/data-temporal-year-range-message.test.ts b/packages/rest/src/data-temporal-year-range-message.test.ts new file mode 100644 index 00000000000..c820cb0ee9d --- /dev/null +++ b/packages/rest/src/data-temporal-year-range-message.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20846] A `date` or `datetime` value refused for its YEAR is refused in + * words that name the kind's years, at the public write doors — not with + * "must be a valid datetime (ISO-8601)", which is false for a readable ISO 8601 + * value such as `0500-07-15T10:00:00Z` and sent its author to rewrite a + * spelling when no spelling of that year is admitted. + * + * Two doors, over a real `SqlDriver` on SQLite: + * + * - `POST /api/v1/data/:object` — `400 VALIDATION_FAILED`, the field's code + * `invalid_date` and its `constraint` `{ type }` exactly as before; only the + * sentence moves; + * - `POST /api/v1/data/:object/import` — each refused row carries the same + * code and the same sentence, whichever reader refused it: the import's cell + * reader for a cell with more than four year digits (`+010000-01-01`, no + * shape it takes), the write door behind it for a cell it takes + * (`0500-07-15T10:00:00Z`, `0000-06-15`). + * + * The CONTROL is a malformed value, which keeps its sentence at each door — + * "must be a valid datetime (ISO-8601)" at the write door, the import's own + * `"…" is not a valid datetime` for a cell — beside the years' edges, which + * are written. + * + * Measured with this change's two selections ablated, which leaves the + * sentence keys of the base (`bee75cebe`) and their unchanged templates: every + * refused value below answered with its ISO sentence ("Opened must be a valid + * datetime (ISO-8601)", "Placed must be a valid date (ISO-8601)"), at the write + * door and on an import row it refused, and the import's reader refused the + * cell `+010000-01-01` as `"+010000-01-01" is not a valid date`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server'; + +const OBJECT = 'rest_year_message_20846'; + +const LEDGER = { + name: OBJECT, + label: 'Ledger 20846', + systemFields: false, + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + placed_on: { name: 'placed_on', type: 'date' as const, label: 'Placed' }, + opened_at: { name: 'opened_at', type: 'datetime' as const, label: 'Opened' }, + }, +}; + +const DATE_RANGE = 'Placed must be a date in the years 0001 to 9999'; +const DATETIME_RANGE = 'Opened must be a datetime whose UTC year falls in the years 1000 to 9999'; + +/** field · written value · the refusal's sentence */ +const WRITE_REFUSED: ReadonlyArray = [ + ['opened_at', '0500-07-15T10:00:00Z', DATETIME_RANGE], + ['opened_at', '0999-12-31T23:59:59.999Z', DATETIME_RANGE], + ['opened_at', '9999-12-31T23:59:59-01:00', DATETIME_RANGE], + ['opened_at', '+010000-01-01T00:00:00.000Z', DATETIME_RANGE], + ['placed_on', '+010000-01-01', DATE_RANGE], + ['placed_on', '+010000-01-01T00:00:00.000Z', DATE_RANGE], + ['placed_on', '0000-06-15', DATE_RANGE], + // CONTROL: malformed — the ISO sentence stays. + ['opened_at', 'not-a-date', 'Opened must be a valid datetime (ISO-8601)'], + ['opened_at', '2026-02-30T10:00:00Z', 'Opened must be a valid datetime (ISO-8601)'], + ['placed_on', 'not-a-date', 'Placed must be a valid date (ISO-8601)'], +]; + +/** One import row per cell: id · placed_on · opened_at · the row's sentence, or null when it is stored. */ +const IMPORT_ROWS: ReadonlyArray = [ + ['r1', '', '0500-07-15T10:00:00Z', DATETIME_RANGE], + ['r2', '+010000-01-01', '', DATE_RANGE], + ['r3', '0000-06-15', '', DATE_RANGE], + // CONTROL: malformed — the import's own sentence stays. + ['r4', '', 'not-a-date', 'Opened: "not-a-date" is not a valid datetime'], + ['r5', 'not-a-date', '', 'Placed: "not-a-date" is not a valid date'], + // CONTROL: the years' edges are stored. + ['r6', '0001-01-01', '1000-01-01T00:00:00Z', null], + ['r7', '9999-12-31', '9999-12-31T23:59:59Z', null], +]; + +function createMockServer() { + const noop = () => {}; + return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; +} + +function makeRes() { + const res: any = { + write: () => true, end: () => {}, + header: () => res, + status: (code: number) => { res._status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return res; +} + +describe('[#20846] a date / datetime refused for its year names the years — POST /data/:object and an import row', () => { + let engine: ObjectQL; + let call: (method: string, path: string, body: unknown) => Promise<{ status: number; body: any }>; + + beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as any), true); + await engine.init(); + engine.registry.registerObject(LEDGER as any); + await engine.syncSchemas(); + const protocol = new ObjectStackProtocolImplementation(engine as any); + const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + call = async (method, path, body) => { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + expect(route, `${method} ${path}`).toBeDefined(); + const res = makeRes(); + await route!.handler({ params: { object: OBJECT }, body, query: {}, headers: {} } as any, res); + return { status: res._status ?? 200, body: res._json }; + }; + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it.each(WRITE_REFUSED)('POST %s = %j → 400 VALIDATION_FAILED, invalid_date, { type }, and the sentence', async (field, value, sentence) => { + const res = await call('POST', '/api/v1/data/:object', { id: `w-${field}`, [field]: value }); + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + expect(res.body.fields).toHaveLength(1); + const [finding] = res.body.fields; + expect(finding).toMatchObject({ field, code: 'invalid_date' }); + expect(finding.constraint).toEqual({ type: LEDGER.fields[field as keyof typeof LEDGER.fields].type }); + expect(finding.message).toBe(sentence); + // The envelope's human `error` — what a toast or the CLI shows — leads with the field's sentence. + expect(String(res.body.error).split('; ')[0]).toBe(sentence); + expect(await engine.findOne(OBJECT, { where: { id: `w-${field}` } })).toBeNull(); + }); + + it('an import row gets the same code and sentence, whichever reader refused it — and the edges are stored', async () => { + const csv = ['ID,Placed,Opened', ...IMPORT_ROWS.map(([id, day, at]) => `${id},${day},${at}`)].join('\r\n'); + const res = await call('POST', '/api/v1/data/:object/import', { + format: 'csv', csv, mapping: { ID: 'id', Placed: 'placed_on', Opened: 'opened_at' }, writeMode: 'insert', + }); + expect(res.status ?? 200, JSON.stringify(res.body)).toBe(200); + const refused = IMPORT_ROWS.filter((r) => r[3] !== null); + expect(res.body).toMatchObject({ total: IMPORT_ROWS.length, ok: IMPORT_ROWS.length - refused.length, errors: refused.length }); + for (const [i, [id, day, , sentence]] of IMPORT_ROWS.entries()) { + const row = res.body.results[i]; + if (sentence === null) { + expect(row, id).toMatchObject({ ok: true }); + expect(await engine.findOne(OBJECT, { where: { id } }), id).not.toBeNull(); + continue; + } + expect(row, id).toMatchObject({ ok: false, action: 'failed', code: 'invalid_date', field: day ? 'placed_on' : 'opened_at' }); + expect(row.error, id).toBe(sentence); + expect(await engine.findOne(OBJECT, { where: { id } }), id).toBeNull(); + } + }); +}); diff --git a/packages/rest/src/import-coerce.test.ts b/packages/rest/src/import-coerce.test.ts index dfa6bc1c1f2..b5b41a81773 100644 --- a/packages/rest/src/import-coerce.test.ts +++ b/packages/rest/src/import-coerce.test.ts @@ -498,3 +498,52 @@ describe('coerceRow — cell-coercion messages are localized (#3957)', () => { expect(errors[0].message).toBe('qty:“abc”不是有效的数字'); }); }); + +/** + * [#20846] A `date` / `datetime` cell in a year outside the kind's supported + * years that this reader does not take (`+010000-01-01`, more than four year + * digits) was refused "is not a valid date" — false for an ISO 8601 expanded + * year, and a prompt to re-spell a value no spelling of year 10000 makes + * admissible. It takes the write door's range sentence for its kind, with the + * write door's code, `invalid_date`. A malformed cell keeps the import's own + * sentence (the CONTROL), and so does a number. + */ +describe('[#20846] coerceRow — a readable date / datetime cell outside its years names the years', () => { + const meta = new Map([ + ['due', { name: 'due', type: 'date', label: 'Due' }], + ['at', { name: 'at', type: 'datetime', label: 'At' }], + ]); + const only = async (row: Record, locale?: string) => { + const { errors } = await coerceRow(row, meta, { locale }); + expect(errors).toHaveLength(1); + return errors[0]; + }; + + it('a date or datetime in year 10000, the extended ISO spelling', async () => { + expect(await only({ due: '+010000-01-01' })).toEqual({ + field: 'due', code: 'invalid_date', message: 'Due must be a date in the years 0001 to 9999', + }); + expect(await only({ at: ' +010000-01-01T00:00:00.000Z ' })).toEqual({ + field: 'at', code: 'invalid_date', message: 'At must be a datetime whose UTC year falls in the years 1000 to 9999', + }); + expect((await only({ at: '-000001-01-01T00:00:00.000Z' })).message) + .toBe('At must be a datetime whose UTC year falls in the years 1000 to 9999'); + }); + + it('in the caller\'s locale', async () => { + expect((await only({ due: '+010000-01-01' }, 'zh-CN')).message).toBe('Due必须是 0001 年至 9999 年之间的日期'); + }); + + it('CONTROL — a malformed cell, or a number, keeps the import\'s own sentence', async () => { + expect(await only({ due: 'not-a-date' })).toEqual({ field: 'due', code: 'invalid_date', message: 'Due: "not-a-date" is not a valid date' }); + expect(await only({ at: 'not-a-date' })).toEqual({ field: 'at', code: 'invalid_date', message: 'At: "not-a-date" is not a valid datetime' }); + const y10000 = Date.parse('+010000-01-01T00:00:00.000Z'); + expect((await only({ due: y10000 })).message).toBe(`Due: "${y10000}" is not a valid date`); + }); + + it('CONTROL — a cell this reader takes, in a year outside the kind\'s, passes here and meets the write door', async () => { + const { data, errors } = await coerceRow({ due: '0000-06-15', at: '0500-07-15T10:00:00Z' }, meta, {}); + expect(errors).toEqual([]); + expect(data).toEqual({ due: '0000-06-15', at: '0500-07-15T10:00:00.000Z' }); + }); +}); diff --git a/packages/rest/src/import-coerce.ts b/packages/rest/src/import-coerce.ts index 944f0558988..176016ef715 100644 --- a/packages/rest/src/import-coerce.ts +++ b/packages/rest/src/import-coerce.ts @@ -32,6 +32,8 @@ */ import { + SUPPORTED_TEMPORAL_YEARS, + isOutsideTemporalYearRange, isUninterpretableTemporalComparand, temporalStorageForm, zonedWallClockToUtcMs, @@ -159,7 +161,8 @@ export interface FieldCoerceError { * label and quotes the offending cell — never the API field name (#3957). * * `code` stays the machine identity the importer's row report and its tests key - * off; `messageKey` selects the sentence from the shared catalog. + * off; `messageKey` selects the sentence from the shared catalog, and `params` + * fills its words beyond the cell (#20846: a kind's supported years). */ function coerceError( meta: ExportFieldMeta | undefined, @@ -168,6 +171,7 @@ function coerceError( messageKey: string, value: unknown, ctx: CoerceContext, + params?: Record, ): { error: FieldCoerceError } { const label = meta?.label?.trim() || field; return { @@ -175,7 +179,7 @@ function coerceError( field, code, message: renderValidationMessage( - { messageKey, label, field, params: { value: String(value) } }, + { messageKey, label, field, params: { ...(params ?? {}), value: String(value) } }, { locale: ctx.locale, translate: ctx.translate }, ), }, @@ -457,8 +461,9 @@ function utcClock(t: Date): string { * Returns `undefined` when the cell is not a recognisable date/time, and the * caller fails the row with the write door's code for the same cell: * `invalid_date` for a `date` / `datetime` (`import_invalid_date` / - * `import_invalid_datetime`), `invalid_time` for a `time` - * (`import_invalid_time`). + * `import_invalid_datetime`, or [#20846] the write door's range sentence for a + * cell in a year outside the kind's supported years — {@link outsideYears}), + * `invalid_time` for a `time` (`import_invalid_time`). * * ## Which text is read at all (#20534) * @@ -571,6 +576,32 @@ export function parseDateCell( return utcClock(instant); } +/** + * [#20846] The years a refused `date` / `datetime` cell's range sentence names, + * or `undefined` when the cell gets the import's own "is not a valid date" + * sentence. A cell whose year falls outside the kind's supported years — the + * one range, `@objectstack/core`'s `isOutsideTemporalYearRange`, asked of what + * `Date.parse` reads, as the write door asks it — is refused for its year, + * whatever its spelling: `10000-01-01` and `+010000-01-01` are no shape this + * reader takes, and "is not a valid date" sent their author to re-spell a + * value no spelling of year 10000 makes admissible. A cell this reader DOES + * take in such a year (`0500-07-15T10:00:00Z` for a `datetime`, `0000-06-15`) + * passes here and meets the write door, which names the same years in the same + * words: one sentence per kind for both doors of one import. + * + * The years are core's `SUPPORTED_TEMPORAL_YEARS`, spelled with four digits, + * never a copy of the numbers. A number (an Excel serial, epoch + * milliseconds) is never a temporal cell, whatever year it names, and keeps + * the plain sentence, as the write door keeps it for a number. + */ +function outsideYears(raw: unknown, kind: 'date' | 'datetime'): { firstYear: string; lastYear: string } | undefined { + const value = typeof raw === 'string' ? raw.trim() : raw; + const readable = value instanceof Date || (typeof value === 'string' && !Number.isNaN(Date.parse(value))); + if (!readable || !isOutsideTemporalYearRange(value, kind)) return undefined; + const { first, last } = SUPPORTED_TEMPORAL_YEARS[kind]; + return { firstYear: String(first).padStart(4, '0'), lastYear: String(last).padStart(4, '0') }; +} + // ── options (select / multiselect) ───────────────────────────────── /** @@ -644,9 +675,14 @@ export async function coerceFieldValue( if (d === undefined) { // [#20722] The write door's code for the same cell — `invalid_time` for a // `time`, `invalid_date` for a `date` / `datetime` — and three sentences. + // [#20846] Five: a cell in a year outside a `date`'s or a `datetime`'s + // supported years takes the write door's range sentence for its kind. const code = t === 'time' ? 'invalid_time' : 'invalid_date'; - const key = t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date'; - return coerceError(meta, field, code, key, raw, ctx); + const years = t === 'time' ? undefined : outsideYears(raw, t); + const key = years + ? (t === 'datetime' ? 'invalid_datetime_range' : 'invalid_date_range') + : t === 'datetime' ? 'import_invalid_datetime' : t === 'time' ? 'import_invalid_time' : 'import_invalid_date'; + return coerceError(meta, field, code, key, raw, ctx, years); } return { value: d }; } diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts index fd2f7cb066c..93b06d173a9 100644 --- a/packages/spec/src/system/validation-message.test.ts +++ b/packages/spec/src/system/validation-message.test.ts @@ -72,6 +72,10 @@ describe('validation message catalog — completeness', () => { value_domain_iana_time_zone: ['{{value}}'], value_domain_iso_4217_currency: ['{{value}}'], value_domain_iso_3166_alpha2: ['{{value}}'], + // `invalid_date`'s range sentences (#20846) name the kind's years in + // every locale — from parameters, so the refusing door's range decides them. + invalid_date_range: ['{{firstYear}}', '{{lastYear}}'], + invalid_datetime_range: ['{{firstYear}}', '{{lastYear}}'], }; for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) { for (const [key, placeholders] of Object.entries(required)) { @@ -100,6 +104,12 @@ describe('renderValidationMessage — English output is unchanged (#3957 no-regr ['invalid_date', undefined, 'Amount must be a valid date (ISO-8601)'], ['invalid_datetime', undefined, 'Amount must be a valid datetime (ISO-8601)'], ['invalid_time', undefined, 'Amount must be a valid time (HH:MM or HH:MM:SS)'], + ['invalid_date_range', { firstYear: '0001', lastYear: '9999' }, 'Amount must be a date in the years 0001 to 9999'], + [ + 'invalid_datetime_range', + { firstYear: '1000', lastYear: '9999' }, + 'Amount must be a datetime whose UTC year falls in the years 1000 to 9999', + ], ['invalid_option', { allowed: 'a, b' }, 'Amount must be one of: a, b'], ['invalid_option_value', { value: 'z', allowed: 'a, b' }, 'Amount: "z" is not one of: a, b'], ['invalid_type_array', undefined, 'Amount must be an array of values'], @@ -215,3 +225,31 @@ describe('interpolateValidationMessage', () => { .toBe('Qty (no_such_key)'); }); }); + +/** + * #20846 — a value the kind's rule reads, in a year outside the kind's + * supported years, is refused `invalid_date` with a sentence that names the + * years. The catalog never spells them: a template that carried a number would + * be a second copy of the range the doors judge by, and would go on naming the + * old years the day the range moved. + */ +describe('invalid_date_range / invalid_datetime_range — the years are parameters, never literals', () => { + it('no locale spells a year in the range sentences', () => { + for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) { + for (const key of ['invalid_date_range', 'invalid_datetime_range']) { + expect(catalog[key], `${locale}.${key}`).toBeDefined(); + const withoutPlaceholders = catalog[key].replace(/\{\{\w+\}\}/g, ''); + expect(withoutPlaceholders, `${locale}.${key}`).not.toMatch(/\d/); + } + } + }); + + it('renders the years it is handed, in the caller\'s locale', () => { + expect( + renderValidationMessage( + { messageKey: 'invalid_datetime_range', label: '发生时间', params: { firstYear: '1000', lastYear: '9999' } }, + { locale: 'zh-CN' }, + ), + ).toBe('发生时间必须是 UTC 年份在 1000 年至 9999 年之间的日期时间'); + }); +}); diff --git a/packages/spec/src/system/validation-message.ts b/packages/spec/src/system/validation-message.ts index e3727963e34..a3301b2948b 100644 --- a/packages/spec/src/system/validation-message.ts +++ b/packages/spec/src/system/validation-message.ts @@ -41,6 +41,16 @@ * (`invalid_option_value`, `invalid_datetime`, `invalid_type_array`, …) are * rendering detail and never appear on the wire. * + * `invalid_date_range` / `invalid_datetime_range` (#20846) are two more + * `invalid_date` sentences, for a value the kind's rule reads but whose year + * falls outside the kind's supported years. "Must be a valid date (ISO-8601)" + * is false for such a value — `0500-07-15T10:00:00Z` is valid ISO 8601 — and + * sends its author to rewrite a spelling when the year is what is refused. The + * years come from `{{firstYear}}` / `{{lastYear}}`, which the refusing door + * fills from `@objectstack/core`'s `SUPPORTED_TEMPORAL_YEARS`, the range it + * judges by; ⛔ no template spells the numbers, so the sentence cannot drift + * from the range it names. + * * The `import_*` keys are the CSV/XLSX importer's cell-coercion failures * (`rest/import-coerce.ts`) — "this cell is not a number" rather than "this * value violates a constraint". They live here because they land in the same @@ -55,8 +65,11 @@ * `{{name}}` placeholders, matching `II18nService.t()`'s convention, filled from * the error's `constraint` values (`min`, `maxLength`, `actual`, `allowed`, …) * plus `{{value}}` (the offending value), `{{label}}` (the field's display name - * in the caller's locale) and `{{field}}` (its API name). An unknown placeholder - * is left verbatim so a broken override is visible rather than silently blank. + * in the caller's locale) and `{{field}}` (its API name). A few are filled from + * the refusing door's message-only parameters, which name a fact of the + * platform rather than a constraint of the field and are never shipped on the + * error (`{{firstYear}}` / `{{lastYear}}` above). An unknown placeholder is + * left verbatim so a broken override is visible rather than silently blank. */ import { resolveBundleLocale } from './i18n-resolver'; @@ -102,6 +115,11 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_boolean: '{{label}} must be true or false', invalid_date: '{{label}} must be a valid date (ISO-8601)', invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)', + // `invalid_date`'s range sentences (#20846): a value the kind's rule reads, + // in a year outside the kind's supported years. The years are parameters + // the refusing door fills from the range it judges by, never literals here. + invalid_date_range: '{{label}} must be a date in the years {{firstYear}} to {{lastYear}}', + invalid_datetime_range: '{{label}} must be a datetime whose UTC year falls in the years {{firstYear}} to {{lastYear}}', invalid_time: '{{label}} must be a valid time (HH:MM or HH:MM:SS)', // `invalid_time`'s second sentence: a time of day written with a `Z` or an // offset. A `time` field is a zone-less wall clock, so the sentence says @@ -152,6 +170,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_boolean: '{{label}}必须是 true 或 false', invalid_date: '{{label}}必须是有效的日期(ISO-8601)', invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)', + invalid_date_range: '{{label}}必须是 {{firstYear}} 年至 {{lastYear}} 年之间的日期', + invalid_datetime_range: '{{label}}必须是 UTC 年份在 {{firstYear}} 年至 {{lastYear}} 年之间的日期时间', invalid_time: '{{label}}必须是有效的时间(HH:MM 或 HH:MM:SS)', invalid_time_zoned: '{{label}}是不带时区的时刻:请去掉 Z 或时区偏移(HH:MM 或 HH:MM:SS),表示时间点请改用日期时间字段', invalid_option: '{{label}}必须是以下值之一:{{allowed}}', @@ -192,6 +212,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_boolean: '{{label}}は true または false で入力してください', invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください', invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください', + invalid_date_range: '{{label}}は {{firstYear}} 年から {{lastYear}} 年までの日付を入力してください', + invalid_datetime_range: '{{label}}は UTC の年が {{firstYear}} 年から {{lastYear}} 年までの日時を入力してください', invalid_time: '{{label}}は有効な時刻(HH:MM または HH:MM:SS)を入力してください', invalid_time_zoned: '{{label}}はタイムゾーンを持たない時刻です。Z やオフセットを外す(HH:MM または HH:MM:SS)か、時点を表すには日時フィールドを使ってください', invalid_option: '{{label}}は次のいずれかを指定してください:{{allowed}}', @@ -232,6 +254,8 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> invalid_boolean: '{{label}} debe ser true o false', invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)', invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)', + invalid_date_range: '{{label}} debe ser una fecha entre los años {{firstYear}} y {{lastYear}}', + invalid_datetime_range: '{{label}} debe ser una fecha y hora cuyo año UTC esté entre los años {{firstYear}} y {{lastYear}}', invalid_time: '{{label}} debe ser una hora válida (HH:MM o HH:MM:SS)', invalid_time_zoned: '{{label}} es una hora del día sin zona horaria: quite la Z o el desfase (HH:MM o HH:MM:SS), o use un campo de fecha y hora para un instante', invalid_option: '{{label}} debe ser uno de: {{allowed}}',