Skip to content

Commit 6b004c0

Browse files
fix(core): readsAsWallClock answers from the spec's ClockTimeValueSchema (#20885)
Fixes #20771 Clause-②: no `readsAsWallClock`, the private wall-clock half of core's `time` rule in `packages/core/src/utils/temporal-comparand.ts`, now answers `ClockTimeValueSchema.safeParse(s).success`. That schema is the spec's `time` stored form from `@objectstack/spec/data`. The private regex is gone, and no second regex was written. Core already imported `@objectstack/spec/data` in this file, so no dependency edge was added. **Shape taken: delegation, not the fallback.** `packages/spec` is untouched, and no spec export was added, so `Clause-②: no` holds. The parse path was measured before choosing (below): it costs about 30 ns more per accepted string and about 1.2 µs more per refused one. At realistic counts that is microseconds per request, so the fallback (a spec-exported regex) was not needed. ## Measurements (scratch scripts, not committed) - **Same boolean for every string.** 8,655,360 generated strings: hours in 1-, 2- and 3-digit spellings plus non-ASCII digits, `:` or `.` separators, minutes in 1- and 2-digit spellings, 12 seconds/fraction variants, 14 suffixes (`Z`, `z`, `+00:00`, `+0000`, `-00:00`, `+08:00`, `-0530`, `+2359`, `+24:00`, a space, a newline, `T`, a letter, none), with and without a leading space. The old private regex (at base `6c96b37be8`) and `ClockTimeValueSchema.safeParse` agreed on all of them: 8,640 accepted by both, 8,646,720 refused by both, 0 by one side only. The schema is a bare `z.string().regex(...)`, with no transform, refinement or brand. - **Cost.** 2,000,000 calls per leg, three rounds. Accepted strings: old 134 to 154 ns, new 169 to 173 ns. Refused strings: old 41 to 43 ns, new 1,215 to 1,303 ns (zod builds the issue list). On a mixed sample (half accepted, half refused): old 94 to 124 ns, new 734 to 796 ns. For scale, 20 time comparands in one request cost about 15 µs in total at the mixed rate. - **Where it runs.** `isUninterpretableTemporalComparand('time', ...)` is asked per comparand by the engine's comparand door and the analytics check, and also per written value by the record validator's `time` arm and per cell by the import's `time` coercion. So it is not only a per-request cost. At the mixed rate, 10,000 rows with 5 `time` fields cost about 33 ms more (39 ms in total). At the accepted rate, which is what a valid import mostly hits, they cost about 1.5 ms more. - **Lean entry.** At `5d3ff119cf`, `check:lean-entry-closure` is green: `@objectstack/objectql/core` keeps its 15 admitted packages (import 201 modules, require 199). `check:dts-closure` is green: 71/71 declaration files. The import names a specifier core already loads. ## Pins In `packages/core/src/utils/temporal-comparand.test.ts`, block `[#20771]`: - `23:59:59.999` is read; - `24:00` and `10:00Z` are refused. They are asserted through `isUninterpretableTemporalComparand('time', ...)`, because `readsAsWallClock` is private. None of the three strings is an ISO instant, so the instant half cannot rescue one, and the verdict is the wall-clock half's alone. Exporting `readsAsWallClock` would widen core's public barrel (`export *` from this module) and turn this into `Clause-②: yes`. The existing temporal-comparand pins are unchanged and green. ## Ablation The implementation was committed first (`45ba267683`). Every leg went through `scripts/ablation-replace.mjs` in wrap mode. - **Core side, source-resolved (the test imports core src relatively).** - `readsAsWallClock` widened to a bare `HH:MM` prefix test: 1 red, the `24:00` / `10:00Z` pin. - `readsAsWallClock` made to answer false: 4 red. They are the `23:59:59.999` pin and three existing wall-clock pins (`[#20264]`, `[#20549]`, `[#20480]`). - Each was restored: the blob equals HEAD and `git diff HEAD` is empty. - **Spec side, dist-resolved.** This leg proves the delegation is live. - `ClockTimeValueSchema` was made to accept a trailing `Z` again, and spec was rebuilt. `scripts/ablation-dist-preflight.mjs` found the marker in 28 built files. - Core's `10:00Z` pin went red (1 failed, 25 passed). - Control, in the same mutated-spec window: core's function was put back to the old private regex, and all 26 passed. The old shape could not see an edit to the spec; the new one can. - Restore: the source was restored, spec was rebuilt, and the preflight found the marker in none of 230 files. The tree is clean, and all 26 passed. ## Verification At head `5d3ff119cf`, which includes a merge of `origin/main` `2d5fe76f43`: - `pnpm --filter @objectstack/core test`: 61 files, 1795 passed. - `pnpm --filter @objectstack/core typecheck`: exit 0. - objectql `engine-time-write-zone-less` and `engine-temporal-comparand-door`: 19 passed, against core dist rebuilt at this head. - `dispatch-gates --commands`: 61 families. `--ran`: 59 run with exit 0, 2 NOT MEASURED, 0 unrun. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, because their prerequisite is every workspace package built, which is beyond the foreground cap. CI builds that closure. - eslint, narrowed to the 2 changed `.ts` files with `eslint --no-inline-config --format json`: 2 files, 0 errors, 0 warnings. - The population comes from `eslint.config.mjs`: its `**/*.{ts,...}` and `packages/**` blocks. `--print-config` resolves both files, so neither is ignored. - The config enables no type-aware linting. `parserOptions` is only `ecmaVersion` and `sourceType`, so this diff cannot move a verdict on an untouched file. ## Acceptance notes - `packages/core/src/utils/temporal-storage-form.ts` `canonicalTimeOfDay` carries its own wall-clock pattern with capture groups. It needs the groups to write `HH:MM:SS`, so `safeParse` cannot replace it. That is the storage rule `readsAsWallClock` mirrors. Today it admits the same set. It is outside this card's file surface, so it is recorded here and not touched. Carrier: none. - objectql's `isZonedTimeOfDay` (record validator) spells a zone-suffix pattern, but it judges the wall-clock half through `isUninterpretableTemporalComparand('time', ...)`, so it now reaches the spec rule too. --- _Generated by [Claude Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27bf358 commit 6b004c0

