diff --git a/.changeset/20771-wall-clock-one-rule.md b/.changeset/20771-wall-clock-one-rule.md new file mode 100644 index 00000000000..c290b0538b7 --- /dev/null +++ b/.changeset/20771-wall-clock-one-rule.md @@ -0,0 +1,14 @@ +--- +'@objectstack/core': patch +--- + +`isUninterpretableTemporalComparand` reads a bare wall clock on a `time` column by the spec's `ClockTimeValueSchema` (`@objectstack/spec/data`), not by a private copy of it (#20771) + +Clause-②: no + +The wall-clock half of core's `time` rule (`HH:MM[:SS[.fraction]]`, hours 00 to 23, minutes and seconds 00 to 59, no time zone) was spelled twice: once as the spec's `ClockTimeValueSchema`, the stored form of a `time` value, and once as a private regex in `@objectstack/core`. The two admitted the same strings, but nothing tied them together, so an edit to either one changed one side only. Core now asks the spec schema. Every caller of `isUninterpretableTemporalComparand('time', …)` therefore answers from the rule the spec's `time` default gate uses: the engine's temporal-comparand door, the analytics comparand check, the record validator's `time` arm and the import's `time` coercion. + +Unchanged: + +- Every string gets the verdict it got before. Measured over 8,655,360 generated strings: 8,640 read by both the old regex and the schema, the rest refused by both, 0 answered differently. +- An instant, a number or a `Date` on a `time` column is judged as before. diff --git a/packages/core/src/utils/temporal-comparand.test.ts b/packages/core/src/utils/temporal-comparand.test.ts index 0f5acedd1f6..c23ba3dbd5f 100644 --- a/packages/core/src/utils/temporal-comparand.test.ts +++ b/packages/core/src/utils/temporal-comparand.test.ts @@ -372,3 +372,21 @@ describe('[#20480] isUninterpretableTemporalComparand — a time column\'s insta } }); }); + +// [#20771] The bare-wall-clock half of the `time` rule is the spec's +// `ClockTimeValueSchema` (`@objectstack/spec/data`), not a second regex in +// core: the private `readsAsWallClock` asks that schema. These are the +// boundary cases on each side, asserted through the one public door that +// reaches `readsAsWallClock`. None of them is an ISO instant, so the +// instant half of the rule cannot rescue one: the verdict is the wall-clock +// half's alone. +describe('[#20771] isUninterpretableTemporalComparand — the wall-clock half is the spec\'s time-of-day rule', () => { + it('reads the last wall clock of the day, to the fraction', () => { + expect(isUninterpretableTemporalComparand('time', '23:59:59.999')).toBe(false); + }); + + it('refuses the hour past the last one, and a time of day with a zone', () => { + expect(isUninterpretableTemporalComparand('time', '24:00')).toBe(true); + expect(isUninterpretableTemporalComparand('time', '10:00Z')).toBe(true); + }); +}); diff --git a/packages/core/src/utils/temporal-comparand.ts b/packages/core/src/utils/temporal-comparand.ts index d66544bdf25..53b62edb323 100644 --- a/packages/core/src/utils/temporal-comparand.ts +++ b/packages/core/src/utils/temporal-comparand.ts @@ -134,7 +134,7 @@ * test), so it is left alone too. */ -import { classifyFilterToken } from '@objectstack/spec/data'; +import { ClockTimeValueSchema, classifyFilterToken } from '@objectstack/spec/data'; import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js'; /** Which temporal storage rule a declared field takes. */ @@ -246,11 +246,16 @@ function readsAsCalendarDay(s: string): boolean { * `temporalStorageForm`'s `time` reading: a bare wall clock whose components are in * range. Out-of-range (`25:00`) is uninterpretable — the rule it mirrors * returns such a value untouched rather than wrapping it. + * + * [#20771] The set is the spec's: `ClockTimeValueSchema`, the `time` stored + * form — `HH:MM[:SS[.fraction]]`, hours 00..23, minutes and seconds 00..59, no + * time zone (ADR-0053 D-C1). This function asks that schema rather than + * spelling a second regex, so the comparand door, the record validator's + * `time` arm and the spec's `time` default gate answer from one rule and + * cannot drift apart on an edit to either side. */ function readsAsWallClock(s: string): boolean { - const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s); - if (!m) return false; - return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? '0') <= 59; + return ClockTimeValueSchema.safeParse(s).success; } /**