3 files changed

Lines changed: 41 additions & 4 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
`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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
Unchanged:
12+
13+
- 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.
14+
- An instant, a number or a `Date` on a `time` column is judged as before.

‎packages/core/src/utils/temporal-comparand.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,21 @@ describe('[#20480] isUninterpretableTemporalComparand — a time column\'s insta
372372
}
373373
});
374374
});
375+
376+
// [#20771] The bare-wall-clock half of the `time` rule is the spec's
377+
// `ClockTimeValueSchema` (`@objectstack/spec/data`), not a second regex in
378+
// core: the private `readsAsWallClock` asks that schema. These are the
379+
// boundary cases on each side, asserted through the one public door that
380+
// reaches `readsAsWallClock`. None of them is an ISO instant, so the
381+
// instant half of the rule cannot rescue one: the verdict is the wall-clock
382+
// half's alone.
383+
describe('[#20771] isUninterpretableTemporalComparand — the wall-clock half is the spec\'s time-of-day rule', () => {
384+
it('reads the last wall clock of the day, to the fraction', () => {
385+
expect(isUninterpretableTemporalComparand('time', '23:59:59.999')).toBe(false);
386+
});
387+
388+
it('refuses the hour past the last one, and a time of day with a zone', () => {
389+
expect(isUninterpretableTemporalComparand('time', '24:00')).toBe(true);
390+
expect(isUninterpretableTemporalComparand('time', '10:00Z')).toBe(true);
391+
});
392+
});

‎packages/core/src/utils/temporal-comparand.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@
134134
* test), so it is left alone too.
135135
*/
136136

137-
import { classifyFilterToken } from '@objectstack/spec/data';
137+
import { ClockTimeValueSchema, classifyFilterToken } from '@objectstack/spec/data';
138138
import { isOutsideTemporalYearRange, temporalStorageForm } from './temporal-storage-form.js';
139139

140140
/** Which temporal storage rule a declared field takes. */
@@ -246,11 +246,16 @@ function readsAsCalendarDay(s: string): boolean {
246246
* `temporalStorageForm`'s `time` reading: a bare wall clock whose components are in
247247
* range. Out-of-range (`25:00`) is uninterpretable — the rule it mirrors
248248
* returns such a value untouched rather than wrapping it.
249+
*
250+
* [#20771] The set is the spec's: `ClockTimeValueSchema`, the `time` stored
251+
* form — `HH:MM[:SS[.fraction]]`, hours 00..23, minutes and seconds 00..59, no
252+
* time zone (ADR-0053 D-C1). This function asks that schema rather than
253+
* spelling a second regex, so the comparand door, the record validator's
254+
* `time` arm and the spec's `time` default gate answer from one rule and
255+
* cannot drift apart on an edit to either side.
249256
*/
250257
function readsAsWallClock(s: string): boolean {
251-
const m = /^(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(s);
252-
if (!m) return false;
253-
return Number(m[1]) <= 23 && Number(m[2]) <= 59 && Number(m[3] ?? '0') <= 59;
258+
return ClockTimeValueSchema.safeParse(s).success;
254259
}
255260

256261
/**

0 commit comments

Comments
 (0)